feat(staples): A3/D3 — Pantry staples toggle UI #35

Merged
marcel merged 17 commits from feat/issue-20-pantry-staples into master 2026-04-03 09:35:03 +02:00
15 changed files with 740 additions and 5 deletions

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import StapleChip from './StapleChip.svelte';
type Ingredient = { id: string; name: string; isStaple: boolean };
let { name, ingredients, onToggle }: {
name: string;
ingredients: Ingredient[];
onToggle: (id: string, value: boolean) => void;
} = $props();
</script>
<div>
<p class="text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)] mb-[8px]">
{name}
</p>
<div class="flex flex-wrap gap-[6px]">
{#each ingredients as ingredient (ingredient.id)}
<StapleChip
name={ingredient.name}
selected={ingredient.isStaple}
onToggle={(value) => onToggle(ingredient.id, value)}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,55 @@
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 = [
{ id: '1', name: 'Olivenöl', isStaple: true },
{ id: '2', name: 'Butter', isStaple: false },
{ id: '3', name: 'Kokosöl', isStaple: false }
];
describe('CategorySection', () => {
it('renders the category name as a heading', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
});
it('renders a chip for each ingredient', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Butter' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Kokosöl' })).toBeInTheDocument();
});
it('reflects isStaple state on each chip', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onToggle with ingredient id and new value when chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle }
});
await user.click(screen.getByRole('button', { name: 'Butter' }));
expect(onToggle).toHaveBeenCalledWith('2', true);
});
it('renders an empty category without crashing', () => {
render(CategorySection, {
props: { name: 'Leer', ingredients: [], onToggle: vi.fn() }
});
expect(screen.getByText('Leer')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let { name, selected, onToggle }: {
name: string;
selected: boolean;
onToggle: (value: boolean) => void;
} = $props();
</script>
<button
type="button"
aria-pressed={selected}
onclick={() => onToggle(!selected)}
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)]'
: 'bg-[var(--color-surface)] border-[var(--color-border)] text-[var(--color-text-muted)]'}"
>
{name}
</button>

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import StapleChip from './StapleChip.svelte';
describe('StapleChip', () => {
it('renders a button with the ingredient name', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
});
it('is aria-pressed="false" when unselected', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'false');
});
it('is aria-pressed="true" when selected', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
});
it('calls onToggle with true when unselected chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle } });
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
expect(onToggle).toHaveBeenCalledWith(true);
});
it('calls onToggle with false when selected chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle } });
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
expect(onToggle).toHaveBeenCalledWith(false);
});
it('has a visible focus ring class for keyboard accessibility', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
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

@@ -0,0 +1,80 @@
<script lang="ts">
import CategorySection from './CategorySection.svelte';
type Ingredient = { id: string; name: string; isStaple: boolean };
type Category = { id: string; name: string; ingredients: Ingredient[] };
let { categories, context }: {
categories: Category[];
context: 'onboarding' | 'settings';
} = $props();
let stapleState = $state<Record<string, boolean>>({});
let errorMessage = $state('');
$effect(() => {
const initial: Record<string, boolean> = {};
for (const cat of categories) {
for (const ing of cat.ingredients) {
initial[ing.id] = ing.isStaple;
}
}
stapleState = initial;
});
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: any[]) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
}) as T;
}
const debouncedPatchers: Record<string, (id: string, value: boolean) => void> = {};
function getPatcher(id: string) {
if (!debouncedPatchers[id]) {
debouncedPatchers[id] = debounce(async (ingredientId: string, value: boolean) => {
const previous = !value;
const res = await fetch(`/household/staples`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ingredientId, isStaple: value })
});
if (!res.ok) {
stapleState[ingredientId] = previous;
errorMessage = 'Vorrat konnte nicht gespeichert werden.';
}
}, 300);
}
return debouncedPatchers[id];
}
function handleToggle(ingredientId: string, newValue: boolean) {
errorMessage = '';
stapleState[ingredientId] = newValue;
getPatcher(ingredientId)(ingredientId, newValue);
}
</script>
<div>
{#if errorMessage}
<p class="mb-[12px] text-[12px] text-[var(--color-error)]">{errorMessage}</p>
{/if}
<div
data-testid="category-grid"
class="grid grid-cols-1 gap-[24px_32px] {context === 'settings' ? 'md:grid-cols-3' : 'md:grid-cols-2'}"
>
{#each categories as category (category.id)}
<CategorySection
name={category.name}
ingredients={category.ingredients.map(ing => ({
...ing,
isStaple: stapleState[ing.id] ?? ing.isStaple
}))}
onToggle={handleToggle}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import StaplesManager from './StaplesManager.svelte';
const mockCategories = [
{
id: 'cat-1',
name: 'Öle & Fette',
ingredients: [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false }
]
},
{
id: 'cat-2',
name: 'Gewürze',
ingredients: [
{ id: 'ing-3', name: 'Salz', isStaple: true },
{ id: 'ing-4', name: 'Pfeffer', isStaple: true }
]
}
];
describe('StaplesManager', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('renders all categories', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
expect(screen.getByText('Gewürze')).toBeInTheDocument();
});
it('renders all chips with correct initial aria-pressed state', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Salz' })).toHaveAttribute('aria-pressed', 'true');
});
it('clicking a chip immediately updates aria-pressed (optimistic)', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
expect(butter).toHaveAttribute('aria-pressed', 'false');
await user.click(butter);
expect(butter).toHaveAttribute('aria-pressed', 'true');
});
it('rapid clicks on same chip result in exactly one fetch call after debounce', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
await user.click(butter);
await user.click(butter);
await user.click(butter);
expect(fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('reverts chip and shows error when PATCH fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
await user.click(butter);
expect(butter).toHaveAttribute('aria-pressed', 'true');
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(butter).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText(/konnte nicht gespeichert werden/i)).toBeInTheDocument();
});
it('uses 2-column grid class in onboarding context', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const grid = screen.getByTestId('category-grid');
expect(grid.className).toContain('md:grid-cols-2');
});
it('uses 3-column grid class in settings context', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'settings' } });
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

