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:
2026-04-09 20:23:28 +02:00
parent 116e400a91
commit 520dae5adf
34 changed files with 9862 additions and 84 deletions

View File

@@ -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) => ({

View File

@@ -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 })
}));
});

View File

@@ -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) => ({

View File

@@ -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 () => {

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}