diff --git a/app/frontend/e2e/claim-receipt-fcp.spec.ts b/app/frontend/e2e/claim-receipt-fcp.spec.ts new file mode 100644 index 00000000..81c5313a --- /dev/null +++ b/app/frontend/e2e/claim-receipt-fcp.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, type CDPSession } from '@playwright/test'; + +// Chrome DevTools' "Slow 3G" network preset — the standard baseline for +// judging first-contentful-paint on weak mobile networks (see issue #292). +const SLOW_3G = { + offline: false, + downloadThroughput: (500 * 1024) / 8, // 500 kb/s + uploadThroughput: (500 * 1024) / 8, + latency: 400, // ms +}; + +const FCP_BUDGET_MS = 1000; + +async function throttleToSlow3G(client: CDPSession) { + await client.send('Network.enable'); + await client.send('Network.emulateNetworkConditions', SLOW_3G); + // A mid-tier/low-end phone CPU is part of "weak mobile networks" in the + // field — 4x slowdown approximates a budget Android device. + await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); +} + +async function getFirstContentfulPaint(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const entry = performance + .getEntriesByType('paint') + .find((e) => e.name === 'first-contentful-paint'); + return entry ? entry.startTime : null; + }); +} + +test.describe('claim-receipt SSR performance', () => { + test('renders first contentful paint under 1s on a throttled 3G profile', async ({ + page, + }) => { + const client = await page.context().newCDPSession(page); + await throttleToSlow3G(client); + + await page.goto('/en/claim-receipt?claimId=mock-claim-123', { + waitUntil: 'load', + }); + + // The receipt heading should already be present in the server-rendered + // HTML — if this fails, the page fell back to a client-side loading + // spinner instead of SSR-ing the claim data. + await expect(page.getByRole('heading', { name: 'Claim Receipt' })).toBeVisible(); + + const fcp = await getFirstContentfulPaint(page); + expect(fcp, 'first-contentful-paint entry should exist').not.toBeNull(); + expect(fcp as number).toBeLessThan(FCP_BUDGET_MS); + }); + + test('server-renders the receipt body without a client-side loading state', async ({ + page, + }) => { + // No throttling here — this is a functional check that the HTML + // Playwright receives on first navigation already contains the claim + // amount, rather than "Loading your receipt…" from a CSR fetch. + const response = await page.goto('/en/claim-receipt?claimId=mock-claim-123'); + const html = await response?.text(); + + expect(html).not.toContain('Loading your receipt'); + expect(html).toContain('Claim Receipt'); + }); +}); diff --git a/app/frontend/jest.config.ts b/app/frontend/jest.config.ts index 79d1967c..7e325e06 100644 --- a/app/frontend/jest.config.ts +++ b/app/frontend/jest.config.ts @@ -6,6 +6,11 @@ const config: Config = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + // Playwright's own test runner owns everything under e2e/ (see + // playwright.config.ts's testDir) — Jest's default testMatch would + // otherwise pick up *.spec.ts files there too and fail, since Playwright + // tests can only run via `playwright test`, not `jest`. + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], }; export default config; diff --git a/app/frontend/package.json b/app/frontend/package.json index 00c71291..464d49cb 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "jest", + "test:e2e": "playwright test", "type-check": "tsc --noEmit", "generate:api": "openapi-typescript openapi.json -o src/lib/generated/api.ts" }, @@ -37,6 +38,7 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", diff --git a/app/frontend/playwright.config.ts b/app/frontend/playwright.config.ts new file mode 100644 index 00000000..9ca4fc5b --- /dev/null +++ b/app/frontend/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.E2E_PORT ?? '3100'; +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // Uses the production build so numbers reflect a real SSR response, + // not the dev server's on-demand compilation. + command: 'npm run build && npm run start -- -p ' + PORT, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000', + NEXT_PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet', + NEXT_PUBLIC_USE_MOCKS: process.env.NEXT_PUBLIC_USE_MOCKS ?? 'true', + }, + }, +}); diff --git a/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx b/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx new file mode 100644 index 00000000..f8fc8236 --- /dev/null +++ b/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx @@ -0,0 +1,13 @@ +'use client'; +import { useRouter } from 'next/navigation'; +export function BackButton() { + const router = useRouter(); + return ( + + ); +} diff --git a/app/frontend/src/app/[locale]/claim-receipt/page.tsx b/app/frontend/src/app/[locale]/claim-receipt/page.tsx index d007395c..8a12be44 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -1,105 +1,78 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import React from 'react'; +import { AlertCircle } from 'lucide-react'; import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt'; -import { AlertCircle, Loader2 } from 'lucide-react'; -import { LiveRegion } from '@/components/LiveRegion'; -import { getStatusTransitionMessage } from '@/lib/status-messages'; - -export default function ClaimReceiptPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const claimId = searchParams.get('claimId'); - - const [claim, setClaim] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const previousClaimRef = React.useRef(null); - const [announcement, setAnnouncement] = useState(''); +import { apiClient } from '@/lib/api-client'; +import { BackButton } from './BackButton'; + +// This page is backed by /api/v1/claims/{id}/receipt, which is per-recipient +// data guarded by the backend's HttpCacheInterceptor (private, must-revalidate — +// see #32). It must be rendered fresh on every request rather than statically +// optimized, so the server always re-validates against that header instead of +// serving a stale Next.js Data/Full Route Cache entry. +export const dynamic = 'force-dynamic'; + +interface PageProps { + searchParams: Promise<{ claimId?: string }>; +} - useEffect(() => { - if (previousClaimRef.current && claim && previousClaimRef.current.status !== claim.status) { - setAnnouncement(getStatusTransitionMessage('Claim', previousClaimRef.current.status, claim.status)); - } - previousClaimRef.current = claim; - }, [claim]); +function ErrorState({ message }: { message: string }) { + return ( +
+ +
+

