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
41 changes: 30 additions & 11 deletions src/components/payment/PaymentHistory/PaymentHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,38 @@ export const PaymentHistory: React.FC<PaymentHistoryProps> = ({

// 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]);

Expand Down
27 changes: 18 additions & 9 deletions tests/e2e/security/payment-isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading