feat(recipes): B2 — Recipe detail view with hero, ingredients, steps #37
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Ingredient } from './types';
|
||||||
|
|
||||||
|
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
||||||
|
|
||||||
|
const sortedIngredients = $derived(
|
||||||
|
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||||
|
>
|
||||||
|
Zutaten
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
||||||
|
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
||||||
|
{#if ingredient.quantity != null}
|
||||||
|
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
||||||
|
{ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[14px] text-[var(--color-text)]">{ingredient.name}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import IngredientList from './IngredientList.svelte';
|
||||||
|
|
||||||
|
const mockIngredients = [
|
||||||
|
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
|
||||||
|
{ ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('IngredientList', () => {
|
||||||
|
it('renders the section heading', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||||
|
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a row for each ingredient', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||||
|
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Salz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quantity and unit when present', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||||
|
expect(screen.getByText('200 g')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('400 g')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no quantity when not present', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||||
|
expect(screen.queryByText('undefined')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no remove buttons (read-only)', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when ingredients array is empty', () => {
|
||||||
|
render(IngredientList, { props: { ingredients: [] } });
|
||||||
|
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ingredients sorted by sortOrder', () => {
|
||||||
|
const unsorted = [
|
||||||
|
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
|
||||||
|
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
|
||||||
|
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
|
||||||
|
];
|
||||||
|
render(IngredientList, { props: { ingredients: unsorted } });
|
||||||
|
const spans = document.querySelectorAll('li span:last-child');
|
||||||
|
expect(spans[0].textContent).toBe('Spaghetti');
|
||||||
|
expect(spans[1].textContent).toBe('Hackfleisch');
|
||||||
|
expect(spans[2].textContent).toBe('Oregano');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Tag } from './types';
|
||||||
|
|
||||||
|
type RecipeHeroData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serves?: number;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
effort?: string;
|
||||||
|
heroImageUrl?: string;
|
||||||
|
tags: Tag[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { recipe }: { recipe: RecipeHeroData } = $props();
|
||||||
|
|
||||||
|
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||||
|
|
||||||
|
let pillBase = $derived(
|
||||||
|
hasImage
|
||||||
|
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||||
|
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||||
|
);
|
||||||
|
|
||||||
|
let cookBtnClass = $derived(
|
||||||
|
hasImage
|
||||||
|
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||||
|
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="recipe-hero"
|
||||||
|
class="min-h-[200px] md:min-h-[240px] {hasImage
|
||||||
|
? 'relative text-white'
|
||||||
|
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||||
|
>
|
||||||
|
{#if hasImage}
|
||||||
|
<img
|
||||||
|
src={recipe.heroImageUrl}
|
||||||
|
alt={recipe.name}
|
||||||
|
class="object-cover w-full h-full absolute inset-0"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
|
||||||
|
|
||||||
|
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
|
||||||
|
{recipe.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex gap-[8px] flex-wrap mt-[12px]">
|
||||||
|
{#if recipe.cookTimeMin != null}
|
||||||
|
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
|
||||||
|
{/if}
|
||||||
|
{#if recipe.effort}
|
||||||
|
<span class={pillBase}>{recipe.effort}</span>
|
||||||
|
{/if}
|
||||||
|
{#if recipe.serves != null}
|
||||||
|
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||||
|
{/if}
|
||||||
|
{#each recipe.tags as tag (tag.id)}
|
||||||
|
<span class={pillBase}>{tag.name}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import RecipeHero from './RecipeHero.svelte';
|
||||||
|
|
||||||
|
const baseRecipe = {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
serves: 4,
|
||||||
|
cookTimeMin: 30,
|
||||||
|
effort: 'Easy',
|
||||||
|
heroImageUrl: undefined as string | undefined,
|
||||||
|
tags: [] as { id: string; name: string; tagType?: string }[]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecipeHero', () => {
|
||||||
|
it('renders the recipe name', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders green-tint hero when no image', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
const hero = document.querySelector('[data-testid="recipe-hero"]');
|
||||||
|
expect(hero?.className).toContain('bg-[var(--green-tint)]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders image when heroImageUrl is provided', () => {
|
||||||
|
render(RecipeHero, {
|
||||||
|
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
|
||||||
|
});
|
||||||
|
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||||
|
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cook time pill', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders effort pill', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
expect(screen.getByText(/Easy/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders serves pill', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
expect(screen.getByText(/4/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back link to /recipes', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||||
|
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cook now link to /cook/[id]', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||||
|
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render img when no heroImageUrl', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tag pills', () => {
|
||||||
|
render(RecipeHero, {
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
...baseRecipe,
|
||||||
|
tags: [
|
||||||
|
{ id: 't1', name: 'Pasta' },
|
||||||
|
{ id: 't2', name: 'Italienisch' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Italienisch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no tag pills when tags array is empty', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
|
||||||
|
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/src/lib/recipes/StepList.svelte
Normal file
33
frontend/src/lib/recipes/StepList.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Step } from './types';
|
||||||
|
|
||||||
|
let { steps }: { steps: Step[] } = $props();
|
||||||
|
|
||||||
|
const sortedSteps = $derived(
|
||||||
|
[...steps].sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||||
|
>
|
||||||
|
Zubereitung
|
||||||
|
</h2>
|
||||||
|
<ol>
|
||||||
|
{#each sortedSteps as step (step.stepNumber)}
|
||||||
|
<li class="flex gap-[16px] items-start mb-[20px]">
|
||||||
|
<div
|
||||||
|
data-testid="step-circle"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
||||||
|
>
|
||||||
|
{step.stepNumber}
|
||||||
|
</div>
|
||||||
|
<p class="text-[14px] text-[var(--color-text)] leading-[1.6] pt-[4px]">
|
||||||
|
{step.instruction}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import StepList from './StepList.svelte';
|
||||||
|
|
||||||
|
const mockSteps = [
|
||||||
|
{ stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
|
||||||
|
{ stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
|
||||||
|
{ stepNumber: 3, instruction: 'Sauce bereiten' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('StepList', () => {
|
||||||
|
it('renders the section heading', () => {
|
||||||
|
render(StepList, { props: { steps: mockSteps } });
|
||||||
|
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each step instruction', () => {
|
||||||
|
render(StepList, { props: { steps: mockSteps } });
|
||||||
|
expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step numbers', () => {
|
||||||
|
render(StepList, { props: { steps: mockSteps } });
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders numbered circles with step numbers', () => {
|
||||||
|
render(StepList, { props: { steps: mockSteps } });
|
||||||
|
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||||
|
expect(circles).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders steps in stepNumber order', () => {
|
||||||
|
const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
|
||||||
|
render(StepList, { props: { steps: shuffled } });
|
||||||
|
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||||
|
expect(circles[0].textContent).toBe('1');
|
||||||
|
expect(circles[1].textContent).toBe('2');
|
||||||
|
expect(circles[2].textContent).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no steps', () => {
|
||||||
|
render(StepList, { props: { steps: [] } });
|
||||||
|
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,3 +5,34 @@ export type RecipeSummary = {
|
|||||||
effort?: string;
|
effort?: string;
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tagType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Ingredient = {
|
||||||
|
ingredientId?: string;
|
||||||
|
name?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Step = {
|
||||||
|
stepNumber?: number;
|
||||||
|
instruction?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipeDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serves?: number;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
effort?: string;
|
||||||
|
heroImageUrl?: string;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
tags: Tag[];
|
||||||
|
};
|
||||||
|
|||||||
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
|
||||||
|
params: { path: { id: params.id } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiError || !data) {
|
||||||
|
error(404, 'Recipe not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipe: {
|
||||||
|
id: data.id!,
|
||||||
|
name: data.name!,
|
||||||
|
serves: data.serves,
|
||||||
|
cookTimeMin: data.cookTimeMin,
|
||||||
|
effort: data.effort,
|
||||||
|
heroImageUrl: data.heroImageUrl,
|
||||||
|
ingredients: (data.ingredients ?? []).map((ing) => ({
|
||||||
|
ingredientId: ing.ingredientId,
|
||||||
|
name: ing.name,
|
||||||
|
quantity: ing.quantity,
|
||||||
|
unit: ing.unit,
|
||||||
|
sortOrder: ing.sortOrder
|
||||||
|
})),
|
||||||
|
steps: (data.steps ?? []).map((s) => ({
|
||||||
|
stepNumber: s.stepNumber,
|
||||||
|
instruction: s.instruction
|
||||||
|
})),
|
||||||
|
tags: (data.tags ?? []).map((t) => ({
|
||||||
|
id: t.id!,
|
||||||
|
name: t.name!,
|
||||||
|
tagType: t.tagType
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
||||||
|
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
||||||
|
import StepList from '$lib/recipes/StepList.svelte';
|
||||||
|
import type { RecipeDetail } from '$lib/recipes/types';
|
||||||
|
|
||||||
|
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.recipe.name} — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
|
||||||
|
<a
|
||||||
|
href="/recipes/{data.recipe.id}/edit"
|
||||||
|
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecipeHero recipe={data.recipe} />
|
||||||
|
|
||||||
|
<div class="md:flex">
|
||||||
|
<div class="md:flex-1 md:border-r md:border-[var(--color-border)] p-[24px]">
|
||||||
|
<IngredientList ingredients={data.recipe.ingredients} />
|
||||||
|
</div>
|
||||||
|
<div class="md:flex-1 p-[24px]">
|
||||||
|
<StepList steps={data.recipe.steps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('recipe detail page — load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.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',
|
||||||
|
heroImageUrl: undefined,
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||||
|
],
|
||||||
|
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fetches recipe from GET /v1/recipes/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||||
|
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||||
|
params: { path: { id: 'r1' } }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recipe data on success', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||||
|
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||||
|
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||||
|
expect(result.recipe.serves).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 error when API returns error', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||||
|
await expect(
|
||||||
|
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 error when API returns 403 (different household — intentional)', async () => {
|
||||||
|
// Security design: we return 404 for both "not found" and "forbidden"
|
||||||
|
// to avoid revealing resource existence to unauthorized users
|
||||||
|
mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
|
||||||
|
await expect(
|
||||||
|
load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
recipe: {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
serves: 4,
|
||||||
|
cookTimeMin: 30,
|
||||||
|
effort: 'Easy',
|
||||||
|
heroImageUrl: undefined as string | undefined,
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' }
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ stepNumber: 1, instruction: 'Wasser aufsetzen' },
|
||||||
|
{ stepNumber: 2, instruction: 'Sauce zubereiten' }
|
||||||
|
],
|
||||||
|
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('recipe detail page', () => {
|
||||||
|
it('renders the recipe name', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders page title', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(document.title).toBe('Spaghetti Bolognese — Mealplan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back link to /recipes', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||||
|
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cook now link to /cook/[id]', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||||
|
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ingredients section heading', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders steps section heading', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ingredient names', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step instructions', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders edit link to /recipes/[id]/edit', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
const editLink = screen.getByRole('link', { name: /bearbeiten/i });
|
||||||
|
expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tag pills in hero', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hero image when heroImageUrl is provided', () => {
|
||||||
|
render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||||
|
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user