feat(recipes): B3 — Add/edit recipe form with dynamic ingredients, steps, tag chips #38

Merged
marcel merged 6 commits from feat/issue-23-recipe-form into master 2026-04-03 10:36:19 +02:00
8 changed files with 993 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
type Category = { id: string; name: string; tagType?: string };
type EditRecipe = {
id: string;
name: string;
serves?: number;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
ingredients: { name: string; quantity: number; unit: string }[];
steps: { instruction: string }[];
tagIds: string[];
} | null;
const { recipe, categories, action }: {
recipe: EditRecipe;
categories: Category[];
action: string;
} = $props();
const effortOptions = [
{ label: 'Leicht', value: 'Easy' },
{ label: 'Mittel', value: 'Medium' },
{ label: 'Schwer', value: 'Hard' }
];
const initial = (() => $state.snapshot(recipe))();
let name = $state(initial?.name ?? '');
let serves = $state<number | ''>(initial?.serves ?? '');
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
let effort = $state(initial?.effort ?? '');
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
let ingredients = $state(
initial?.ingredients.map((ing) => ({
name: ing.name,
quantity: ing.quantity as number | '',
unit: ing.unit
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
);
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
</script>
<form method="POST" {action} use:enhance>
<!-- Error banner -->
{#if $page.form?.error}
<div
role="alert"
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
>
{$page.form.error}
</div>
{/if}
<!-- Two-column layout -->
<div class="md:flex md:gap-[32px]">
<!-- Left column: main form fields -->
<div class="md:flex-1">
<!-- Basic info -->
<div class="mb-[24px]">
<div class="mb-[16px]">
<label
for="name"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Name
</label>
<input
id="name"
name="name"
type="text"
bind:value={name}
required
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<div class="mb-[16px]">
<label
for="serves"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Portionen
</label>
<input
id="serves"
name="serves"
type="number"
bind:value={serves}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
<div class="mb-[16px]">
<label
for="cookTimeMin"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Kochzeit
</label>
<input
id="cookTimeMin"
name="cookTimeMin"
type="number"
bind:value={cookTimeMin}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
</div>
</div>
<!-- Effort chips -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
Schwierigkeitsgrad
</p>
<div class="flex flex-wrap gap-[8px]">
{#each effortOptions as opt (opt.value)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
effort === opt.value
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="radio"
name="effort"
value={opt.value}
bind:group={effort}
class="sr-only"
/>
{opt.label}
</label>
{/each}
</div>
</div>
<!-- Ingredients -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
<div class="flex flex-col gap-[8px]">
{#each ingredients as ing, i (i)}
<div class="flex items-center gap-[8px]">
<input
type="number"
bind:value={ing.quantity}
placeholder="Menge"
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<input
type="text"
bind:value={ing.unit}
placeholder="Einheit"
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<input
type="text"
bind:value={ing.name}
placeholder="Zutat"
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
/>
<button
type="button"
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Entfernen
</button>
</div>
{/each}
</div>
<button
type="button"
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Zutat hinzufügen
</button>
</div>
<!-- Steps -->
<div class="mb-[24px]">
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
<div class="flex flex-col gap-[12px]">
{#each steps as _, i (i)}
<div class="flex items-start gap-[12px]">
<span
class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
>
{i + 1}
</span>
<div class="flex flex-1 flex-col gap-[6px]">
<textarea
bind:value={steps[i]}
placeholder="Schritt beschreiben…"
rows="3"
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
></textarea>
<button
type="button"
onclick={() => (steps = steps.filter((_, j) => j !== i))}
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
>
Entfernen
</button>
</div>
</div>
{/each}
</div>
<button
type="button"
onclick={() => (steps = [...steps, ''])}
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
>
Schritt hinzufügen
</button>
</div>
</div>
<!-- Right panel: categories -->
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
<div
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
>
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
<div class="flex flex-wrap gap-[8px]">
{#each categories as cat (cat.id)}
<label
class={[
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
selectedTagIds.includes(cat.id)
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
].join(' ')}
>
<input
type="checkbox"
name="tagIds"
value={cat.id}
checked={selectedTagIds.includes(cat.id)}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedTagIds = [...selectedTagIds, cat.id];
} else {
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
}
}}
class="sr-only"
/>
{cat.name}
</label>
{/each}
</div>
</div>
</div>
</div>
<!-- Hidden inputs for form submission -->
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
<!-- Footer -->
<div class="mt-[32px] flex items-center justify-between">
<a
href="/recipes"
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Abbrechen
</a>
<button
type="submit"
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
>
Speichern
</button>
</div>
</form>

View File

@@ -0,0 +1,165 @@
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' }
];
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');
});
it('displays form error message when $page.form.error is set', async () => {
const { page } = await import('$app/stores');
(page as ReturnType<typeof writable>).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<typeof writable>).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();
});
});

View File

@@ -0,0 +1,98 @@
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([
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 || !VALID_EFFORTS.includes(effort))
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
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 : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
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 as string[])
.filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
tagIds
}
});
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
redirect(303, '/recipes');
}
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.recipe?.name ?? 'Rezept bearbeiten'} — Mealplan</title>
</svelte:head>
<div class="p-[24px]">
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
Rezept bearbeiten
</h1>
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/update" />
</div>

View File

@@ -0,0 +1,190 @@
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 });
});
});
describe('edit recipe page — update action', () => {
let actions: any;
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
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);
});
});

View File

@@ -0,0 +1,70 @@
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', {});
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 || !VALID_EFFORTS.includes(effort))
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
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 : undefined,
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
effort,
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 as string[])
.filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
tagIds
}
});
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
redirect(303, '/recipes');
}
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Neues Rezept — Mealplan</title>
</svelte:head>
<div class="p-[24px]">
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
Neues Rezept
</h1>
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/create" />
</div>

View File

@@ -0,0 +1,154 @@
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();
});
});
describe('new recipe page — create action', () => {
let actions: any;
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
const base: Record<string, string | string[]> = {
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);
});
});