fix(recipes): address B2 review — tags, sort, edit link, types, a11y, tests
- RecipeHero: render tag pills, min-h-[200px/240px], fix back link styling, remove font-[400] - IngredientList: sort by sortOrder ascending - StepList: aria-hidden on step circles - types.ts: add Tag, Ingredient, Step, RecipeDetail shared types - +page.svelte: add Edit link → /recipes/[id]/edit (desktop topbar) - Tests: tag pills, sortOrder sort, edit link, image variant, 403-as-404 documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,22 +2,9 @@
|
||||
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';
|
||||
|
||||
type RecipeData = {
|
||||
recipe: {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: { ingredientId?: string; name?: string; quantity?: number; unit?: string; sortOrder?: number }[];
|
||||
steps: { stepNumber?: number; instruction?: string }[];
|
||||
tags: { id: string; name: string; tagType?: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
let { data }: { data: RecipeData } = $props();
|
||||
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -25,6 +12,15 @@
|
||||
</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">
|
||||
|
||||
@@ -54,4 +54,13 @@ describe('recipe detail page — load', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,27 @@ describe('recipe detail page', () => {
|
||||
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