Compare commits

...

9 Commits

Author SHA1 Message Date
df95462094 refactor(staples): convert dynamic userEvent import to static in CategorySection test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:30:19 +02:00
2d6ddf0e48 fix(staples): apply design-system styles to nav links and settings heading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:29:53 +02:00
73b33ee956 fix(staples): apply design-system button spec to StapleChip (13px, tracking, font-sans)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:28:50 +02:00
8daaa0e21d fix(staples): pass ctx from URL through load function; fix script order in page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:27:43 +02:00
45b7e7b003 fix(staples): add role guard — only planer role can toggle staples
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:40 +02:00
3581af2bf9 fix(staples): forward backend error status code instead of always 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:06 +02:00
21b873b85b fix(staples): validate isStaple is boolean before forwarding to backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:35 +02:00
65f18cfb43 test(staples): cover API failure fallback in page load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:07 +02:00
7b497be1c1 test(staples): add empty categories edge case to StaplesManager
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:23:48 +02:00
10 changed files with 109 additions and 35 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import CategorySection from './CategorySection.svelte';
const mockIngredients = [
@@ -34,7 +35,6 @@ describe('CategorySection', () => {
});
it('calls onToggle with ingredient id and new value when chip is clicked', async () => {
const { userEvent } = await import('@testing-library/user-event');
const user = userEvent.setup();
const onToggle = vi.fn();
render(CategorySection, {

View File

@@ -10,7 +10,7 @@
type="button"
aria-pressed={selected}
onclick={() => onToggle(!selected)}
class="inline-flex text-[12px] font-medium px-[12px] py-[6px] rounded-full border cursor-pointer
class="inline-flex font-sans text-[13px] font-medium tracking-[0.04em] px-[12px] py-[6px] rounded-full border cursor-pointer
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)]
{selected
? 'bg-[var(--green-tint)] border-[var(--green-light)] text-[var(--green-dark)]'

View File

@@ -42,4 +42,12 @@ describe('StapleChip', () => {
const btn = screen.getByRole('button', { name: 'Olivenöl' });
expect(btn.className).toContain('focus-visible:outline');
});
it('uses design-system button text spec: 13px, tracking, font-sans', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
const btn = screen.getByRole('button', { name: 'Olivenöl' });
expect(btn.className).toContain('text-[13px]');
expect(btn.className).toContain('tracking-[0.04em]');
expect(btn.className).toContain('font-sans');
});
});

View File

@@ -99,4 +99,9 @@ describe('StaplesManager', () => {
const grid = screen.getByTestId('category-grid');
expect(grid.className).toContain('md:grid-cols-3');
});
it('renders without crashing when categories is empty', () => {
render(StaplesManager, { props: { categories: [], context: 'onboarding' } });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch }) => {
export const load: PageServerLoad = async ({ fetch, url }) => {
const api = apiClient(fetch);
const [categoriesResult, ingredientsResult] = await Promise.all([
@@ -24,5 +24,5 @@ export const load: PageServerLoad = async ({ fetch }) => {
}))
}));
return { categories };
return { categories, ctx: url.searchParams.get('ctx') };
};

View File

