feat(onboarding): A2 — Household setup page #34
71
frontend/src/lib/components/ProgressSidebar.svelte
Normal file
71
frontend/src/lib/components/ProgressSidebar.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { currentStep }: { currentStep: number } = $props();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
label: 'Haushalt benennen',
|
||||||
|
subtitle: 'Deiner Familie einen Namen geben'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 2,
|
||||||
|
label: 'Vorräte einrichten',
|
||||||
|
subtitle: 'Was ihr immer zu Hause habt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 3,
|
||||||
|
label: 'Mitglieder einladen',
|
||||||
|
subtitle: 'Haushalt teilen'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function circleClass(n: number): string {
|
||||||
|
if (n === currentStep) return 'bg-[var(--green)] text-white';
|
||||||
|
if (n < currentStep) return 'bg-[var(--green-tint)] text-[var(--green-dark)]';
|
||||||
|
return 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelClass(n: number): string {
|
||||||
|
if (n === currentStep) return 'text-[13px] font-medium text-[var(--color-text)]';
|
||||||
|
return 'text-[13px] text-[var(--color-text-muted)]';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<!-- Logo row -->
|
||||||
|
<div class="flex items-center gap-[8px] mb-[40px]">
|
||||||
|
<div
|
||||||
|
class="w-[28px] h-[28px] rounded-[6px] bg-[var(--green)] flex items-center justify-center text-[14px]"
|
||||||
|
>
|
||||||
|
🥗
|
||||||
|
</div>
|
||||||
|
<span class="font-[var(--font-display)] text-[16px] font-medium">Mealplan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="flex flex-col gap-[24px]">
|
||||||
|
{#each steps as step (step.number)}
|
||||||
|
<div
|
||||||
|
class="flex gap-[12px] items-start"
|
||||||
|
data-testid="step-{step.number}"
|
||||||
|
data-state={step.number < currentStep
|
||||||
|
? 'completed'
|
||||||
|
: step.number === currentStep
|
||||||
|
? 'current'
|
||||||
|
: 'future'}
|
||||||
|
aria-current={step.number === currentStep ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-[28px] h-[28px] rounded-full flex items-center justify-center text-[12px] font-medium flex-shrink-0 {circleClass(step.number)}"
|
||||||
|
aria-label="Schritt {step.number}"
|
||||||
|
>
|
||||||
|
{step.number < currentStep ? '✓' : step.number}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class={labelClass(step.number)}>{step.label}</div>
|
||||||
|
<div class="text-[11px] text-[var(--color-text-muted)] mt-[2px]">{step.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
63
frontend/src/lib/components/ProgressSidebar.test.ts
Normal file
63
frontend/src/lib/components/ProgressSidebar.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
80
frontend/src/lib/onboarding/HouseholdSetupForm.svelte
Normal file
80
frontend/src/lib/onboarding/HouseholdSetupForm.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
type FormResult = {
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
let { form = null }: { form?: FormResult } = $props();
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let touched = $state(false);
|
||||||
|
let submitAttempted = $state(false);
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
const isDisabled = $derived(name.trim().length === 0);
|
||||||
|
const error = $derived(
|
||||||
|
(touched || submitAttempted) && name.trim() === '' ? 'Haushaltsname ist erforderlich' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.errors) {
|
||||||
|
formError = form.errors.form ?? '';
|
||||||
|
name = form?.name ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: SubmitEvent) {
|
||||||
|
submitAttempted = true;
|
||||||
|
if (name.trim() === '') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
|
||||||
|
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px]">Haushalt benennen</h1>
|
||||||
|
<p class="mb-[24px] text-[12px] text-[var(--color-text-muted)] md:text-[14px]">
|
||||||
|
Gib deinem Haushalt einen Namen, damit du ihn leicht wiederfindest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-[16px]">
|
||||||
|
<label for="name" class="mb-[6px] block text-[14px] font-medium">Haushaltsname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="z.B. Familie Müller"
|
||||||
|
autocomplete="organization"
|
||||||
|
bind:value={name}
|
||||||
|
oninput={handleInput}
|
||||||
|
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] text-[14px] outline-none focus:ring-2 focus:ring-[var(--green-dark)] {error
|
||||||
|
? 'border-[var(--color-error)]'
|
||||||
|
: 'border-[var(--color-border)]'}"
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-1 text-[12px] text-[var(--color-error)]">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<p
|
||||||
|
class="mb-[16px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isDisabled}
|
||||||
|
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[14px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Weiter → Vorräte einrichten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
83
frontend/src/lib/onboarding/HouseholdSetupForm.test.ts
Normal file
83
frontend/src/lib/onboarding/HouseholdSetupForm.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/routes/household/setup/+page.server.ts
Normal file
39
frontend/src/routes/household/setup/+page.server.ts
Normal file
@@ -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;
|
||||||
41
frontend/src/routes/household/setup/+page.svelte
Normal file
41
frontend/src/routes/household/setup/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProgressSidebar from '$lib/components/ProgressSidebar.svelte';
|
||||||
|
import HouseholdSetupForm from '$lib/onboarding/HouseholdSetupForm.svelte';
|
||||||
|
|
||||||
|
type FormResult = {
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
let { form = null }: { form?: FormResult } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Haushalt einrichten — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen bg-[var(--color-page)]">
|
||||||
|
<!-- Desktop progress sidebar — hidden on mobile -->
|
||||||
|
<aside
|
||||||
|
class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]"
|
||||||
|
>
|
||||||
|
<ProgressSidebar currentStep={1} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Form area -->
|
||||||
|
<main class="flex flex-1 flex-col justify-center">
|
||||||
|
<!-- Mobile: step indicator (visible only on mobile) -->
|
||||||
|
<div class="md:hidden px-[20px] pt-[24px] pb-[0]">
|
||||||
|
<p class="text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)]">
|
||||||
|
Schritt 1 von 3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="px-[20px] py-[24px] md:px-[56px] md:py-[48px]">
|
||||||
|
<div class="max-w-[420px]">
|
||||||
|
<HouseholdSetupForm {form} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
158
frontend/src/routes/household/setup/page.server.test.ts
Normal file
158
frontend/src/routes/household/setup/page.server.test.ts
Normal file
@@ -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<string, string>) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
frontend/src/routes/household/setup/page.test.ts
Normal file
53
frontend/src/routes/household/setup/page.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
7
frontend/src/routes/household/staples/+page.svelte
Normal file
7
frontend/src/routes/household/staples/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>Vorräte einrichten — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)]">
|
||||||
|
<p class="text-[var(--color-text-muted)]">A3 — Vorräte einrichten (coming soon)</p>
|
||||||
|
</div>
|
||||||
1
frontend/src/test-setup.ts
Normal file
1
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Reference in New Issue
Block a user