From 3d49e6b7bf4cdace3c53db66c009cc225140569e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:20:45 +0200 Subject: [PATCH] feat(recipes): add /recipes/[id]/edit route with update action Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/recipes/[id]/edit/+page.server.ts | 89 +++++++++++++++++++ .../(app)/recipes/[id]/edit/+page.svelte | 17 ++++ .../recipes/[id]/edit/page.server.test.ts | 77 ++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts create mode 100644 frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte create mode 100644 frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts new file mode 100644 index 0000000..aca6c2f --- /dev/null +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -0,0 +1,89 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ fetch, params }) => { + const api = apiClient(fetch); + const [recipeResult, tagsResult] = await Promise.all([ + api.GET('/v1/recipes/{id}', { params: { path: { id: params.id } } }), + api.GET('/v1/tags', {}) + ]); + + if (recipeResult.error || !recipeResult.data) { + error(404, 'Recipe not found'); + } + + const recipe = recipeResult.data; + const allTags = tagsResult.data ?? []; + const categories = allTags + .filter((t) => t.tagType === 'category') + .map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); + + return { + recipe: { + id: recipe.id!, + name: recipe.name!, + serves: recipe.serves, + cookTimeMin: recipe.cookTimeMin, + effort: recipe.effort, + heroImageUrl: recipe.heroImageUrl, + ingredients: (recipe.ingredients ?? []).map((ing) => ({ + name: ing.name ?? '', + quantity: ing.quantity ?? 0, + unit: ing.unit ?? '' + })), + steps: (recipe.steps ?? []) + .sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0)) + .map((s) => ({ instruction: s.instruction ?? '' })), + tagIds: (recipe.tags ?? []).map((t) => t.id!) + }, + categories + }; +}; + +export const actions: Actions = { + update: async ({ request, fetch, params }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const serves = formData.get('serves'); + const cookTimeMin = formData.get('cookTimeMin'); + const effort = formData.get('effort') as string; + const ingredientsJson = formData.get('ingredientsJson') as string; + const stepsJson = formData.get('stepsJson') as string; + 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 (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' }); + + const parsedIngredients = JSON.parse(ingredientsJson || '[]'); + const parsedSteps = JSON.parse(stepsJson || '[]'); + + 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, + effort, + ingredients: parsedIngredients + .filter((ing: { name: string }) => ing.name?.trim()) + .map((ing: { name: string; quantity: string; unit: string }, i: number) => ({ + 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() })), + tagIds + } + }); + + if (apiError) return fail(500, { error: 'Fehler beim Speichern' }); + + redirect(303, '/recipes'); + } +}; diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte b/frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte new file mode 100644 index 0000000..440cb4d --- /dev/null +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte @@ -0,0 +1,17 @@ + + + + {data.recipe?.name ?? 'Rezept bearbeiten'} — Mealplan + + +
+

+ Rezept bearbeiten +

+ +
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 new file mode 100644 index 0000000..d30ce17 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +const mockPut = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet, PUT: mockPut }) +})); + +describe('edit recipe page — load', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + mockPut.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + const mockRecipe = { + id: 'r1', + name: 'Spaghetti Bolognese', + serves: 4, + cookTimeMin: 30, + effort: 'Easy', + ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }], + steps: [{ stepNumber: 1, instruction: 'Kochen' }], + tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }] + }; + + const mockTags = [ + { id: 't1', name: 'Pasta', tagType: 'category' }, + { id: 't2', name: 'Fleisch', tagType: 'category' } + ]; + + it('fetches recipe and tags in parallel', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined }); + if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined }); + }); + await load({ fetch: vi.fn(), params: { id: 'r1' } } as any); + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('returns recipe data mapped for form', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined }); + if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined }); + }); + const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any); + expect(result.recipe.name).toBe('Spaghetti Bolognese'); + expect(result.recipe.effort).toBe('Easy'); + }); + + it('returns categories from tags', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined }); + if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined }); + }); + const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any); + expect(result.categories).toHaveLength(2); + }); + + it('throws 404 when recipe not found', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/v1/recipes/{id}') return Promise.resolve({ data: undefined, error: { status: 404 } }); + if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined }); + }); + await expect( + load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any) + ).rejects.toMatchObject({ status: 404 }); + }); +});