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() {