@@ -1,18 +1,18 @@
<svelte:head>
<title>Vorräte einrichten — Mealplan</title>
</svelte:head>
<script lang="ts">
import ProgressSidebar from '$lib/components/ProgressSidebar.svelte';
import StaplesManager from '$lib/onboarding/StaplesManager.svelte';
type Category = { id: string; name: string; ingredients: { id: string; name: string; isStaple: boolean }[] };
let { data, ctx }: { data: { categories: Category[] }; ctx?: string } = $props();
let { data }: { data: { categories: Category[]; ctx: string | null } } = $props();
const isOnboarding = $derived(ctx === 'onboarding');
const isOnboarding = $derived(data.ctx === 'onboarding');
</script>
<svelte:head>
<title>Vorräte einrichten — Mealplan</title>
</svelte:head>
{#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]">
<!-- Desktop sidebar -->
@@ -32,14 +32,20 @@
<!-- Footer navigation -->
<div class="flex justify-between p-6">
<a href="/planner">Überspringen</a>
<a href="/household/invite">Weiter</a>
<a
href="/planner"
class="font-sans text-[13px] font-medium tracking-[0.04em] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>Überspringen</a>
<a
href="/household/invite"
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
>Weiter</a>
</div>
</main>
</div>
{:else}
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
<h1>Vorräte</h1>
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
<StaplesManager categories={data.categories} context="settings" />
</div>
{/if}

View File

@@ -2,7 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const PATCH: RequestHandler = async ({ request, fetch }) => {
export const PATCH: RequestHandler = async ({ request, fetch, locals }) => {
if (locals.benutzer?.rolle !== 'planer') {
return json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { id, isStaple } = body;
@@ -10,6 +14,10 @@ export const PATCH: RequestHandler = async ({ request, fetch }) => {
return json({ error: 'id is required' }, { status: 400 });
}
if (typeof isStaple !== 'boolean') {
return json({ error: 'isStaple must be a boolean' }, { status: 400 });
}
const api = apiClient(fetch);
const { error } = await api.PATCH('/v1/ingredients/{id}', {
params: { path: { id } },
@@ -17,7 +25,8 @@ export const PATCH: RequestHandler = async ({ request, fetch }) => {
});
if (error) {
return json({ error: 'Failed to update ingredient' }, { status: 500 });
const status = (error as { status?: number }).status ?? 500;
return json({ error: 'Failed to update ingredient' }, { status });
}
return new Response(null, { status: 204 });

View File

@@ -41,9 +41,23 @@ describe('household staples page — load', () => {
});
}
it('passes ctx from url searchParams into returned data', async () => {
mockApiResponses();
const url = new URL('http://localhost/household/staples?ctx=onboarding');
const result = await load({ fetch: vi.fn(), url } as any);
expect(result.ctx).toBe('onboarding');
});
it('returns ctx as null when no ctx param is present', async () => {
mockApiResponses();
const url = new URL('http://localhost/household/staples');
const result = await load({ fetch: vi.fn(), url } as any);
expect(result.ctx).toBeNull();
});
it('fetches both categories and ingredients in parallel', async () => {
mockApiResponses();
await load({ fetch: vi.fn() } as any);
await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredient-categories');
@@ -52,7 +66,7 @@ describe('household staples page — load', () => {
it('groups ingredients by category id', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn() } as any);
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
expect(result.categories).toHaveLength(2);
const oele = result.categories.find((c: any) => c.id === 'cat-1');
@@ -62,7 +76,7 @@ describe('household staples page — load', () => {
it('preserves isStaple flag on each ingredient', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn() } as any);
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const oele = result.categories.find((c: any) => c.id === 'cat-1');
expect(oele.ingredients.find((i: any) => i.name === 'Olivenöl').isStaple).toBe(true);
@@ -79,9 +93,15 @@ describe('household staples page — load', () => {
}
});
const result = await load({ fetch: vi.fn() } as any);
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const leer = result.categories.find((c: any) => c.id === 'cat-3');
expect(leer).toBeDefined();
expect(leer.ingredients).toHaveLength(0);
});
it('returns empty categories when API fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
expect(result.categories).toEqual([]);
});
});

View File

@@ -2,10 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
vi.mock('$app/state', () => ({
page: { url: { searchParams: { get: vi.fn() } } }
}));
const mockCategories = [
{
id: 'cat-1',
@@ -27,34 +23,34 @@ describe('staples page — onboarding context (?ctx=onboarding)', () => {
});
it('renders ProgressSidebar with step 2 active', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step');
});
it('renders Continue button linking to /household/invite', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
const continueLink = screen.getByRole('link', { name: /weiter/i });
expect(continueLink).toHaveAttribute('href', '/household/invite');
});
it('renders Skip button linking to /planner', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
const skipLink = screen.getByRole('link', { name: /überspringen/i });
expect(skipLink).toHaveAttribute('href', '/planner');
});
it('renders the StaplesManager with categories', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
});
it('sets the page title', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(document.title).toBe('Vorräte einrichten — Mealplan');
});
it('renders mobile step indicator Schritt 2 von 3', () => {
render(Page, { props: { data: { categories: mockCategories }, ctx: 'onboarding' } });
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByText(/schritt 2 von 3/i)).toBeInTheDocument();
});
});
@@ -69,18 +65,18 @@ describe('staples page — settings context (no ctx)', () => {
});
it('does not render ProgressSidebar', () => {
render(Page, { props: { data: { categories: mockCategories } } });
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.queryByTestId('step-1')).not.toBeInTheDocument();
});
it('does not render Continue or Skip buttons', () => {
render(Page, { props: { data: { categories: mockCategories } } });
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.queryByRole('link', { name: /weiter/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /überspringen/i })).not.toBeInTheDocument();
});
it('renders a settings heading', () => {
render(Page, { props: { data: { categories: mockCategories } } });
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
});

View File

@@ -18,12 +18,13 @@ describe('household staples PATCH handler', () => {
PATCH = mod.PATCH;
});
function createRequest(body: object) {
function createRequest(body: object, rolle: 'planer' | 'mitglied' = 'planer') {
return {
request: {
json: () => Promise.resolve(body)
},
fetch: vi.fn()
fetch: vi.fn(),
locals: { benutzer: { rolle } }
} as any;
}
@@ -46,7 +47,7 @@ describe('household staples PATCH handler', () => {
expect(response.status).toBe(204);
});
it('returns 500 when backend returns an error', async () => {
it('returns 500 when backend returns a 500 error', async () => {
mockPatch.mockResolvedValue({ data: undefined, error: { status: 500, message: 'error' } });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
@@ -54,10 +55,39 @@ describe('household staples PATCH handler', () => {
expect(response.status).toBe(500);
});
it('forwards backend 404 status when ingredient not found', async () => {
mockPatch.mockResolvedValue({ data: undefined, error: { status: 404 } });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
expect(response.status).toBe(404);
});
it('returns 400 when id is missing', async () => {
const response = await PATCH(createRequest({ isStaple: true }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 400 when isStaple is missing', async () => {
const response = await PATCH(createRequest({ id: 'ing-1' }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 403 when caller has mitglied role', async () => {
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }, 'mitglied'));
expect(response.status).toBe(403);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 400 when isStaple is not a boolean', async () => {
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: 'yes' }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
});