From b9ef06fd736a5aa2e30738da6e187f489579260c Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:08:38 +0200 Subject: [PATCH 01/11] feat(onboarding): add ProgressSidebar component with 3-step active/completed/future states Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/ProgressSidebar.svelte | 71 +++++++++++++++++++ .../lib/components/ProgressSidebar.test.ts | 56 +++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 frontend/src/lib/components/ProgressSidebar.svelte create mode 100644 frontend/src/lib/components/ProgressSidebar.test.ts 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..66348d0 --- /dev/null +++ b/frontend/src/lib/components/ProgressSidebar.test.ts @@ -0,0 +1,56 @@ +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'); + }); +}); -- 2.49.1 From 175bfbe7ddb53dce15800b634eb3623bd9ea141a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:13:41 +0200 Subject: [PATCH 02/11] feat(onboarding): add HouseholdSetupForm component with disabled-until-valid continue button Co-Authored-By: Claude Sonnet 4.6 --- .../lib/onboarding/HouseholdSetupForm.svelte | 80 ++++++++++++++++++ .../lib/onboarding/HouseholdSetupForm.test.ts | 81 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 frontend/src/lib/onboarding/HouseholdSetupForm.svelte create mode 100644 frontend/src/lib/onboarding/HouseholdSetupForm.test.ts diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte new file mode 100644 index 0000000..29aeedf --- /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..aa535e2 --- /dev/null +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts @@ -0,0 +1,81 @@ +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); + + // override disabled to allow submit attempt by typing then clearing + 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(); + }); +}); -- 2.49.1 From e85a7ca3130f47b835287766e9d5c3b060b790a4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:14:39 +0200 Subject: [PATCH 03/11] feat(onboarding): add household setup page server action and load guard Creates household via POST /v1/households, redirects to /household/staples. Load guard redirects users who already have a household to /planner. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/household/setup/+page.server.ts | 35 +++++ .../household/setup/page.server.test.ts | 137 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 frontend/src/routes/household/setup/+page.server.ts create mode 100644 frontend/src/routes/household/setup/page.server.test.ts 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..9fb1224 --- /dev/null +++ b/frontend/src/routes/household/setup/+page.server.ts @@ -0,0 +1,35 @@ +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: '' }); + } + + 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.server.test.ts b/frontend/src/routes/household/setup/page.server.test.ts new file mode 100644 index 0000000..dd339f4 --- /dev/null +++ b/frontend/src/routes/household/setup/page.server.test.ts @@ -0,0 +1,137 @@ +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, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }; + } + + 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 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(); + }); +}); -- 2.49.1 From 6de7f5a9b5d4b3e7252d746969d4fb3a2e10b57c Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:20:02 +0200 Subject: [PATCH 04/11] feat(onboarding): add A2 household setup page with responsive progress sidebar layout Desktop: 300px ProgressSidebar (step 1 active) + flex form area. Mobile: "Schritt 1 von 3" eyebrow + HouseholdSetupForm. Also stubs /household/staples as redirect target for A3. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/household/setup/+page.svelte | 42 +++++++++++++++ .../src/routes/household/setup/page.test.ts | 53 +++++++++++++++++++ .../src/routes/household/staples/+page.svelte | 7 +++ frontend/src/test-setup.ts | 7 +++ 4 files changed, 109 insertions(+) create mode 100644 frontend/src/routes/household/setup/+page.svelte create mode 100644 frontend/src/routes/household/setup/page.test.ts create mode 100644 frontend/src/routes/household/staples/+page.svelte create mode 100644 frontend/src/test-setup.ts diff --git a/frontend/src/routes/household/setup/+page.svelte b/frontend/src/routes/household/setup/+page.svelte new file mode 100644 index 0000000..8e24d8b --- /dev/null +++ b/frontend/src/routes/household/setup/+page.svelte @@ -0,0 +1,42 @@ + + + + Haushalt einrichten — Mealplan + + +
+ + + + +
+ +
+

+ Schritt 1 von 3 +

