Compare commits
3 Commits
3d49e6b7bf
...
33f3b30cb4
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f3b30cb4 | |||
| e4d3008139 | |||
| 6505cb4251 |
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type Category = { id: string; name: string; tagType?: string };
|
||||
|
||||
type EditRecipe = {
|
||||
@@ -25,8 +28,6 @@
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
];
|
||||
|
||||
// Take a plain snapshot of the initial prop value so local state is
|
||||
// initialised once and not tied to ongoing prop reactivity.
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
@@ -44,91 +45,238 @@
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
</script>
|
||||
|
||||
<form method="POST" {action}>
|
||||
<!-- Basic info -->
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" bind:value={name} required />
|
||||
|
||||
<label for="serves">Portionen</label>
|
||||
<input id="serves" name="serves" type="number" bind:value={serves} />
|
||||
|
||||
<label for="cookTimeMin">Kochzeit</label>
|
||||
<input id="cookTimeMin" name="cookTimeMin" type="number" bind:value={cookTimeMin} />
|
||||
|
||||
<!-- Effort chips -->
|
||||
<fieldset>
|
||||
<legend>Schwierigkeitsgrad</legend>
|
||||
{#each effortOptions as opt (opt.value)}
|
||||
<label>
|
||||
<input type="radio" name="effort" value={opt.value} bind:group={effort} />
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<!-- Category chips -->
|
||||
<fieldset>
|
||||
<legend>Kategorien</legend>
|
||||
{#each categories as cat (cat.id)}
|
||||
<label>
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{cat.name}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<fieldset>
|
||||
<legend>Zutaten</legend>
|
||||
{#each ingredients as ing, i (i)}
|
||||
<div>
|
||||
<input type="number" bind:value={ing.quantity} placeholder="Menge" />
|
||||
<input type="text" bind:value={ing.unit} placeholder="Einheit" />
|
||||
<input type="text" bind:value={ing.name} placeholder="Zutat" />
|
||||
<button type="button" onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '', unit: '' }])}
|
||||
<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)]"
|
||||
>
|
||||
Zutat hinzufügen
|
||||
</button>
|
||||
</fieldset>
|
||||
{$page.form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Steps -->
|
||||
<fieldset>
|
||||
<legend>Schritte</legend>
|
||||
{#each steps as _, i (i)}
|
||||
<div>
|
||||
<textarea bind:value={steps[i]} placeholder="Schritt beschreiben…"></textarea>
|
||||
<button type="button" onclick={() => (steps = steps.filter((_, j) => j !== i))}>
|
||||
Entfernen
|
||||
<!-- 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>
|
||||
{/each}
|
||||
<button type="button" onclick={() => (steps = [...steps, ''])}>Schritt hinzufügen</button>
|
||||
</fieldset>
|
||||
|
||||
<!-- 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 -->
|
||||
<a href="/recipes">Abbrechen</a>
|
||||
<button type="submit">Speichern</button>
|
||||
<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>
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user