diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index bf055d1..b6f5e4d 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -106,12 +106,14 @@ isLoadingSuggestions = false; return; } + const controller = new AbortController(); isLoadingSuggestions = true; - fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal }) .then((r) => r.json()) .then((d) => { suggestions = d.suggestions ?? []; }) - .catch(() => { suggestions = []; }) + .catch((e) => { if (e.name !== 'AbortError') suggestions = []; }) .finally(() => { isLoadingSuggestions = false; }); + return () => controller.abort(); }); function handleSelectDay(day: string) { diff --git a/frontend/src/routes/(app)/planner/page.test.ts b/frontend/src/routes/(app)/planner/page.test.ts new file mode 100644 index 0000000..c400a88 --- /dev/null +++ b/frontend/src/routes/(app)/planner/page.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import Page from './+page.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); +vi.mock('$app/forms', () => ({ + enhance: () => () => ({ destroy: () => {} }) +})); + +const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001'; +// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday) +const DATE = '2025-01-06'; // Monday, January 6 2025 + +const mockData = { + weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] }, + varietyScore: null, + weekStart: DATE, + recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }], + benutzer: { rolle: 'planer' } +}; + +const mockSuggestions = [ + { + recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 }, + scoreDelta: 1.5, + hasConflict: false + } +]; + +describe('+page.svelte — $effect suggestion fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls fetch when picker opens with correct planId and date', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`); + expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`); + }); + + it('shows suggestions in RecipePicker after fetch resolves', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + expect(await screen.findByText('Lachsfilet')).toBeTruthy(); + }); + + it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: [] }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const fetchOptions = (fetch as any).mock.calls[0][1]; + expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal); + }); +});