From d4d83b5b3c5a9a0885e67986d866bda4da4853b4 Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sun, 12 Jul 2026 11:07:00 +0200 Subject: [PATCH] Guard History and Analytics against partial API payloads The Playwright suite surfaced two unhandled TypeErrors that blanked pages whenever the API returned a partial/unexpected shape: - History: useEntries now normalizes at the data boundary (entries ?? [], count ?? 0) so data.items is always an array. - Analytics: by_type breakdown falls back to 0 per type, and the win-rate badge/label/bar use (win_rate ?? 0) instead of calling .toFixed on undefined. e2e/degraded-api.spec.ts replays the exact degraded payloads and asserts both pages render gracefully. Verified: 13 e2e passed, eslint zero warnings, tsc clean, 170 unit tests pass. --- frontend/e2e/degraded-api.spec.ts | 44 +++++++++++++++++++++++++++++++ frontend/src/hooks/useEntries.ts | 5 ++-- frontend/src/pages/Analytics.tsx | 12 ++++----- 3 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/degraded-api.spec.ts diff --git a/frontend/e2e/degraded-api.spec.ts b/frontend/e2e/degraded-api.spec.ts new file mode 100644 index 0000000..c6edbe3 --- /dev/null +++ b/frontend/e2e/degraded-api.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { mockApi } from './mocks'; + +/** + * Regression: partial/unexpected API payloads must degrade gracefully, not + * blank the page with an unhandled TypeError (History and Analytics did + * exactly that before the defensive guards). + * + * Routes registered after mockApi() take precedence, so each test overrides + * just the endpoint it degrades. + */ +function ok(data: unknown) { + return { + status: 200, + contentType: 'application/json' as const, + body: JSON.stringify({ success: true, data }), + }; +} + +test('History survives an empty entries payload', async ({ page }) => { + await mockApi(page); + await page.route('**/api/v1/entries/**', (route) => route.fulfill(ok({}))); + + await page.goto('/history'); + + await expect(page.getByRole('heading', { name: /history/i })).toBeVisible(); + await expect(page.getByText(/showing 0 of 0 entries/i)).toBeVisible(); +}); + +test('Analytics survives summaries without by_type or win_rate', async ({ page }) => { + await mockApi(page); + await page.route('**/api/v1/analytics/entries/summary**', (route) => + route.fulfill(ok({ total: 5, successful: 5, failed: 0, total_points_spent: 100, success_rate: 100 })) + ); + await page.route('**/api/v1/analytics/giveaways/summary**', (route) => + route.fulfill(ok({ total: 10, active: 3, entered: 2, hidden: 0, wins: 1 })) + ); + + await page.goto('/analytics'); + + await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible(); + // The by_type breakdown renders with zeroed values instead of crashing. + await expect(page.getByText('Automatic')).toBeVisible(); +}); diff --git a/frontend/src/hooks/useEntries.ts b/frontend/src/hooks/useEntries.ts index 6ac8906..81f4e1f 100644 --- a/frontend/src/hooks/useEntries.ts +++ b/frontend/src/hooks/useEntries.ts @@ -84,10 +84,11 @@ export function useEntries(filters: EntryFilters = {}) { // Transform backend response to frontend format const page = filters.page || 1; const limit = filters.limit || 20; - const total = response.data.count; + // Defensive: a partial/unexpected payload must not blank the page. + const total = response.data?.count ?? 0; return { - items: response.data.entries, + items: response.data?.entries ?? [], total, page, limit, diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index e044b83..c11ff41 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -130,19 +130,19 @@ export function Analytics() {
@@ -249,16 +249,16 @@ export function Analytics() {
Giveaway Win Rate = 1 ? 'success' : giveawayStats.win_rate >= 0.5 ? 'warning' : 'default'} + variant={(giveawayStats.win_rate ?? 0) >= 1 ? 'success' : (giveawayStats.win_rate ?? 0) >= 0.5 ? 'warning' : 'default'} size="md" > - {giveawayStats.win_rate.toFixed(2)}% + {(giveawayStats.win_rate ?? 0).toFixed(2)}%