@@ -0,0 +1,7 @@
<svelte:head>
<title>Mitglieder einladen — Mealplan</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)]">
<p class="text-[var(--color-text-muted)]">A4 — Mitglieder einladen (coming soon)</p>
</div>

View File

@@ -34,6 +34,6 @@ export const actions = {
});
}
throw redirect(303, '/household/staples');
throw redirect(303, '/household/staples?ctx=onboarding');
}
} satisfies Actions;

View File

@@ -97,7 +97,7 @@ describe('household setup — form action', () => {
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/household/staples');
expect(e.location).toBe('/household/staples?ctx=onboarding');
}
});

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, url }) => {
const api = apiClient(fetch);
const [categoriesResult, ingredientsResult] = await Promise.all([
api.GET('/v1/ingredient-categories'),
api.GET('/v1/ingredients')
]);
const rawCategories = categoriesResult.data ?? [];
const rawIngredients = ingredientsResult.data ?? [];
const categories = rawCategories.map((cat) => ({
id: cat.id!,
name: cat.name!,
ingredients: rawIngredients
.filter((ing) => ing.category?.id === cat.id)
.map((ing) => ({
id: ing.id!,
name: ing.name!,
isStaple: ing.isStaple ?? false
}))
}));
return { categories, ctx: url.searchParams.get('ctx') };
};

View File