+ Error +

+

{message}

+
+
+ ); +} - useEffect(() => { - if (!claimId) { - setError('Claim ID not provided'); - setLoading(false); - return; +async function loadClaim( + claimId: string, +): Promise<{ claim: ClaimReceiptData | null; error: string | null }> { + try { + const { data, error, response } = await apiClient.GET( + '/api/v1/claims/{id}/receipt', + { + params: { path: { id: claimId } }, + // Never let the Next.js server-side data cache serve a stale receipt — + // the backend's own Cache-Control header (private, must-revalidate) + // governs revalidation once it reaches the browser/CDN. + cache: 'no-store', + } as Parameters[1], + ); + + if (error || !data) { + if (response?.status === 404) { + return { claim: null, error: 'Claim receipt not found' }; + } + return { claim: null, error: 'Failed to load claim receipt' }; } - const loadClaim = async () => { - try { - setLoading(true); - // TODO: Replace with actual API call - // const response = await fetch(`/api/claims/${claimId}/receipt`); - // if (!response.ok) throw new Error('Failed to load receipt'); - // const data: ClaimReceiptData = await response.json(); - - // Mock data for now - const data: ClaimReceiptData = { - claimId, - packageId: 'pkg-' + Math.random().toString(36).substr(2, 9), - status: 'disbursed', - amount: 150.5, - tokenAddress: - 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN', - transactionHash: - '439d564ab3b4df9d1d1f057bb081f9a26be4cd8cf9d564ab3b4df9d1d1f057bb', - contractAddress: - 'CDA4BEYKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN', - timestamp: new Date().toISOString(), - }; - - setClaim(data); - setError(null); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to load claim receipt', - ); - } finally { - setLoading(false); - } + return { claim: data as ClaimReceiptData, error: null }; + } catch (err) { + return { + claim: null, + error: err instanceof Error ? err.message : 'Failed to load claim receipt', }; + } +} - void loadClaim(); - }, [claimId]); - - const handleShare = async () => { - if (!claim) return; +export default async function ClaimReceiptPage({ searchParams }: PageProps) { + const { claimId } = await searchParams; - try { - // Try Web Share API first - if (navigator.share) { - await navigator.share({ - title: 'Claim Receipt', - text: `Claim ${claim.claimId} - ${claim.status}`, - }); - } - } catch (err) { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Share failed:', err); - } - } - }; + const { claim, error } = claimId + ? await loadClaim(claimId) + : { claim: null, error: 'Claim ID not provided' }; return (
-
{/* Header */}
- +

Claim Receipt

@@ -108,47 +81,13 @@ export default function ClaimReceiptPage() {

- {/* Loading State */} - {loading && ( -
-
- )} - {/* Error State */} - {error && ( -
-
- )} + {error && } {/* Receipt Card */} - {!loading && claim && ( + {!error && claim && (
- + {/* Additional Information */}
diff --git a/app/frontend/src/components/ClaimReceipt.tsx b/app/frontend/src/components/ClaimReceipt.tsx index 70856148..b77d51e9 100644 --- a/app/frontend/src/components/ClaimReceipt.tsx +++ b/app/frontend/src/components/ClaimReceipt.tsx @@ -9,7 +9,13 @@ import { buildExplorerUrl } from '../lib/explorer'; export interface ClaimReceiptData { claimId: string; packageId: string; - status: 'requested' | 'verified' | 'approved' | 'disbursed' | 'archived'; + status: + | 'requested' + | 'verified' + | 'approved' + | 'disbursed' + | 'archived' + | 'cancelled'; amount: number; tokenAddress?: string; transactionHash?: string; @@ -39,6 +45,7 @@ export const ClaimReceipt: React.FC = ({ approved: 'bg-green-50 border-green-200 text-green-900', disbursed: 'bg-emerald-50 border-emerald-200 text-emerald-900', archived: 'bg-gray-50 border-gray-200 text-gray-900', + cancelled: 'bg-red-50 border-red-200 text-red-900', }; const statusBadgeColors = { @@ -47,6 +54,7 @@ export const ClaimReceipt: React.FC = ({ approved: 'bg-green-100 text-green-800', disbursed: 'bg-emerald-100 text-emerald-800', archived: 'bg-gray-100 text-gray-800', + cancelled: 'bg-red-100 text-red-800', }; const formattedDate = useMemo(() => { diff --git a/app/frontend/src/lib/mock-api/client.ts b/app/frontend/src/lib/mock-api/client.ts index 44f33c04..58594934 100644 --- a/app/frontend/src/lib/mock-api/client.ts +++ b/app/frontend/src/lib/mock-api/client.ts @@ -38,6 +38,16 @@ export async function fetchClient( await new Promise((resolve) => setTimeout(resolve, 500)); return handlers['/campaigns/:id'](urlString, init); } + + // Support dynamic claim receipt endpoints like /api/v1/claims/:id/receipt + if ( + /^\/api\/v1\/claims\/[^/]+\/receipt$/.test(pathWithoutQuery) && + handlers['/api/v1/claims/:id/receipt'] + ) { + console.log(`[Mock API] Intercepting dynamic claim receipt request to: ${urlString}`); + await new Promise((resolve) => setTimeout(resolve, 500)); + return handlers['/api/v1/claims/:id/receipt'](urlString, init); + } } // Fallback to real fetch diff --git a/app/frontend/src/lib/mock-api/handlers.ts b/app/frontend/src/lib/mock-api/handlers.ts index 857a460f..8480582b 100644 --- a/app/frontend/src/lib/mock-api/handlers.ts +++ b/app/frontend/src/lib/mock-api/handlers.ts @@ -368,6 +368,28 @@ const recipientsImportConfirmHandler: MockHandler = async (_url, options) => { }); }; +const claimReceiptHandler: MockHandler = async (url) => { + const match = url.match(/\/api\/v1\/claims\/([^/]+)\/receipt/); + const claimId = match?.[1] ?? 'unknown-claim'; + + if (claimId === 'not-found') { + return new Response(null, { status: 404 }); + } + + return new Response( + JSON.stringify({ + claimId, + packageId: 'AID-001', + status: 'disbursed', + amount: 150.5, + tokenAddress: 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN', + timestamp: new Date().toISOString(), + recipientRef: 'recipient-ref-mock', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +}; + export const handlers: Record = { '/health': healthHandler, '/aid-packages': aidPackagesHandler, @@ -387,4 +409,5 @@ export const handlers: Record = { } return new Response(JSON.stringify({ success: false, message: 'Method not implemented in mock' }), { status: 405, headers: { 'Content-Type': 'application/json' } }); }, + '/api/v1/claims/:id/receipt': claimReceiptHandler, }; diff --git a/app/frontend/test/accessibility.spec.tsx b/app/frontend/test/accessibility.spec.tsx index 520db1bd..7107ba82 100644 --- a/app/frontend/test/accessibility.spec.tsx +++ b/app/frontend/test/accessibility.spec.tsx @@ -10,6 +10,22 @@ import Home from '@/app/[locale]/page'; import CampaignsPage from '@/app/[locale]/campaigns/page'; import ClaimReceiptPage from '@/app/[locale]/claim-receipt/page'; +jest.mock('@/lib/api-client', () => ({ + apiClient: { + GET: jest.fn(async () => ({ + data: { + claimId: 'claim-123', + packageId: 'AID-001', + status: 'disbursed', + amount: 150.5, + timestamp: new Date().toISOString(), + }, + error: undefined, + response: { status: 200 }, + })), + }, +})); + expect.extend(toHaveNoViolations); // jsdom has no layout engine, so color-contrast cannot be computed here. @@ -186,8 +202,10 @@ describe('accessibility (axe, WCAG 2.2 AA automated subset)', () => { }); it('claim receipt page with claimId has no axe violations', async () => { - mockSearchParams.current = new URLSearchParams('claimId=claim-123'); - const { container } = renderWithProviders(); + const ui = await ClaimReceiptPage({ + searchParams: Promise.resolve({ claimId: 'claim-123' }), + }); + const { container } = renderWithProviders(ui); await waitFor(() => { expect(screen.getByText('What is this receipt?')).toBeInTheDocument(); @@ -197,7 +215,8 @@ describe('accessibility (axe, WCAG 2.2 AA automated subset)', () => { }); it('claim receipt page without claimId (error state) has no axe violations', async () => { - const { container } = renderWithProviders(); + const ui = await ClaimReceiptPage({ searchParams: Promise.resolve({}) }); + const { container } = renderWithProviders(ui); await waitFor(() => { expect(screen.getByText('Claim ID not provided')).toBeInTheDocument();