From e5d96cd85a5b473f9dae22c43f6df62dbf183d3b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 08:19:37 +0200 Subject: [PATCH] fix(frontend): address all PR review concerns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 7px → 11px font-size on section headers in RecipePicker - Extract shared slotActions.ts with UUID validation for planId/slotId/recipeId - Load full recipe list in planner page load (was placeholder current-week slots) - Update planner/+page.svelte to pass data.recipes as allRecipes to RecipePicker - Update planner and recipes page.server.ts to use shared slot action helpers - Fix planner page.server tests: add recipes mock for parallel GET load - Update action tests to use valid UUIDs (were 'plan-1'/'r1' style strings) - Add validation-path tests for blank/invalid input on all slot actions - Add tests for recipes/+server.ts GET endpoint (DayPicker week navigation) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 4 +- frontend/src/lib/server/slotActions.ts | 65 ++++++ .../src/routes/(app)/planner/+page.server.ts | 77 +++---- .../src/routes/(app)/planner/+page.svelte | 6 +- .../routes/(app)/planner/page.server.test.ts | 197 +++++++++++++----- .../src/routes/(app)/recipes/+page.server.ts | 49 +---- .../routes/(app)/recipes/page.server.test.ts | 112 +++++++--- .../src/routes/(app)/recipes/server.test.ts | 71 +++++++ 8 files changed, 396 insertions(+), 185 deletions(-) create mode 100644 frontend/src/lib/server/slotActions.ts create mode 100644 frontend/src/routes/(app)/recipes/server.test.ts diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index f629ee0..1d2ffde 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -73,7 +73,7 @@ {#if suggestions.length > 0}
Empfohlen · Beste Abwechslung
@@ -127,7 +127,7 @@
Alle Rezepte
diff --git a/frontend/src/lib/server/slotActions.ts b/frontend/src/lib/server/slotActions.ts new file mode 100644 index 0000000..be648e2 --- /dev/null +++ b/frontend/src/lib/server/slotActions.ts @@ -0,0 +1,65 @@ +import { apiClient } from '$lib/server/api'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidUuid(value: string | null): value is string { + return typeof value === 'string' && UUID_RE.test(value); +} + +export async function addSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotDate = formData.get('slotDate') as string | null; + const recipeId = formData.get('recipeId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(recipeId) || !slotDate) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { + params: { path: { id: planId } }, + body: { slotDate, recipeId } + }); + + if (error || !data) return { success: false }; + return { success: true, slot: data }; +} + +export async function updateSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotId = formData.get('slotId') as string | null; + const recipeId = formData.get('recipeId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(slotId) || !isValidUuid(recipeId)) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } }, + body: { recipeId } + }); + + if (error || !data) return { success: false }; + return { success: true, slot: data }; +} + +export async function deleteSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotId = formData.get('slotId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(slotId)) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } } + }); + + if (error) return { success: false }; + return { success: true }; +} diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts index b011745..602dc39 100644 --- a/frontend/src/routes/(app)/planner/+page.server.ts +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -1,77 +1,50 @@ import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; import { getWeekStart } from '$lib/planner/week'; +import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions'; export const load: PageServerLoad = async ({ fetch, url }) => { const weekParam = url.searchParams.get('week'); const weekStart = weekParam ?? getWeekStart(new Date()); const api = apiClient(fetch); - const { data: weekPlan, error } = await api.GET('/v1/week-plans', { - params: { query: { weekStart } } - }); + const [weekPlanResult, recipesResult] = await Promise.all([ + api.GET('/v1/week-plans', { params: { query: { weekStart } } }), + api.GET('/v1/recipes', {}) + ]); - if (error || !weekPlan?.id) { - return { weekPlan: null, varietyScore: null, weekStart }; + const recipes = + recipesResult.error || !recipesResult.data?.data + ? [] + : recipesResult.data.data.map((r: any) => ({ + id: r.id!, + name: r.name!, + cookTimeMin: r.cookTimeMin, + effort: r.effort, + heroImageUrl: r.heroImageUrl + })); + + if (weekPlanResult.error || !weekPlanResult.data?.id) { + return { weekPlan: null, varietyScore: null, weekStart, recipes }; } + const weekPlan = weekPlanResult.data; const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', { - params: { path: { id: weekPlan.id } } + params: { path: { id: weekPlan.id! } } }); return { weekPlan, varietyScore: varietyScore ?? null, - weekStart + weekStart, + recipes }; }; export const actions: Actions = { - addSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotDate = formData.get('slotDate') as string; - const recipeId = formData.get('recipeId') as string; - - const api = apiClient(fetch); - const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { - params: { path: { id: planId } }, - body: { slotDate, recipeId } - }); - - if (error || !data) return { success: false }; - return { success: true, slot: data }; - }, - - updateSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotId = formData.get('slotId') as string; - const recipeId = formData.get('recipeId') as string; - - const api = apiClient(fetch); - const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', { - params: { path: { planId, slotId } }, - body: { recipeId } - }); - - if (error || !data) return { success: false }; - return { success: true, slot: data }; - }, - - deleteSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotId = formData.get('slotId') as string; - - const api = apiClient(fetch); - const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', { - params: { path: { planId, slotId } } - }); - - if (error) return { success: false }; - return { success: true }; - }, + addSlot: addSlotAction, + updateSlot: updateSlotAction, + deleteSlot: deleteSlotAction, createPlan: async ({ fetch, request, locals }) => { // Role guard: only planners may create week plans diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index aa997e7..b1cc5ad 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -10,7 +10,7 @@ import UndoBar from '$lib/planner/UndoBar.svelte'; import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week'; - let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string }; form?: any } = $props(); + let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props(); // Use UTC date string (YYYY-MM-DD) consistently const today: string = new Date().toISOString().slice(0, 10); @@ -263,7 +263,7 @@ dateLabel={formatDayLabel(selectedDay)} currentVarietyScore={varietyScore?.score ?? 0} suggestions={[]} - allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []} + allRecipes={data.recipes} onpick={handleRecipePick} /> @@ -495,7 +495,7 @@ dateLabel={formatDayLabel(pickerDate)} currentVarietyScore={varietyScore?.score ?? 0} suggestions={[]} - allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []} + allRecipes={data.recipes} onpick={handleRecipePick} /> diff --git a/frontend/src/routes/(app)/planner/page.server.test.ts b/frontend/src/routes/(app)/planner/page.server.test.ts index 4c47299..df992ad 100644 --- a/frontend/src/routes/(app)/planner/page.server.test.ts +++ b/frontend/src/routes/(app)/planner/page.server.test.ts @@ -12,6 +12,22 @@ vi.mock('$lib/server/api', () => ({ apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete }) })); +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const SLOT_UUID = '22222222-2222-2222-2222-222222222222'; +const RECIPE_UUID = '33333333-3333-3333-3333-333333333333'; + +const mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-03-30', + status: 'draft', + slots: [ + { id: SLOT_UUID, slotDate: '2026-03-30', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } } + ] +}; + +const mockRecipes = [{ id: RECIPE_UUID, name: 'Pasta', cookTimeMin: 30, effort: 'Easy' }]; +const mockVarietyScore = { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] }; + describe('planner page — load', () => { let load: any; @@ -23,48 +39,44 @@ describe('planner page — load', () => { load = mod.load; }); - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [ - { id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } }, - { id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } } - ] - }; - it('fetches week plan for the current week by default', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ - data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] }, - error: undefined - }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore const url = new URL('http://localhost/planner'); await load({ fetch: vi.fn(), url }); expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) })); }); it('uses weekStart from URL search params if provided', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner?week=2026-03-30'); await load({ fetch: vi.fn(), url }); expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) })); }); it('returns weekPlan with slots in page data', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.id).toBe('plan-1'); - expect(result.weekPlan.slots).toHaveLength(2); + expect(result.weekPlan.id).toBe(PLAN_UUID); + expect(result.weekPlan.slots).toHaveLength(1); }); it('returns variety score in page data', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined }); + const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }; + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: scoreWithOverlap, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.varietyScore.score).toBe(7.5); @@ -72,7 +84,9 @@ describe('planner page — load', () => { }); it('returns null weekPlan when API returns 404', async () => { - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) // weekPlan 404 + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); // recipes const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeNull(); @@ -80,8 +94,10 @@ describe('planner page — load', () => { }); it('returns the weekStart used for the query', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner?week=2026-03-30'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekStart).toBe('2026-03-30'); @@ -89,11 +105,34 @@ describe('planner page — load', () => { it('creates week plan if not found and fetches variety score after creation', async () => { // When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeNull(); }); + + it('returns recipes in page data', async () => { + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); + const url = new URL('http://localhost/planner'); + const result = await load({ fetch: vi.fn(), url }); + expect(result.recipes).toHaveLength(1); + expect(result.recipes[0].name).toBe('Pasta'); + }); + + it('returns empty recipes array when recipes API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) // recipes fail + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); + const url = new URL('http://localhost/planner'); + const result = await load({ fetch: vi.fn(), url }); + expect(result.recipes).toEqual([]); + }); }); describe('planner page — actions', () => { @@ -108,7 +147,7 @@ describe('planner page — actions', () => { }); it('createPlan action calls POST /v1/week-plans', async () => { - mockPost.mockResolvedValue({ data: { id: 'plan-new', weekStart: '2026-03-30', slots: [] }, error: undefined }); + mockPost.mockResolvedValue({ data: { id: PLAN_UUID, weekStart: '2026-03-30', slots: [] }, error: undefined }); const formData = new FormData(); formData.set('weekStart', '2026-03-30'); const result = await actions.createPlan({ @@ -178,20 +217,15 @@ describe('planner page — variety score partial failure', () => { load = mod.load; }); - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [] - }; - it('returns weekPlan even when variety score API fails', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); // variety score fails const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.id).toBe('plan-1'); + expect(result.weekPlan.id).toBe(PLAN_UUID); expect(result.varietyScore).toBeNull(); }); }); @@ -211,10 +245,10 @@ describe('planner page — slot actions', () => { it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); + formData.set('planId', PLAN_UUID); formData.set('slotDate', '2026-04-01'); - formData.set('recipeId', 'r1'); - mockPost.mockResolvedValue({ data: { id: 's1', slotDate: '2026-04-01' }, error: undefined }); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: { id: SLOT_UUID, slotDate: '2026-04-01' }, error: undefined }); const result = await actions.addSlot({ fetch: vi.fn(), request: { formData: async () => formData } @@ -222,19 +256,19 @@ describe('planner page — slot actions', () => { expect(mockPost).toHaveBeenCalledWith( '/v1/week-plans/{id}/slots', expect.objectContaining({ - params: { path: { id: 'plan-1' } }, - body: { slotDate: '2026-04-01', recipeId: 'r1' } + params: { path: { id: PLAN_UUID } }, + body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID } }) ); expect(result.success).toBe(true); - expect(result.slot?.id).toBe('s1'); + expect(result.slot?.id).toBe(SLOT_UUID); }); it('addSlot returns failure when API errors', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); + formData.set('planId', PLAN_UUID); formData.set('slotDate', '2026-04-01'); - formData.set('recipeId', 'r1'); + formData.set('recipeId', RECIPE_UUID); mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } }); const result = await actions.addSlot({ fetch: vi.fn(), @@ -243,12 +277,39 @@ describe('planner page — slot actions', () => { expect(result.success).toBe(false); }); + it('addSlot returns validation error when planId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', 'not-a-uuid'); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('addSlot returns validation error when slotDate is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('slotId', 's1'); - formData.set('recipeId', 'r2'); - mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined }); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + formData.set('recipeId', RECIPE_UUID); + mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); const result = await actions.updateSlot({ fetch: vi.fn(), request: { formData: async () => formData } @@ -256,17 +317,31 @@ describe('planner page — slot actions', () => { expect(mockPatch).toHaveBeenCalledWith( '/v1/week-plans/{planId}/slots/{slotId}', expect.objectContaining({ - params: { path: { planId: 'plan-1', slotId: 's1' } }, - body: { recipeId: 'r2' } + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }, + body: { recipeId: RECIPE_UUID } }) ); expect(result.success).toBe(true); }); + it('updateSlot returns validation error when slotId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', 'bad-id'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPatch).not.toHaveBeenCalled(); + }); + it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('slotId', 's1'); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); mockDelete.mockResolvedValue({ error: undefined }); const result = await actions.deleteSlot({ fetch: vi.fn(), @@ -275,9 +350,21 @@ describe('planner page — slot actions', () => { expect(mockDelete).toHaveBeenCalledWith( '/v1/week-plans/{planId}/slots/{slotId}', expect.objectContaining({ - params: { path: { planId: 'plan-1', slotId: 's1' } } + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } } }) ); expect(result.success).toBe(true); }); + + it('deleteSlot returns validation error when planId is missing', async () => { + const formData = new FormData(); + formData.set('slotId', SLOT_UUID); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockDelete).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts index 3355fcc..df455b0 100644 --- a/frontend/src/routes/(app)/recipes/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; import { getWeekStart } from '$lib/planner/week'; +import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions'; export const load: PageServerLoad = async ({ fetch }) => { const api = apiClient(fetch); @@ -29,49 +30,7 @@ export const load: PageServerLoad = async ({ fetch }) => { }; export const actions: Actions = { - addSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotDate = formData.get('slotDate') as string; - const recipeId = formData.get('recipeId') as string; - - const api = apiClient(fetch); - const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { - params: { path: { id: planId } }, - body: { slotDate, recipeId } - }); - - if (error || !data) return { success: false }; - return { success: true, slot: data }; - }, - - updateSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotId = formData.get('slotId') as string; - const recipeId = formData.get('recipeId') as string; - - const api = apiClient(fetch); - const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', { - params: { path: { planId, slotId } }, - body: { recipeId } - }); - - if (error || !data) return { success: false }; - return { success: true, slot: data }; - }, - - deleteSlot: async ({ fetch, request }) => { - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const slotId = formData.get('slotId') as string; - - const api = apiClient(fetch); - const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', { - params: { path: { planId, slotId } } - }); - - if (error) return { success: false }; - return { success: true }; - } + addSlot: addSlotAction, + updateSlot: updateSlotAction, + deleteSlot: deleteSlotAction }; diff --git a/frontend/src/routes/(app)/recipes/page.server.test.ts b/frontend/src/routes/(app)/recipes/page.server.test.ts index 0fe73d1..e21b1b7 100644 --- a/frontend/src/routes/(app)/recipes/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/page.server.test.ts @@ -16,6 +16,21 @@ vi.mock('$lib/planner/week', () => ({ getWeekStart: () => '2026-04-07' })); +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const SLOT_UUID = '22222222-2222-2222-2222-222222222222'; +const RECIPE_UUID = '33333333-3333-3333-3333-333333333333'; + +const mockRecipes = [ + { id: RECIPE_UUID, name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' }, + { id: '44444444-4444-4444-4444-444444444444', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } +]; + +const mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-04-07', + slots: [{ id: SLOT_UUID, slotDate: '2026-04-07', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'easy' } }] +}; + describe('recipe library page — load', () => { let load: any; @@ -26,17 +41,6 @@ describe('recipe library page — load', () => { load = mod.load; }); - const mockRecipes = [ - { id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' }, - { id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } - ]; - - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-04-07', - slots: [{ id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } }] - }; - it('fetches recipes from GET /v1/recipes', async () => { mockGet .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) @@ -75,7 +79,7 @@ describe('recipe library page — load', () => { .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const result = await load({ fetch: vi.fn() } as any); - expect(result.activePlan.id).toBe('plan-1'); + expect(result.activePlan.id).toBe(PLAN_UUID); expect(result.activePlan.slots).toHaveLength(1); }); @@ -103,10 +107,10 @@ describe('recipe library page — actions', () => { it('addSlot calls POST /v1/week-plans/{id}/slots and returns success', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); + formData.set('planId', PLAN_UUID); formData.set('slotDate', '2026-04-01'); - formData.set('recipeId', 'r1'); - mockPost.mockResolvedValue({ data: { id: 's1' }, error: undefined }); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); const result = await actions.addSlot({ fetch: vi.fn(), request: { formData: async () => formData } @@ -114,8 +118,8 @@ describe('recipe library page — actions', () => { expect(mockPost).toHaveBeenCalledWith( '/v1/week-plans/{id}/slots', expect.objectContaining({ - params: { path: { id: 'plan-1' } }, - body: { slotDate: '2026-04-01', recipeId: 'r1' } + params: { path: { id: PLAN_UUID } }, + body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID } }) ); expect(result.success).toBe(true); @@ -123,9 +127,9 @@ describe('recipe library page — actions', () => { it('addSlot returns error when API fails', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); + formData.set('planId', PLAN_UUID); formData.set('slotDate', '2026-04-01'); - formData.set('recipeId', 'r1'); + formData.set('recipeId', RECIPE_UUID); mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } }); const result = await actions.addSlot({ fetch: vi.fn(), @@ -134,12 +138,38 @@ describe('recipe library page — actions', () => { expect(result.success).toBe(false); }); - it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + it('addSlot returns validation error when planId is not a UUID', async () => { const formData = new FormData(); formData.set('planId', 'plan-1'); - formData.set('slotId', 's1'); - formData.set('recipeId', 'r1'); - mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined }); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('addSlot returns validation error when slotDate is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + formData.set('recipeId', RECIPE_UUID); + mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); const result = await actions.updateSlot({ fetch: vi.fn(), request: { formData: async () => formData } @@ -147,17 +177,31 @@ describe('recipe library page — actions', () => { expect(mockPatch).toHaveBeenCalledWith( '/v1/week-plans/{planId}/slots/{slotId}', expect.objectContaining({ - params: { path: { planId: 'plan-1', slotId: 's1' } }, - body: { recipeId: 'r1' } + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }, + body: { recipeId: RECIPE_UUID } }) ); expect(result.success).toBe(true); }); + it('updateSlot returns validation error when slotId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', 's1'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPatch).not.toHaveBeenCalled(); + }); + it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('slotId', 's1'); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); mockDelete.mockResolvedValue({ error: undefined }); const result = await actions.deleteSlot({ fetch: vi.fn(), @@ -166,9 +210,21 @@ describe('recipe library page — actions', () => { expect(mockDelete).toHaveBeenCalledWith( '/v1/week-plans/{planId}/slots/{slotId}', expect.objectContaining({ - params: { path: { planId: 'plan-1', slotId: 's1' } } + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } } }) ); expect(result.success).toBe(true); }); + + it('deleteSlot returns validation error when slotId is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockDelete).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/(app)/recipes/server.test.ts b/frontend/src/routes/(app)/recipes/server.test.ts new file mode 100644 index 0000000..d0e3224 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/server.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet }) +})); + +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; + +const mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-04-07', + slots: [] +}; + +describe('GET /recipes (server route)', () => { + let GET: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+server'); + GET = mod.GET; + }); + + it('returns plan data when week param is provided and API succeeds', async () => { + mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeDefined(); + expect(body.plan.id).toBe(PLAN_UUID); + }); + + it('returns { plan: null } when week param is missing', async () => { + const url = new URL('http://localhost/recipes'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns { plan: null } when API returns an error', async () => { + mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + }); + + it('returns { plan: null } when API returns data without id', async () => { + mockGet.mockResolvedValue({ data: { weekStart: '2026-04-07' }, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + }); + + it('calls GET /v1/week-plans with the provided week param', async () => { + mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-14'); + await GET({ fetch: vi.fn(), url } as any); + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ + params: { query: { weekStart: '2026-04-14' } } + })); + }); +});