diff --git a/frontend/src/lib/onboarding/CategorySection.svelte b/frontend/src/lib/onboarding/CategorySection.svelte new file mode 100644 index 0000000..d9e4f4d --- /dev/null +++ b/frontend/src/lib/onboarding/CategorySection.svelte @@ -0,0 +1,26 @@ + + +
+

+ {name} +

+
+ {#each ingredients as ingredient (ingredient.id)} + onToggle(ingredient.id, value)} + /> + {/each} +
+
diff --git a/frontend/src/lib/onboarding/CategorySection.test.ts b/frontend/src/lib/onboarding/CategorySection.test.ts new file mode 100644 index 0000000..46c0347 --- /dev/null +++ b/frontend/src/lib/onboarding/CategorySection.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/onboarding/StapleChip.svelte b/frontend/src/lib/onboarding/StapleChip.svelte new file mode 100644 index 0000000..71ddb85 --- /dev/null +++ b/frontend/src/lib/onboarding/StapleChip.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/onboarding/StapleChip.test.ts b/frontend/src/lib/onboarding/StapleChip.test.ts new file mode 100644 index 0000000..f9e6939 --- /dev/null +++ b/frontend/src/lib/onboarding/StapleChip.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/onboarding/StaplesManager.svelte b/frontend/src/lib/onboarding/StaplesManager.svelte new file mode 100644 index 0000000..3e133ad --- /dev/null +++ b/frontend/src/lib/onboarding/StaplesManager.svelte @@ -0,0 +1,80 @@ + + +
+ {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ {#each categories as category (category.id)} + ({ + ...ing, + isStaple: stapleState[ing.id] ?? ing.isStaple + }))} + onToggle={handleToggle} + /> + {/each} +
+
diff --git a/frontend/src/lib/onboarding/StaplesManager.test.ts b/frontend/src/lib/onboarding/StaplesManager.test.ts new file mode 100644 index 0000000..1f7a96d --- /dev/null +++ b/frontend/src/lib/onboarding/StaplesManager.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/household/invite/+page.svelte b/frontend/src/routes/household/invite/+page.svelte new file mode 100644 index 0000000..a9bf614 --- /dev/null +++ b/frontend/src/routes/household/invite/+page.svelte @@ -0,0 +1,7 @@ + + Mitglieder einladen — Mealplan + + +
+

A4 — Mitglieder einladen (coming soon)

+
diff --git a/frontend/src/routes/household/setup/+page.server.ts b/frontend/src/routes/household/setup/+page.server.ts index 6fe21b8..d780d6c 100644 --- a/frontend/src/routes/household/setup/+page.server.ts +++ b/frontend/src/routes/household/setup/+page.server.ts @@ -34,6 +34,6 @@ export const actions = { }); } - throw redirect(303, '/household/staples'); + throw redirect(303, '/household/staples?ctx=onboarding'); } } satisfies Actions; diff --git a/frontend/src/routes/household/setup/page.server.test.ts b/frontend/src/routes/household/setup/page.server.test.ts index 46e3679..a4a43ab 100644 --- a/frontend/src/routes/household/setup/page.server.test.ts +++ b/frontend/src/routes/household/setup/page.server.test.ts @@ -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'); } }); diff --git a/frontend/src/routes/household/staples/+page.server.ts b/frontend/src/routes/household/staples/+page.server.ts new file mode 100644 index 0000000..0927f77 --- /dev/null +++ b/frontend/src/routes/household/staples/+page.server.ts @@ -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') }; +}; diff --git a/frontend/src/routes/household/staples/+page.svelte b/frontend/src/routes/household/staples/+page.svelte index 6a67101..5db2188 100644 --- a/frontend/src/routes/household/staples/+page.svelte +++ b/frontend/src/routes/household/staples/+page.svelte @@ -1,7 +1,51 @@ + + Vorräte einrichten — Mealplan -
-

A3 — Vorräte einrichten (coming soon)

-
+{#if isOnboarding} +
+ + + + +
+ +

Schritt 2 von 3

+ + +
+ +
+ + + +
+
+{:else} +
+

Vorräte

+ +
+{/if} diff --git a/frontend/src/routes/household/staples/+server.ts b/frontend/src/routes/household/staples/+server.ts new file mode 100644 index 0000000..ebc3f3a --- /dev/null +++ b/frontend/src/routes/household/staples/+server.ts @@ -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 }); +}; diff --git a/frontend/src/routes/household/staples/page.server.test.ts b/frontend/src/routes/household/staples/page.server.test.ts new file mode 100644 index 0000000..85af50a --- /dev/null +++ b/frontend/src/routes/household/staples/page.server.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/routes/household/staples/page.test.ts b/frontend/src/routes/household/staples/page.test.ts new file mode 100644 index 0000000..873bbc9 --- /dev/null +++ b/frontend/src/routes/household/staples/page.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/household/staples/server.test.ts b/frontend/src/routes/household/staples/server.test.ts new file mode 100644 index 0000000..0490cf5 --- /dev/null +++ b/frontend/src/routes/household/staples/server.test.ts @@ -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(); + }); +});