diff --git a/src/components/payment/PaymentHistory/PaymentHistory.tsx b/src/components/payment/PaymentHistory/PaymentHistory.tsx index a0899901..65c3d4ab 100644 --- a/src/components/payment/PaymentHistory/PaymentHistory.tsx +++ b/src/components/payment/PaymentHistory/PaymentHistory.tsx @@ -70,19 +70,38 @@ export const PaymentHistory: React.FC = ({ // Fetch payment history (uses authenticated user). Extracted as a stable // callback so the realtime hook can re-run it on a payment_results change. + // + // Retries transient failures before surfacing the error alert. The query + // runs right after auth/session hydration; under load (e.g. Supabase + // Realtime/RLS returning a transient 401/406 during a session refresh) the + // FIRST attempt can throw even though a retry a moment later succeeds. + // Without this, a momentary blip stranded the user on "Error loading + // payment history" — a real UX regression, and the cause of the flaky + // payment-isolation E2E under concurrent-backend load. const refetch = useCallback(async () => { setError(null); - try { - // getPaymentHistory uses the authenticated user automatically (REQ-SEC-001) - const history = await getPaymentHistory(initialLimit); - setPayments(history); - setFilteredPayments(history); - } catch (err) { - setError( - err instanceof Error ? err : new Error('Failed to load payment history') - ); - } finally { - setLoading(false); + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // getPaymentHistory uses the authenticated user automatically (REQ-SEC-001) + const history = await getPaymentHistory(initialLimit); + setPayments(history); + setFilteredPayments(history); + setLoading(false); + return; + } catch (err) { + if (attempt === maxAttempts) { + setError( + err instanceof Error + ? err + : new Error('Failed to load payment history') + ); + setLoading(false); + return; + } + // Linear backoff (300ms, 600ms) before the next attempt. + await new Promise((r) => setTimeout(r, attempt * 300)); + } } }, [initialLimit]); diff --git a/tests/e2e/security/payment-isolation.spec.ts b/tests/e2e/security/payment-isolation.spec.ts index 91640a1d..6dff4813 100644 --- a/tests/e2e/security/payment-isolation.spec.ts +++ b/tests/e2e/security/payment-isolation.spec.ts @@ -141,20 +141,29 @@ test.describe('Payment Isolation E2E - REQ-SEC-001', () => { timeout: 10000, }); - // Payment history should be scoped to the current user - // The "No payment history" heading or payment list should be visible + // Payment history should be scoped to the current user: the empty-state + // heading OR at least one payment item must be visible. The component + // retries a transient fetch error (Realtime/RLS blip during session + // hydration under concurrent-backend load) before it surfaces an error, + // so poll for a STABLE terminal state rather than asserting on the first + // post-loading frame — otherwise we'd race the retry window. const noPaymentsHeading = page.getByRole('heading', { name: /No payment history/i, }); const paymentList = page.locator('[data-payment-item]'); - // Either empty state or payment list should be visible - const hasNoPayments = await noPaymentsHeading - .isVisible() - .catch(() => false); - const hasPayments = (await paymentList.count()) > 0; - - expect(hasNoPayments || hasPayments).toBe(true); + await expect + .poll( + async () => { + const hasNoPayments = await noPaymentsHeading + .isVisible() + .catch(() => false); + const hasPayments = (await paymentList.count()) > 0; + return hasNoPayments || hasPayments; + }, + { timeout: 15000 } + ) + .toBe(true); await context.close(); });