diff --git a/.github/workflows/flaky-test-detection.yml b/.github/workflows/flaky-test-detection.yml new file mode 100644 index 0000000..4031928 --- /dev/null +++ b/.github/workflows/flaky-test-detection.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2eb5bcc..20f91ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: ` 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/ + 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 diff --git a/__tests__/auth-api.test.ts b/__tests__/auth-api.test.ts new file mode 100644 index 0000000..10d2d57 --- /dev/null +++ b/__tests__/auth-api.test.ts @@ -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'); + }); + }); +}); diff --git a/__tests__/notifications-api.test.ts b/__tests__/notifications-api.test.ts new file mode 100644 index 0000000..9cbfadf --- /dev/null +++ b/__tests__/notifications-api.test.ts @@ -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([]); + }); +}); diff --git a/__tests__/reminders.test.ts b/__tests__/reminders.test.ts index 6c5369c..5ab2177 100644 --- a/__tests__/reminders.test.ts +++ b/__tests__/reminders.test.ts @@ -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'; @@ -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 () => { diff --git a/docs/architecture.md b/docs/architecture.md index d44c133..facb709 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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=` 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