diff --git a/frontend/src/lib/components/ProgressSidebar.svelte b/frontend/src/lib/components/ProgressSidebar.svelte new file mode 100644 index 0000000..102d065 --- /dev/null +++ b/frontend/src/lib/components/ProgressSidebar.svelte @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/lib/components/ProgressSidebar.test.ts b/frontend/src/lib/components/ProgressSidebar.test.ts new file mode 100644 index 0000000..a27b723 --- /dev/null +++ b/frontend/src/lib/components/ProgressSidebar.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ProgressSidebar from './ProgressSidebar.svelte'; + +describe('ProgressSidebar', () => { + it('renders the app logo and name', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByText('Mealplan')).toBeInTheDocument(); + }); + + it('renders all 3 step labels', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByText('Haushalt benennen')).toBeInTheDocument(); + expect(screen.getByText('Vorräte einrichten')).toBeInTheDocument(); + expect(screen.getByText('Mitglieder einladen')).toBeInTheDocument(); + }); + + it('step 1 active: renders green circle for step 1', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + const step1 = screen.getByTestId('step-1'); + expect(step1).toHaveAttribute('aria-current', 'step'); + }); + + it('step 1 active: steps 2 and 3 are not current', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 2 active: step 1 is completed (checkmark), step 2 is current, step 3 is future', () => { + render(ProgressSidebar, { props: { currentStep: 2 } }); + expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 1 completed has accessible label', () => { + render(ProgressSidebar, { props: { currentStep: 2 } }); + const step1 = screen.getByTestId('step-1'); + expect(step1).toHaveAttribute('data-state', 'completed'); + expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument(); + }); + + it('each step has an accessible aria-label', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/schritt 2/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/schritt 3/i)).toBeInTheDocument(); + }); + + it('future steps do not have aria-current', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 3 active: steps 1 and 2 are both completed', () => { + render(ProgressSidebar, { props: { currentStep: 3 } }); + expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-2')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-3')).toHaveAttribute('aria-current', 'step'); + }); +}); diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte new file mode 100644 index 0000000..e176f54 --- /dev/null +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte @@ -0,0 +1,80 @@ + + +
+

Haushalt benennen

+

+ Gib deinem Haushalt einen Namen, damit du ihn leicht wiederfindest. +

+ +
+ + + {#if error} +

{error}

+ {/if} +
+ + {#if formError} +

+ {formError} +

+ {/if} + + +
diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts new file mode 100644 index 0000000..e3f7785 --- /dev/null +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import HouseholdSetupForm from './HouseholdSetupForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('HouseholdSetupForm', () => { + it('renders household name input with label', () => { + render(HouseholdSetupForm); + expect(screen.getByLabelText('Haushaltsname')).toBeInTheDocument(); + }); + + it('renders heading', () => { + render(HouseholdSetupForm); + expect(screen.getByText('Haushalt benennen')).toBeInTheDocument(); + }); + + it('renders Continue button', () => { + render(HouseholdSetupForm); + expect(screen.getByRole('button', { name: /weiter/i })).toBeInTheDocument(); + }); + + it('Continue button is disabled when name is empty', () => { + render(HouseholdSetupForm); + const btn = screen.getByRole('button', { name: /weiter/i }); + expect(btn).toBeDisabled(); + }); + + it('Continue button is enabled when name has text', async () => { + const user = userEvent.setup(); + render(HouseholdSetupForm); + + await user.type(screen.getByLabelText('Haushaltsname'), 'Familie Müller'); + expect(screen.getByRole('button', { name: /weiter/i })).not.toBeDisabled(); + }); + + it('shows validation error when submitting with empty name', async () => { + const user = userEvent.setup(); + render(HouseholdSetupForm); + + // Type then clear: sets touched=true, which makes the $derived error visible + // as soon as the field is empty. The button is disabled so the click is a no-op, + // but the error is already shown from the touched+empty state. + const input = screen.getByLabelText('Haushaltsname'); + await user.type(input, 'a'); + await user.clear(input); + await user.click(screen.getByRole('button', { name: /weiter/i })); + + expect(screen.getByText('Haushaltsname ist erforderlich')).toBeInTheDocument(); + }); + + it('shows server-side error from form prop', () => { + render(HouseholdSetupForm, { + props: { + form: { + errors: { form: 'Haushalt konnte nicht erstellt werden.' }, + name: 'Smith family' + } + } + }); + expect(screen.getByText('Haushalt konnte nicht erstellt werden.')).toBeInTheDocument(); + }); + + it('repopulates name from form prop on server error', () => { + render(HouseholdSetupForm, { + props: { + form: { + errors: { form: 'Fehler' }, + name: 'Familie Müller' + } + } + }); + expect(screen.getByLabelText('Haushaltsname')).toHaveValue('Familie Müller'); + }); + + it('input has correct placeholder', () => { + render(HouseholdSetupForm); + expect(screen.getByPlaceholderText('z.B. Familie Müller')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/household/setup/+page.server.ts b/frontend/src/routes/household/setup/+page.server.ts new file mode 100644 index 0000000..6fe21b8 --- /dev/null +++ b/frontend/src/routes/household/setup/+page.server.ts @@ -0,0 +1,39 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.haushalt?.id) { + throw redirect(303, '/planner'); + } + return {}; +}; + +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const name = (formData.get('name') ?? '').toString().trim(); + + if (!name) { + return fail(400, { errors: { name: 'Haushaltsname ist erforderlich' }, name: '' }); + } + + if (name.length > 100) { + return fail(400, { errors: { name: 'Haushaltsname darf maximal 100 Zeichen lang sein' }, name }); + } + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/households', { + body: { name } + }); + + if (error || !data?.data) { + return fail(500, { + errors: { form: 'Haushalt konnte nicht erstellt werden. Bitte versuche es erneut.' }, + name + }); + } + + throw redirect(303, '/household/staples'); + } +} satisfies Actions; diff --git a/frontend/src/routes/household/setup/+page.svelte b/frontend/src/routes/household/setup/+page.svelte new file mode 100644 index 0000000..8048d8c --- /dev/null +++ b/frontend/src/routes/household/setup/+page.svelte @@ -0,0 +1,41 @@ + + + + Haushalt einrichten — Mealplan + + +
+ + + + +
+ +
+

