From 2cef8a1169e95383a2ed01e6bc105e3570a6a88e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:17:17 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(recipes):=20add=20RecipeForm=20compone?= =?UTF-8?q?nt=20=E2=80=94=20add/edit=20two-state=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 134 ++++++++++++++++++ frontend/src/lib/recipes/RecipeForm.test.ts | 143 ++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 frontend/src/lib/recipes/RecipeForm.svelte create mode 100644 frontend/src/lib/recipes/RecipeForm.test.ts diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte new file mode 100644 index 0000000..f62df04 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -0,0 +1,134 @@ + + +
+ + + + + + + + + + + +
+ Schwierigkeitsgrad + {#each effortOptions as opt (opt.value)} + + {/each} +
+ + +
+ Kategorien + {#each categories as cat (cat.id)} + + {/each} +
+ + +
+ Zutaten + {#each ingredients as ing, i (i)} +
+ + + + +
+ {/each} + +
+ + +
+ Schritte + {#each steps as _, i (i)} +
+ + +
+ {/each} + +
+ + + + + + + Abbrechen + +
diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts new file mode 100644 index 0000000..3ecf980 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import RecipeForm from './RecipeForm.svelte'; + +const mockCategories = [ + { id: 'c1', name: 'Pasta', tagType: 'category' }, + { id: 'c2', name: 'Fleisch', tagType: 'category' } +]; + +const emptyProps = { + recipe: null, + categories: mockCategories, + action: '?/create' +}; + +const editProps = { + recipe: { + id: 'r1', + name: 'Spaghetti Bolognese', + serves: 4, + cookTimeMin: 30, + effort: 'Medium', + heroImageUrl: undefined as string | undefined, + ingredients: [ + { name: 'Spaghetti', quantity: 200, unit: 'g' } + ], + steps: [ + { instruction: 'Wasser aufsetzen' } + ], + tagIds: ['c1'] + }, + categories: mockCategories, + action: '?/update' +}; + +describe('RecipeForm', () => { + it('renders recipe name input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + }); + + it('renders serves input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument(); + }); + + it('renders cook time input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument(); + }); + + it('prefills name when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese'); + }); + + it('prefills serves when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByLabelText(/portionen/i)).toHaveValue(4); + }); + + it('renders effort chips', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument(); + }); + + it('prefills effort when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked(); + }); + + it('renders category chips', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument(); + }); + + it('prefills selected categories when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked(); + }); + + it('renders at least one ingredient row initially for empty form', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument(); + }); + + it('prefills ingredient rows when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument(); + }); + + it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + const before = screen.getAllByPlaceholderText(/zutat/i).length; + await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i })); + expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1); + }); + + it('removes ingredient row when remove button is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: editProps }); + await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i })); + const before = screen.getAllByPlaceholderText(/zutat/i).length; + const removeButtons = screen.getAllByRole('button', { name: /entfernen/i }); + await user.click(removeButtons[0]); + expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1); + }); + + it('renders at least one step row initially for empty form', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument(); + }); + + it('prefills step rows when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument(); + }); + + it('adds step row when "Schritt hinzufügen" is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + const before = screen.getAllByPlaceholderText(/schritt/i).length; + await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i })); + expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1); + }); + + it('renders save button', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument(); + }); + + it('renders cancel link back to /recipes', () => { + render(RecipeForm, { props: emptyProps }); + const cancelLink = screen.getByRole('link', { name: /abbrechen/i }); + expect(cancelLink).toHaveAttribute('href', '/recipes'); + }); +}); From 4e2b0b5727395f27df0ecc986953a582310f72b5 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:19:27 +0200 Subject: [PATCH 2/6] feat(recipes): add /recipes/new route with create action Co-Authored-By: Claude Sonnet 4.6 --- .../routes/(app)/recipes/new/+page.server.ts | 61 +++++++++++++++++++ .../src/routes/(app)/recipes/new/+page.svelte | 17 ++++++ .../(app)/recipes/new/page.server.test.ts | 53 ++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 frontend/src/routes/(app)/recipes/new/+page.server.ts create mode 100644 frontend/src/routes/(app)/recipes/new/+page.svelte create mode 100644 frontend/src/routes/(app)/recipes/new/page.server.test.ts diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts new file mode 100644 index 0000000..704e414 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -0,0 +1,61 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ fetch }) => { + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/tags', {}); + + const allTags = error || !data ? [] : data; + const categories = allTags + .filter((t) => t.tagType === 'category') + .map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType })); + + return { recipe: null, categories }; +}; + +export const actions: Actions = { + create: async ({ request, fetch }) => { + 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.POST('/v1/recipes', { + 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/new/+page.svelte b/frontend/src/routes/(app)/recipes/new/+page.svelte new file mode 100644 index 0000000..36b468f --- /dev/null +++ b/frontend/src/routes/(app)/recipes/new/+page.svelte @@ -0,0 +1,17 @@ + + + + Neues Rezept — Mealplan + + +
+

+ Neues Rezept +

+ +
diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts new file mode 100644 index 0000000..0b0c904 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet, POST: mockPost }) +})); + +describe('new recipe page — load', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + mockPost.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + const mockTags = [ + { id: 't1', name: 'Pasta', tagType: 'category' }, + { id: 't2', name: 'Fleisch', tagType: 'category' } + ]; + + it('fetches tags from GET /v1/tags', async () => { + mockGet.mockResolvedValue({ data: mockTags, error: undefined }); + await load({ fetch: vi.fn() } as any); + expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything()); + }); + + it('returns categories filtered from tags', async () => { + mockGet.mockResolvedValue({ data: mockTags, error: undefined }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.categories).toHaveLength(2); + expect(result.categories[0].name).toBe('Pasta'); + }); + + it('returns empty categories when API fails', async () => { + mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.categories).toEqual([]); + }); + + it('returns null recipe for new form', async () => { + mockGet.mockResolvedValue({ data: mockTags, error: undefined }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.recipe).toBeNull(); + }); +}); From 3d49e6b7bf4cdace3c53db66c009cc225140569e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:20:45 +0200 Subject: [PATCH 3/6] 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 }); + }); +}); From 6505cb42510e6b46d9ac1b46a47cdc7c76d3e214 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:27:54 +0200 Subject: [PATCH 4/6] 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); + }); +}); From e4d3008139c61353b5573e77cb8e4c5c2a635108 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:31:18 +0200 Subject: [PATCH 5/6] feat(recipes): display form error from \$page.form in RecipeForm - Import page store and render role="alert" error banner - Add mock for \$app/stores and \$app/forms in RecipeForm tests - Add tests: error banner shown when form.error set, hidden when null Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 7 ++++++ frontend/src/lib/recipes/RecipeForm.test.ts | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index f62df04..4dc67cc 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -1,4 +1,6 @@
+ + {#if $page.form?.error} +
{$page.form.error}
+ {/if} + diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts index 3ecf980..64cb585 100644 --- a/frontend/src/lib/recipes/RecipeForm.test.ts +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -1,8 +1,17 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { userEvent } from '@testing-library/user-event'; +import { writable } from 'svelte/store'; import RecipeForm from './RecipeForm.svelte'; +vi.mock('$app/stores', () => ({ + page: writable({ form: null, url: new URL('http://localhost/recipes/new') }) +})); + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + const mockCategories = [ { id: 'c1', name: 'Pasta', tagType: 'category' }, { id: 'c2', name: 'Fleisch', tagType: 'category' } @@ -140,4 +149,17 @@ describe('RecipeForm', () => { const cancelLink = screen.getByRole('link', { name: /abbrechen/i }); expect(cancelLink).toHaveAttribute('href', '/recipes'); }); + + it('displays form error message when $page.form.error is set', async () => { + const { page } = await import('$app/stores'); + (page as ReturnType).set({ form: { error: 'Name ist erforderlich' }, url: new URL('http://localhost/recipes/new') }); + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('alert')).toHaveTextContent('Name ist erforderlich'); + (page as ReturnType).set({ form: null, url: new URL('http://localhost/recipes/new') }); + }); + + it('does not display error banner when form has no error', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); }); From 33f3b30cb4e63e19d16103115d03d02ad98b91e3 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:35:35 +0200 Subject: [PATCH 6/6] feat(recipes): style RecipeForm with design system + split-panel layout - Full design system tokens: inputs, labels, chips, buttons - Effort and category chips as pill-style radio/checkbox - Desktop two-column split-panel: form left, categories right (280px) - Ingredient rows: quantity/unit/name flex layout with remove ghost button - Steps with numbered circle indicator - Add use:enhance for SPA experience without full page reload - Footer: cancel link left, primary save button right Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 291 +++++++++++++++------ 1 file changed, 216 insertions(+), 75 deletions(-) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index 4dc67cc..5560e81 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -1,5 +1,6 @@ - + {#if $page.form?.error} -
{$page.form.error}
+ {/if} - - - + +
+ +
+ +
+
+ + +
- - +
+ + +
- - +
+ + +
+
- -
- Schwierigkeitsgrad - {#each effortOptions as opt (opt.value)} - - {/each} -
+ +
+

+ Schwierigkeitsgrad +

+
+ {#each effortOptions as opt (opt.value)} + + {/each} +
+
- -
- Kategorien - {#each categories as cat (cat.id)} - - {/each} -
- - -
- Zutaten - {#each ingredients as ing, i (i)} -
- - - - +
+ {/each} +
+
- {/each} - - - -
- Schritte - {#each steps as _, i (i)} -
- - +
+ + {/each} + + - {/each} - -
+ + + +
+
+

Kategorien

+
+ {#each categories as cat (cat.id)} + + {/each} +
+
+
+ - Abbrechen - +
+ + Abbrechen + + +