From e85a7ca3130f47b835287766e9d5c3b060b790a4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 19:14:39 +0200 Subject: [PATCH] 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(); + }); +});