+ Schritt 1 von 3 +

+
+ + +
+
+ +
+
+
+
diff --git a/frontend/src/routes/household/setup/page.server.test.ts b/frontend/src/routes/household/setup/page.server.test.ts new file mode 100644 index 0000000..46e3679 --- /dev/null +++ b/frontend/src/routes/household/setup/page.server.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ POST: mockPost }) +})); + +describe('household setup — load', () => { + let load: any; + + beforeEach(async () => { + const mod = await import('./+page.server'); + load = mod.load; + }); + + it('redirects to /planner when user already has a household', async () => { + const event = { + locals: { + benutzer: { id: '1', name: 'Sarah', rolle: 'planer' }, + haushalt: { id: 'household-123', name: 'Smith family' } + } + } as any; + + try { + await load(event); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/planner'); + } + }); + + it('allows access when user has no household', async () => { + const event = { + locals: { + benutzer: { id: '1', name: 'Sarah', rolle: 'planer' }, + haushalt: { id: undefined, name: 'Kein Haushalt' } + } + } as any; + + const result = await load(event); + expect(result).toBeDefined(); + }); +}); + +describe('household setup — form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createRequest(formData: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + function mockSuccess() { + return { + data: { data: { id: 'hh-123', name: 'Smith family', members: [] } }, + error: undefined + }; + } + + it('calls POST /v1/households with the household name', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createRequest({ name: 'Smith family' })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/households', { + body: { name: 'Smith family' } + }); + }); + + it('redirects to /household/staples on success', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createRequest({ name: 'Smith family' })); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/household/staples'); + } + }); + + it('returns fail(400) when name is empty', async () => { + const result = await actions.default(createRequest({ name: '' })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when name is whitespace only', async () => { + const result = await actions.default(createRequest({ name: ' ' })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('echoes name back on validation error', async () => { + const result = await actions.default(createRequest({ name: '' })); + expect(result.data.name).toBe(''); + }); + + it('returns fail(400) when name exceeds 100 characters', async () => { + const longName = 'a'.repeat(101); + const result = await actions.default(createRequest({ name: longName })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('accepts name at exactly 100 characters', async () => { + mockPost.mockResolvedValue(mockSuccess()); + const maxName = 'a'.repeat(100); + + try { + await actions.default(createRequest({ name: maxName })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalled(); + }); + + it('returns fail with form error on API failure', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 500, message: 'Internal server error' } + }); + + const result = await actions.default(createRequest({ name: 'Smith family' })); + + expect(result.status).toBe(500); + expect(result.data.errors.form).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/household/setup/page.test.ts b/frontend/src/routes/household/setup/page.test.ts new file mode 100644 index 0000000..ff818b1 --- /dev/null +++ b/frontend/src/routes/household/setup/page.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('household setup page', () => { + it('renders the form heading', () => { + render(Page); + expect(screen.getByRole('heading', { name: 'Haushalt benennen' })).toBeInTheDocument(); + }); + + it('renders the household name input', () => { + render(Page); + expect(screen.getByLabelText('Haushaltsname')).toBeInTheDocument(); + }); + + it('renders the continue button', () => { + render(Page); + expect(screen.getByRole('button', { name: /weiter/i })).toBeInTheDocument(); + }); + + it('renders the ProgressSidebar with step 1 active', () => { + render(Page); + const step1 = screen.getByTestId('step-1'); + expect(step1).toHaveAttribute('aria-current', 'step'); + }); + + it('renders steps 2 and 3 as future steps', () => { + render(Page); + expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('does not render app navigation chrome', () => { + render(Page); + // No nav links like Planer or Rezepte (those are app shell nav items) + expect(screen.queryByText('Planer')).not.toBeInTheDocument(); + expect(screen.queryByText('Rezepte')).not.toBeInTheDocument(); + }); + + it('sets the page title', () => { + render(Page); + expect(document.title).toBe('Haushalt einrichten — Mealplan'); + }); + + it('renders the mobile step indicator text', () => { + render(Page); + expect(screen.getByText(/schritt 1 von 3/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/household/staples/+page.svelte b/frontend/src/routes/household/staples/+page.svelte new file mode 100644 index 0000000..6a67101 --- /dev/null +++ b/frontend/src/routes/household/staples/+page.svelte @@ -0,0 +1,7 @@ + + Vorräte einrichten — Mealplan + + +
+

A3 — Vorräte einrichten (coming soon)

+
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest';