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
44 changes: 44 additions & 0 deletions frontend/e2e/degraded-api.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
5 changes: 3 additions & 2 deletions frontend/src/hooks/useEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,19 @@ export function Analytics() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<TypeBreakdownItem
label="Automatic"
value={entryStats.by_type.auto}
value={entryStats.by_type?.auto ?? 0}
total={entryStats.total}
color="blue"
/>
<TypeBreakdownItem
label="Manual"
value={entryStats.by_type.manual}
value={entryStats.by_type?.manual ?? 0}
total={entryStats.total}
color="green"
/>
<TypeBreakdownItem
label="Wishlist"
value={entryStats.by_type.wishlist}
value={entryStats.by_type?.wishlist ?? 0}
total={entryStats.total}
color="purple"
/>
Expand Down Expand Up @@ -249,16 +249,16 @@ export function Analytics() {
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Giveaway Win Rate</span>
<Badge
variant={giveawayStats.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)}%
</Badge>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500 bg-green-500"
style={{ width: `${Math.min(giveawayStats.win_rate * 10, 100)}%` }}
style={{ width: `${Math.min((giveawayStats.win_rate ?? 0) * 10, 100)}%` }}
/>
</div>
<div className="flex justify-between text-sm text-gray-500 dark:text-gray-400">
Expand Down
Loading