From 6505cb42510e6b46d9ac1b46a47cdc7c76d3e214 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:27:54 +0200 Subject: [PATCH] test(recipes): add action tests and harden create/update form actions - Add try-catch around JSON.parse with fail(400) for malformed input - Validate effort against allowed values ['Easy','Medium','Hard'] - Fix NaN risk: Number(serves)||undefined instead of Number(serves) - Add action tests for create/update: validation, JSON.parse crash, success, API error Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/recipes/[id]/edit/+page.server.ts | 31 +++-- .../recipes/[id]/edit/page.server.test.ts | 113 ++++++++++++++++++ .../routes/(app)/recipes/new/+page.server.ts | 31 +++-- .../(app)/recipes/new/page.server.test.ts | 101 ++++++++++++++++ 4 files changed, 254 insertions(+), 22 deletions(-) diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts index aca6c2f..22a5715 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -2,6 +2,8 @@ import { error, redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; +const VALID_EFFORTS = ['Easy', 'Medium', 'Hard']; + export const load: PageServerLoad = async ({ fetch, params }) => { const api = apiClient(fetch); const [recipeResult, tagsResult] = await Promise.all([ @@ -53,31 +55,38 @@ export const actions: Actions = { const tagIds = formData.getAll('tagIds') as string[]; if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' }); - if (!effort) return fail(422, { error: 'Schwierigkeitsgrad ist erforderlich' }); + if (!effort || !VALID_EFFORTS.includes(effort)) + return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' }); if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' }); - const parsedIngredients = JSON.parse(ingredientsJson || '[]'); - const parsedSteps = JSON.parse(stepsJson || '[]'); + let parsedIngredients: unknown[]; + let parsedSteps: unknown[]; + try { + parsedIngredients = JSON.parse(ingredientsJson || '[]'); + parsedSteps = JSON.parse(stepsJson || '[]'); + } catch { + return fail(400, { error: 'Ungültige Formulardaten' }); + } const api = apiClient(fetch); const { error: apiError } = await api.PUT('/v1/recipes/{id}', { params: { path: { id: params.id } }, body: { name: name.trim(), - serves: serves ? Number(serves) : undefined, - cookTimeMin: cookTimeMin ? Number(cookTimeMin) : undefined, + serves: serves ? Number(serves) || undefined : undefined, + cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, effort, - ingredients: parsedIngredients - .filter((ing: { name: string }) => ing.name?.trim()) - .map((ing: { name: string; quantity: string; unit: string }, i: number) => ({ + ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) + .filter((ing) => ing.name?.trim()) + .map((ing, i) => ({ newIngredientName: ing.name.trim(), quantity: Number(ing.quantity) || 0, unit: ing.unit || '', sortOrder: i })), - steps: parsedSteps - .filter((s: string) => s?.trim()) - .map((s: string, i: number) => ({ stepNumber: i + 1, instruction: s.trim() })), + steps: (parsedSteps as string[]) + .filter((s) => s?.trim()) + .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), tagIds } }); diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts index d30ce17..cddb051 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -75,3 +75,116 @@ describe('edit recipe page — load', () => { ).rejects.toMatchObject({ status: 404 }); }); }); + +describe('edit recipe page — update action', () => { + let actions: any; + + const makeFormData = (overrides: Record = {}) => { + const base: Record = { + name: 'Test Rezept', + effort: 'Easy', + tagIds: ['t1'], + ingredientsJson: '[]', + stepsJson: '[]', + ...overrides + }; + const fd = new FormData(); + for (const [key, val] of Object.entries(base)) { + if (Array.isArray(val)) { + for (const v of val) fd.append(key, v); + } else { + fd.append(key, val); + } + } + return fd; + }; + + beforeEach(async () => { + mockGet.mockReset(); + mockPut.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + it('returns fail(422) when name is missing', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ name: '' }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when effort is missing', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ effort: '' }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when effort is not a valid value', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ effort: 'VeryHard' }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when no tagIds', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ tagIds: [] }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(400) when ingredientsJson is invalid JSON', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(400); + }); + + it('returns fail(400) when stepsJson is invalid JSON', async () => { + const result = await actions.update({ + request: { formData: async () => makeFormData({ stepsJson: '{broken' }) }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(400); + }); + + it('calls PUT /v1/recipes/{id} with correct body on success', async () => { + mockPut.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ + ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]), + stepsJson: JSON.stringify(['Kochen']) + }); + await actions.update({ + request: { formData: async () => fd }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any).catch(() => {}); + expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({ + params: { path: { id: 'r1' } }, + body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) + })); + }); + + it('returns fail(500) when API returns error', async () => { + mockPut.mockResolvedValue({ error: { status: 500 } }); + const result = await actions.update({ + request: { formData: async () => makeFormData() }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(500); + }); +}); diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts index 704e414..4a21b7f 100644 --- a/frontend/src/routes/(app)/recipes/new/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -2,6 +2,8 @@ import { redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; +const VALID_EFFORTS = ['Easy', 'Medium', 'Hard']; + export const load: PageServerLoad = async ({ fetch }) => { const api = apiClient(fetch); const { data, error } = await api.GET('/v1/tags', {}); @@ -26,30 +28,37 @@ export const actions: Actions = { const tagIds = formData.getAll('tagIds') as string[]; if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' }); - if (!effort) return fail(422, { error: 'Schwierigkeitsgrad ist erforderlich' }); + if (!effort || !VALID_EFFORTS.includes(effort)) + return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' }); if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' }); - const parsedIngredients = JSON.parse(ingredientsJson || '[]'); - const parsedSteps = JSON.parse(stepsJson || '[]'); + let parsedIngredients: unknown[]; + let parsedSteps: unknown[]; + try { + parsedIngredients = JSON.parse(ingredientsJson || '[]'); + parsedSteps = JSON.parse(stepsJson || '[]'); + } catch { + return fail(400, { error: 'Ungültige Formulardaten' }); + } const api = apiClient(fetch); const { error: apiError } = await api.POST('/v1/recipes', { body: { name: name.trim(), - serves: serves ? Number(serves) : undefined, - cookTimeMin: cookTimeMin ? Number(cookTimeMin) : undefined, + serves: serves ? Number(serves) || undefined : undefined, + cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, effort, - ingredients: parsedIngredients - .filter((ing: { name: string }) => ing.name?.trim()) - .map((ing: { name: string; quantity: string; unit: string }, i: number) => ({ + ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) + .filter((ing) => ing.name?.trim()) + .map((ing, i) => ({ newIngredientName: ing.name.trim(), quantity: Number(ing.quantity) || 0, unit: ing.unit || '', sortOrder: i })), - steps: parsedSteps - .filter((s: string) => s?.trim()) - .map((s: string, i: number) => ({ stepNumber: i + 1, instruction: s.trim() })), + steps: (parsedSteps as string[]) + .filter((s) => s?.trim()) + .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), tagIds } }); diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts index 0b0c904..ac054bb 100644 --- a/frontend/src/routes/(app)/recipes/new/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -51,3 +51,104 @@ describe('new recipe page — load', () => { expect(result.recipe).toBeNull(); }); }); + +describe('new recipe page — create action', () => { + let actions: any; + + const makeFormData = (overrides: Record = {}) => { + const base: Record = { + name: 'Test Rezept', + effort: 'Easy', + tagIds: ['t1'], + ingredientsJson: '[]', + stepsJson: '[]', + ...overrides + }; + const fd = new FormData(); + for (const [key, val] of Object.entries(base)) { + if (Array.isArray(val)) { + for (const v of val) fd.append(key, v); + } else { + fd.append(key, val); + } + } + return fd; + }; + + beforeEach(async () => { + mockGet.mockReset(); + mockPost.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + it('returns fail(422) when name is missing', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ name: '' }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when effort is missing', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ effort: '' }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when effort is not a valid value', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ effort: 'InvalidEffort' }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(422) when no tagIds', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ tagIds: [] }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(422); + }); + + it('returns fail(400) when ingredientsJson is invalid JSON', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(400); + }); + + it('returns fail(400) when stepsJson is invalid JSON', async () => { + const result = await actions.create({ + request: { formData: async () => makeFormData({ stepsJson: '{broken' }) }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(400); + }); + + it('calls POST /v1/recipes with correct body on success', async () => { + mockPost.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ + ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]), + stepsJson: JSON.stringify(['Kochen']) + }); + await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch( + () => {} + ); + expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) })); + }); + + it('returns fail(500) when API returns error', async () => { + mockPost.mockResolvedValue({ error: { status: 500 } }); + const result = await actions.create({ + request: { formData: async () => makeFormData() }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(500); + }); +});