Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/flaky-test-detection.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Scheduled Flaky Test Detection

on:
schedule:
# Run every Sunday at 03:00 UTC
- cron: '0 3 * * 0'
workflow_dispatch:

jobs:
flaky-test-detection:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run Test Suite Iteration 1
run: npm test -- --run

- name: Run Test Suite Iteration 2
run: npm test -- --run

- name: Run Test Suite Iteration 3
run: npm test -- --run
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,19 @@ Note: a number of component tests still live as flat files directly under
only governs where _new_ tests should go. Bulk migration to colocation is a
candidate for a future dedicated issue.

#### Flaky Test Detection & Quarantine Process

- **Automated Detection**: A scheduled CI workflow (`flaky-test-detection.yml`) runs the full test suite 3× sequentially on a weekly schedule (every Sunday at 03:00 UTC) to identify intermittent test failures without burdening per-PR CI run times.
- **Quarantining a Flaky Test**:
1. Open a GitHub Issue titled `flaky: <Test Description / Suite Name>` detailing the failure log and frequency.
2. Mark the flaky test using `.skip` (e.g. `it.skip(...)` or `describe.skip(...)`) in code.
3. Include a comment above the `.skip` referencing the tracking issue URL:
```typescript
// Quarantined due to flakiness - see https://github.com/Invoice-Liquidity-Network/ILN-Frontend/issues/<issue_number>
it.skip('handles dynamic timer updates without race conditions', () => { ... });
```
4. Fix the underlying timing or async race condition in a follow-up PR and remove `.skip`.

#### End-to-End Tests (Playwright)

```bash
Expand Down
84 changes: 84 additions & 0 deletions __tests__/auth-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from '../app/api/auth/challenge';
import { POST } from '../app/api/auth/verify';
import { Keypair } from '@stellar/stellar-sdk';

describe('/api/auth Integration Tests', () => {
const validPublicKey = 'GD6WACCBH6M6V45TXWZ6T375LMDRCFEHQVQLJLT3GMS6I4DZEATSTJZZ';

beforeEach(() => {
vi.clearAllMocks();
});

describe('GET /api/auth/challenge (SEP-10 Challenge)', () => {
it('returns 400 if account query parameter is missing', async () => {
const req = new NextRequest('http://localhost/api/auth/challenge');
const response = await GET(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe("Missing 'account' parameter");
});

it('returns 400 if account public key is invalid', async () => {
const req = new NextRequest('http://localhost/api/auth/challenge?account=invalid_key');
const response = await GET(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe('Invalid public key format');
});

it('returns 200 with a valid XDR challenge transaction for valid public key', async () => {
const req = new NextRequest(`http://localhost/api/auth/challenge?account=${validPublicKey}`);
const response = await GET(req);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.challenge).toBeDefined();
expect(typeof body.challenge).toBe('string');
});
});

describe('POST /api/auth/verify (SEP-10 Verification)', () => {
it('returns 400 if account or transaction is missing from request body', async () => {
const req = new NextRequest('http://localhost/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ account: validPublicKey }),
});

const response = await POST(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe("Missing 'account' or 'transaction' in request body");
});

it('returns 400 if account is not a valid Ed25519 public key', async () => {
const req = new NextRequest('http://localhost/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ account: 'invalid_key', transaction: 'tx_xdr' }),
});

const response = await POST(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe('Invalid public key format');
});

it('returns 400 if transaction parsing/verification fails', async () => {
const req = new NextRequest('http://localhost/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ account: validPublicKey, transaction: 'invalid_xdr_payload' }),
});

const response = await POST(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe('Invalid signed transaction');
});
});
});
47 changes: 47 additions & 0 deletions __tests__/notifications-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from '../app/api/notifications/[address]/route';
import { getNotifications } from '@/lib/notifications';

vi.mock('@/lib/notifications', () => ({
getNotifications: vi.fn(),
}));

describe('/api/notifications/[address] API route', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns notifications for a valid wallet address', async () => {
const mockNotifications = [
{
id: 'notif-1',
type: 'INVOICE_PAID',
message: 'Invoice #101 has been paid',
timestamp: '2026-07-25T12:00:00Z',
read: false,
},
];

vi.mocked(getNotifications).mockResolvedValue(mockNotifications);

const req = new NextRequest('http://localhost/api/notifications/GABC123');
const response = await GET(req, { params: Promise.resolve({ address: 'GABC123' }) });
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual(mockNotifications);
expect(getNotifications).toHaveBeenCalledWith('GABC123');
});

it('gracefully degrades to empty array when backing store throws error', async () => {
vi.mocked(getNotifications).mockRejectedValue(new Error('Supabase store unavailable'));

const req = new NextRequest('http://localhost/api/notifications/GABC123');
const response = await GET(req, { params: Promise.resolve({ address: 'GABC123' }) });
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual([]);
});
});
100 changes: 98 additions & 2 deletions __tests__/reminders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,50 @@ describe('/api/reminders API route', () => {
{ onConflict: 'address' }
);
});