+
+ + +
+
+ +
+
+
+
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..ef01078 --- /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.getByText('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..41b13c7 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { configure } from '@testing-library/dom'; + +// Exclude elements inside aria-hidden containers from text queries, +// so that visually-hidden sidebars (e.g. ProgressSidebar in onboarding pages) +// don't create duplicate text matches when the same text appears in the main content. +configure({ defaultIgnore: 'script, style, [aria-hidden="true"] *' }); -- 2.49.1 From e5614ccf30a64a7f102b8c0d1e2626d209fd38b4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:28:46 +0200 Subject: [PATCH 05/11] refactor(onboarding): remove aria-hidden workaround from progress sidebar Replace getByText with getByRole(heading) in page test to disambiguate the duplicate "Haushalt benennen" text between sidebar and form. Revert defaultIgnore change in test-setup.ts. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/household/setup/+page.svelte | 1 - frontend/src/routes/household/setup/page.test.ts | 2 +- frontend/src/test-setup.ts | 6 ------ 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/frontend/src/routes/household/setup/+page.svelte b/frontend/src/routes/household/setup/+page.svelte index 8e24d8b..8048d8c 100644 --- a/frontend/src/routes/household/setup/+page.svelte +++ b/frontend/src/routes/household/setup/+page.svelte @@ -18,7 +18,6 @@ diff --git a/frontend/src/routes/household/setup/page.test.ts b/frontend/src/routes/household/setup/page.test.ts index ef01078..ff818b1 100644 --- a/frontend/src/routes/household/setup/page.test.ts +++ b/frontend/src/routes/household/setup/page.test.ts @@ -9,7 +9,7 @@ vi.mock('$app/forms', () => ({ describe('household setup page', () => { it('renders the form heading', () => { render(Page); - expect(screen.getByText('Haushalt benennen')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Haushalt benennen' })).toBeInTheDocument(); }); it('renders the household name input', () => { diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 41b13c7..bb02c60 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,7 +1 @@ import '@testing-library/jest-dom/vitest'; -import { configure } from '@testing-library/dom'; - -// Exclude elements inside aria-hidden containers from text queries, -// so that visually-hidden sidebars (e.g. ProgressSidebar in onboarding pages) -// don't create duplicate text matches when the same text appears in the main content. -configure({ defaultIgnore: 'script, style, [aria-hidden="true"] *' }); -- 2.49.1 From 66525484a64f139699ddb7e748da39b858e229cb Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:29:21 +0200 Subject: [PATCH 06/11] fix(onboarding): correct Tailwind arbitrary font-family syntax in HouseholdSetupForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit font-['var(--font-display)'] → font-[var(--font-display)] so Fraunces display font is applied correctly to the h1. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/onboarding/HouseholdSetupForm.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte index 29aeedf..e0c6aaf 100644 --- a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte @@ -38,7 +38,7 @@
-

Haushalt benennen

+

Haushalt benennen

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

-- 2.49.1 From 36dfea34cc2ed3bba511ed58b552e653ce9f520d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:29:52 +0200 Subject: [PATCH 07/11] fix(onboarding): make HouseholdSetupForm heading responsive and use font-medium text-[18px] md:text-[28px] matches auth form pattern. font-medium (500) replaces font-semibold (600) per design system rules. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/onboarding/HouseholdSetupForm.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte index e0c6aaf..46f29e4 100644 --- a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte @@ -38,7 +38,7 @@ -

Haushalt benennen

+

Haushalt benennen

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

-- 2.49.1 From 3742364956742f2983e60e1cc104610c5df74f23 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:30:23 +0200 Subject: [PATCH 08/11] fix(onboarding): make HouseholdSetupForm subtitle responsive (12px mobile, 14px desktop) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/onboarding/HouseholdSetupForm.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte index 46f29e4..e176f54 100644 --- a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte @@ -39,7 +39,7 @@

Haushalt benennen

-

+

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

-- 2.49.1 From 2d1604492d9d00aac85a4bec92a24d53b93fd5ed Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:31:13 +0200 Subject: [PATCH 09/11] feat(onboarding): add max-length validation for household name (100 chars) Fails fast before the API call with a clear German error message. Tests boundary: 100 chars accepted, 101 rejected. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/household/setup/+page.server.ts | 4 ++++ .../household/setup/page.server.test.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/frontend/src/routes/household/setup/+page.server.ts b/frontend/src/routes/household/setup/+page.server.ts index 9fb1224..6fe21b8 100644 --- a/frontend/src/routes/household/setup/+page.server.ts +++ b/frontend/src/routes/household/setup/+page.server.ts @@ -18,6 +18,10 @@ export const actions = { 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 } diff --git a/frontend/src/routes/household/setup/page.server.test.ts b/frontend/src/routes/household/setup/page.server.test.ts index dd339f4..0971b2a 100644 --- a/frontend/src/routes/household/setup/page.server.test.ts +++ b/frontend/src/routes/household/setup/page.server.test.ts @@ -123,6 +123,28 @@ describe('household setup — form action', () => { 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, -- 2.49.1 From 01a321caa95600729138bc1ac9551707c295f3b4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:31:56 +0200 Subject: [PATCH 10/11] test(onboarding): add ProgressSidebar test for currentStep=3 (all prior steps completed) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/ProgressSidebar.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/components/ProgressSidebar.test.ts b/frontend/src/lib/components/ProgressSidebar.test.ts index 66348d0..a27b723 100644 --- a/frontend/src/lib/components/ProgressSidebar.test.ts +++ b/frontend/src/lib/components/ProgressSidebar.test.ts @@ -53,4 +53,11 @@ describe('ProgressSidebar', () => { 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'); + }); }); -- 2.49.1 From 7c66dcad3ae0b1d9640331f87c7ffa63545c8b51 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:32:44 +0200 Subject: [PATCH 11/11] refactor(onboarding): clarify test comment and remove unused response mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HouseholdSetupForm.test.ts: explain that touched+empty drives the $derived error, not a submit event on the disabled button. page.server.test.ts: remove unused response key from mockSuccess() — household creation doesn't set a session cookie. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/onboarding/HouseholdSetupForm.test.ts | 4 +++- frontend/src/routes/household/setup/page.server.test.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts index aa535e2..e3f7785 100644 --- a/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts @@ -41,7 +41,9 @@ describe('HouseholdSetupForm', () => { const user = userEvent.setup(); render(HouseholdSetupForm); - // override disabled to allow submit attempt by typing then clearing + // 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); diff --git a/frontend/src/routes/household/setup/page.server.test.ts b/frontend/src/routes/household/setup/page.server.test.ts index 0971b2a..46e3679 100644 --- a/frontend/src/routes/household/setup/page.server.test.ts +++ b/frontend/src/routes/household/setup/page.server.test.ts @@ -71,8 +71,7 @@ describe('household setup — form action', () => { function mockSuccess() { return { data: { data: { id: 'hh-123', name: 'Smith family', members: [] } }, - error: undefined, - response: { headers: { get: vi.fn().mockReturnValue(null) } } + error: undefined }; } -- 2.49.1