Compare commits
9 Commits
7979076f5e
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
| df95462094 | |||
| 2d6ddf0e48 | |||
| 73b33ee956 | |||
| 8daaa0e21d | |||
| 45b7e7b003 | |||
| 3581af2bf9 | |||
| 21b873b85b | |||
| 65f18cfb43 | |||
| 7b497be1c1 |
@@ -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, {
|
||||
|
||||
@@ -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)]'
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') };
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user