feat(recipes): add image upload, fix save 500, seed HelloFresh data
- Store hero image as base64 data URI in text column (V023 migration) - Add file upload UI to RecipeForm with FileReader preview - Remove isChildFriendly from RecipeCreateRequest (no form field) - Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed from primitive short to nullable Integer to survive omitted fields - Fix empty categories panel: removed stale tagType=category filter - Group category tags by type with German headings in recipe form - Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe - Seed 11 HelloFresh recipes with ingredients, steps and tags (V101) - Add frontend e2e scaffold, specs and dev yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
6
frontend/e2e/startseite.test.ts
Normal file
6
frontend/e2e/startseite.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('Startseite lädt korrekt', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible();
|
||||
});
|
||||
12
frontend/playwright.config.ts
Normal file
12
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
},
|
||||
testDir: 'e2e',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,22 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface Warning {
|
||||
title: string;
|
||||
explanation: string;
|
||||
interface WarningItem {
|
||||
dayShort: string;
|
||||
recipeName: string;
|
||||
slotId: number;
|
||||
}
|
||||
|
||||
let { warnings }: { warnings: Warning[] } = $props();
|
||||
interface ActionWarning {
|
||||
title: string;
|
||||
items: WarningItem[];
|
||||
}
|
||||
|
||||
let { warnings, weekStart }: { warnings: ActionWarning[]; weekStart: string } = $props();
|
||||
</script>
|
||||
|
||||
{#each warnings as warning}
|
||||
{#each warnings as warning (warning.title)}
|
||||
<div
|
||||
data-testid="warning-card"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] overflow-hidden"
|
||||
>
|
||||
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||
{warning.title}
|
||||
</p>
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{warning.explanation}
|
||||
</p>
|
||||
<!-- Header row -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--yellow-light)]">
|
||||
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||
{warning.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Item rows -->
|
||||
{#each warning.items as item (item.slotId)}
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--yellow-light)] last:border-b-0">
|
||||
<!-- Left: day label + recipe name -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--yellow-text)] w-6 flex-shrink-0">
|
||||
{item.dayShort}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{item.recipeName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Right: swap link -->
|
||||
<a
|
||||
href="/planner?week={weekStart}&swap={item.slotId}"
|
||||
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] flex-shrink-0 hover:underline"
|
||||
>
|
||||
Tauschen →
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -23,13 +23,30 @@
|
||||
} = $props();
|
||||
|
||||
const effortOptions = [
|
||||
{ label: 'Leicht', value: 'Easy' },
|
||||
{ label: 'Mittel', value: 'Medium' },
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
{ label: 'Leicht', value: 'easy' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Schwer', value: 'hard' }
|
||||
];
|
||||
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
dietary: 'Ernährung',
|
||||
cuisine: 'Küche',
|
||||
protein: 'Protein',
|
||||
other: 'Sonstiges'
|
||||
};
|
||||
|
||||
const groupedCategories = $derived(
|
||||
Object.entries(
|
||||
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
|
||||
const type = cat.tagType ?? 'other';
|
||||
(acc[type] ??= []).push(cat);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
);
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||
@@ -43,6 +60,17 @@
|
||||
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||
);
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
||||
|
||||
function handleImageChange(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
heroImageUrl = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" {action} use:enhance>
|
||||
@@ -140,6 +168,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
|
||||
{#if heroImageUrl}
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt=""
|
||||
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (heroImageUrl = null)}
|
||||
class="mb-[8px] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
{/if}
|
||||
<label
|
||||
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleImageChange}
|
||||
class="sr-only"
|
||||
/>
|
||||
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
||||
</label>
|
||||
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
||||
@@ -227,35 +286,42 @@
|
||||
<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>
|
||||
<p class="mb-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
{#each groupedCategories as [type, tags] (type)}
|
||||
<div class="mb-[16px] last:mb-0">
|
||||
<p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{TAG_TYPE_LABELS[type] ?? type}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each tags 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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'];
|
||||
const VALID_EFFORTS = ['easy', 'medium', 'hard'];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
@@ -17,9 +17,7 @@ export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
|
||||
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 }));
|
||||
const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
@@ -50,6 +48,7 @@ export const actions: Actions = {
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const heroImageUrl = (formData.get('heroImageUrl') as string) || null;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
@@ -76,6 +75,7 @@ export const actions: Actions = {
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
heroImageUrl,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('edit recipe page — load', () => {
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
effort: 'easy',
|
||||
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
|
||||
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||
@@ -53,7 +53,7 @@ describe('edit recipe page — load', () => {
|
||||
});
|
||||
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');
|
||||
expect(result.recipe.effort).toBe('easy');
|
||||
});
|
||||
|
||||
it('returns categories from tags', async () => {
|
||||
@@ -82,7 +82,7 @@ describe('edit recipe page — update action', () => {
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
effort: 'easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
@@ -174,7 +174,33 @@ describe('edit recipe page — update action', () => {
|
||||
} as any).catch(() => {});
|
||||
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
params: { path: { id: 'r1' } },
|
||||
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
|
||||
body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends heroImageUrl in PUT body when provided', async () => {
|
||||
mockPut.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
|
||||
await actions.update({
|
||||
request: { formData: async () => fd },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any).catch(() => {});
|
||||
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends null heroImageUrl when field is empty', async () => {
|
||||
mockPut.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({ heroImageUrl: '' });
|
||||
await actions.update({
|
||||
request: { formData: async () => fd },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any).catch(() => {});
|
||||
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
body: expect.objectContaining({ heroImageUrl: null })
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -2,16 +2,14 @@ import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||
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 }));
|
||||
const categories = allTags.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return { recipe: null, categories };
|
||||
};
|
||||
@@ -23,6 +21,7 @@ export const actions: Actions = {
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const heroImageUrl = (formData.get('heroImageUrl') as string) || null;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
@@ -48,6 +47,7 @@ export const actions: Actions = {
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
heroImageUrl,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
|
||||
@@ -22,8 +22,10 @@ describe('new recipe page — load', () => {
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
{ id: 't1', name: 'Vegetarisch', tagType: 'dietary' },
|
||||
{ id: 't2', name: 'Mediterran', tagType: 'cuisine' },
|
||||
{ id: 't3', name: 'Käse', tagType: 'protein' },
|
||||
{ id: 't4', name: 'Auflauf', tagType: 'other' }
|
||||
];
|
||||
|
||||
it('fetches tags from GET /v1/tags', async () => {
|
||||
@@ -32,11 +34,11 @@ describe('new recipe page — load', () => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
|
||||
});
|
||||
|
||||
it('returns categories filtered from tags', async () => {
|
||||
it('returns all tags as categories regardless of tagType', 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');
|
||||
expect(result.categories).toHaveLength(4);
|
||||
expect(result.categories[0].name).toBe('Vegetarisch');
|
||||
});
|
||||
|
||||
it('returns empty categories when API fails', async () => {
|
||||
@@ -58,7 +60,7 @@ describe('new recipe page — create action', () => {
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
effort: 'easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
@@ -140,7 +142,25 @@ describe('new recipe page — create action', () => {
|
||||
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' }) }));
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) }));
|
||||
});
|
||||
|
||||
it('sends heroImageUrl in POST body when provided', async () => {
|
||||
mockPost.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' });
|
||||
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {});
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({
|
||||
body: expect.objectContaining({ heroImageUrl: 'data:image/jpeg;base64,abc123' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends null heroImageUrl when field is empty', async () => {
|
||||
mockPost.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({ heroImageUrl: '' });
|
||||
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {});
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({
|
||||
body: expect.objectContaining({ heroImageUrl: null })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns fail(500) when API returns error', async () => {
|
||||
|
||||
7
frontend/src/routes/+layout.svelte
Normal file
7
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Reference in New Issue
Block a user