@@ -1,7 +1,51 @@
<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 }: { data: { categories: Category[]; ctx: string | null } } = $props();
const isOnboarding = $derived(data.ctx === 'onboarding');
</script>
<svelte:head>
<title>Vorräte einrichten — Mealplan</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)]">
<p class="text-[var(--color-text-muted)]">A3 — Vorräte einrichten (coming soon)</p>
</div>
{#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]">
<!-- Desktop sidebar -->
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
<ProgressSidebar currentStep={2} />
</aside>
<!-- Main area -->
<main class="flex flex-1 flex-col">
<!-- Mobile step indicator -->
<p class="md:hidden px-6 pt-6 text-sm text-[var(--color-text-muted)]">Schritt 2 von 3</p>
<!-- Content -->
<div class="flex-1 p-6">
<StaplesManager categories={data.categories} context="onboarding" />
</div>
<!-- Footer navigation -->
<div class="flex justify-between p-6">
<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 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

@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
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;
if (!id) {
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 } },
body: { isStaple }
});
if (error) {
const status = (error as { status?: number }).status ?? 500;
return json({ error: 'Failed to update ingredient' }, { status });
}
return new Response(null, { status: 204 });
};

View File

@@ -0,0 +1,107 @@
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 })
}));
const mockCategories = [
{ id: 'cat-1', name: 'Öle & Fette' },
{ id: 'cat-2', name: 'Gewürze' }
];
const mockIngredients = [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true, category: { id: 'cat-1', name: 'Öle & Fette' } },
{ id: 'ing-2', name: 'Butter', isStaple: false, category: { id: 'cat-1', name: 'Öle & Fette' } },
{ id: 'ing-3', name: 'Salz', isStaple: true, category: { id: 'cat-2', name: 'Gewürze' } }
];
describe('household staples page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function mockApiResponses() {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredient-categories') {
return Promise.resolve({ data: mockCategories, error: undefined });
}
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
});
}
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(), url: new URL('http://localhost/household/staples') } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredient-categories');
expect(calls).toContain('/v1/ingredients');
});
it('groups ingredients by category id', async () => {
mockApiResponses();
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');
expect(oele.ingredients).toHaveLength(2);
expect(oele.ingredients[0].name).toBe('Olivenöl');
});
it('preserves isStaple flag on each ingredient', async () => {
mockApiResponses();
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);
expect(oele.ingredients.find((i: any) => i.name === 'Butter').isStaple).toBe(false);
});
it('categories without ingredients are included with empty array', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredient-categories') {
return Promise.resolve({ data: [...mockCategories, { id: 'cat-3', name: 'Leer' }], error: undefined });
}
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
});
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

@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
const mockCategories = [
{
id: 'cat-1',
name: 'Öle & Fette',
ingredients: [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false }
]
}
];
describe('staples page — onboarding context (?ctx=onboarding)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders ProgressSidebar with step 2 active', () => {
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' } } });
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' } } });
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' } } });
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
});
it('sets the page title', () => {
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' } } });
expect(screen.getByText(/schritt 2 von 3/i)).toBeInTheDocument();
});
});
describe('staples page — settings context (no ctx)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not render ProgressSidebar', () => {
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, 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, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ PATCH: mockPatch })
}));
describe('household staples PATCH handler', () => {
let PATCH: any;
beforeEach(async () => {
mockPatch.mockReset();
const mod = await import('./+server');
PATCH = mod.PATCH;
});
function createRequest(body: object, rolle: 'planer' | 'mitglied' = 'planer') {
return {
request: {
json: () => Promise.resolve(body)
},
fetch: vi.fn(),
locals: { benutzer: { rolle } }
} as any;
}
it('calls backend PATCH /v1/ingredients/{id} with isStaple', async () => {
mockPatch.mockResolvedValue({ data: {}, error: undefined });
await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
expect(mockPatch).toHaveBeenCalledWith('/v1/ingredients/{id}', {
params: { path: { id: 'ing-1' } },
body: { isStaple: true }
});
});
it('returns 204 on success', async () => {
mockPatch.mockResolvedValue({ data: {}, error: undefined });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
expect(response.status).toBe(204);
});
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 }));
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();
});
});