it('should return 400 if address or email is missing', async () => {
const req = new NextRequest('http://localhost/api/reminders', {
method: 'POST',
body: JSON.stringify({ address: 'GABC123' }),
});

const response = await POST(req);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe('Address and email are required');
});

it('should return 500 when Supabase upsert encounters an error', async () => {
mockSupabase.upsert.mockResolvedValue({ error: new Error('Database error') });

const req = new NextRequest('http://localhost/api/reminders', {
method: 'POST',
body: JSON.stringify({ address: 'GABC123', email: 'test@example.com' }),
});

const response = await POST(req);
const body = await response.json();

expect(response.status).toBe(500);
expect(body.error).toBe('Failed to save preference');
});
});

describe('GET handler (Cron Trigger)', () => {
it('should send a reminder email for an invoice due in 72 hours', async () => {
it('should return 401 Unauthorized if Bearer token is missing or invalid', async () => {
const req = new NextRequest('http://localhost/api/reminders', {
headers: { authorization: 'Bearer invalid-secret' },
});

const response = await GET(req);
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Unauthorized');
});

it('should send a reminder email with List-Unsubscribe header for an invoice due in 72 hours', async () => {
const now = Math.floor(Date.now() / 1000);
const dueIn71Hours = now + 71 * 3600;
const payerAddress = 'GPA123';
Expand Down Expand Up @@ -136,7 +176,63 @@ describe('/api/reminders API route', () => {
expect(body.sentCount).toBe(1);

const resendInstance = new Resend();
expect(resendInstance.emails.send).toHaveBeenCalled();
expect(resendInstance.emails.send).toHaveBeenCalledWith(
expect.objectContaining({
to: ['payer@example.com'],
headers: expect.objectContaining({
'List-Unsubscribe': expect.stringContaining('/api/reminders/unsubscribe?address=GPA123'),
}),
})
);
});

it('should handle Resend API delivery failures gracefully without inserting log', async () => {
const now = Math.floor(Date.now() / 1000);
const dueIn71Hours = now + 71 * 3600;
const payerAddress = 'GPA123';

const resendInstance = new Resend();
vi.mocked(resendInstance.emails.send).mockResolvedValueOnce({
data: null,
error: { name: 'resend_error', message: 'API key invalid' },
});

mockSupabase.eq.mockResolvedValueOnce({
data: [{ address: payerAddress, email: 'payer@example.com', enabled: true }],
error: null,
});

vi.mocked(soroban.getAllInvoices).mockResolvedValue([
{
id: 103n,
payer: payerAddress,
status: 'Funded',
due_date: BigInt(dueIn71Hours),
token: 'USDC_CONTRACT',
amount: 500000000n,
freelancer: 'GFL123',
discount_rate: 0,
},
]);

vi.mocked(soroban.getTokenMetadata).mockResolvedValue({
contractId: 'USDC_CONTRACT',
symbol: 'USDC',
decimals: 7,
name: 'USD Coin',
});

mockSupabase.maybeSingle.mockResolvedValue({ data: null, error: null });

const req = new NextRequest('http://localhost/api/reminders', {
headers: { authorization: 'Bearer test-secret' },
});

const response = await GET(req);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.sentCount).toBe(0);
});

it('should not send duplicate emails for the same milestone', async () => {
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,11 @@ API routes are used for server-only integration points:
- `app/api/reminders/route.ts` reads Supabase reminder preferences and sends Resend emails when authorized by `CRON_SECRET`.
- `app/api/reminders/unsubscribe/route.ts` updates reminder preferences.
- `app/api/feedback/route.ts` can create GitHub issues from app feedback when GitHub credentials are configured.
- `app/api/auth/challenge.ts` and `app/api/auth/verify.ts` support SEP-10-style auth helpers.
- `app/api/auth/challenge.ts` and `app/api/auth/verify.ts` support SEP-10-style auth helpers:
- `GET /api/auth/challenge?account=<public_key>` generates a timebounded, signed Stellar transaction challenge.
- `POST /api/auth/verify` validates the signed transaction payload and issues a 24-hour JWT authentication token.
- `app/api/notifications/[address]/route.ts` bridges notification reads by address.

Contributor references for these server integrations live in [docs/supabase-setup.md](supabase-setup.md), [docs/feature-flags.md](feature-flags.md), [docs/api-routes.md](api-routes.md), and [docs/testing.md](testing.md).
- Contributor references for these server integrations live in [docs/supabase-setup.md](supabase-setup.md), [docs/feature-flags.md](feature-flags.md), [docs/api-routes.md](api-routes.md), and [docs/testing.md](testing.md).

## State, Providers, and UI Boundaries

Expand Down
Loading