From 66cf53845419d5024ee5e30b035904cb370132fb Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:38:30 +0200 Subject: [PATCH 01/25] refactor(auth): make (public) layout bare, move brand panel into login page The signup page needs its own brand panel, so the shared layout becomes a simple slot. Login page now owns its brand panel markup. Co-Authored-By: Claude Opus 4.6 --- frontend/src/routes/(public)/+layout.svelte | 9 +-------- frontend/src/routes/(public)/login/+page.svelte | 13 +++++++++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte index d90949a..a54cfdc 100644 --- a/frontend/src/routes/(public)/+layout.svelte +++ b/frontend/src/routes/(public)/+layout.svelte @@ -2,11 +2,4 @@ let { children } = $props(); -
- -
- {@render children()} -
-
+{@render children()} diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte index 7b4a440..d2406d2 100644 --- a/frontend/src/routes/(public)/login/+page.svelte +++ b/frontend/src/routes/(public)/login/+page.svelte @@ -1,2 +1,11 @@ -

Anmelden

-

Login-Formular folgt.

+
+ +
+
+

Anmelden

+

Login-Formular folgt.

+
+
+
-- 2.49.1 From 56fc7e605290cdb66267b254c21c7e13345eb24b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:39:17 +0200 Subject: [PATCH 02/25] feat(auth): add /signup to public routes Allow unauthenticated access to the signup page. Co-Authored-By: Claude Opus 4.6 --- frontend/src/hooks.server.test.ts | 2 +- frontend/src/hooks.server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 708071e..68fab8a 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); - it.each(['/login', '/login/', '/register', '/invite/abc123'])( + it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])( 'allows public route %s without auth', async (path) => { const { event, resolve } = createEvent(path); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 0f60418..df32645 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { apiClient } from '$lib/server/api'; -const PUBLIC_ROUTES = ['/login', '/register', '/invite']; +const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite']; const STATIC_PREFIXES = ['/_app/', '/favicon']; -- 2.49.1 From e8fe69a5435ae829ca7daa0621da031f447f6910 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:41:10 +0200 Subject: [PATCH 03/25] feat(auth): add BrandPanel component for signup screen Renders brand identity with logo, app name, tagline, and feature icons on green-dark background. Responsive: banner on mobile, 440px column on desktop. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/BrandPanel.svelte | 32 ++++++++++++++++++++++ frontend/src/lib/auth/BrandPanel.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 frontend/src/lib/auth/BrandPanel.svelte create mode 100644 frontend/src/lib/auth/BrandPanel.test.ts diff --git a/frontend/src/lib/auth/BrandPanel.svelte b/frontend/src/lib/auth/BrandPanel.svelte new file mode 100644 index 0000000..a28d8ab --- /dev/null +++ b/frontend/src/lib/auth/BrandPanel.svelte @@ -0,0 +1,32 @@ + + +
+ + +

+ Mealprep +

+ +

+ Plan meals, eat well, waste less +

+ + +
diff --git a/frontend/src/lib/auth/BrandPanel.test.ts b/frontend/src/lib/auth/BrandPanel.test.ts new file mode 100644 index 0000000..38b9dd3 --- /dev/null +++ b/frontend/src/lib/auth/BrandPanel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import BrandPanel from './BrandPanel.svelte'; + +describe('BrandPanel', () => { + it('renders the app name', () => { + render(BrandPanel); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('renders the tagline', () => { + render(BrandPanel); + expect(screen.getByText('Plan meals, eat well, waste less')).toBeInTheDocument(); + }); + + it('renders the logo emoji', () => { + render(BrandPanel); + expect(screen.getByText('🥗')).toBeInTheDocument(); + }); + + it('renders three feature icons with labels', () => { + render(BrandPanel); + expect(screen.getByText('Plan')).toBeInTheDocument(); + expect(screen.getByText('Cook')).toBeInTheDocument(); + expect(screen.getByText('Shop')).toBeInTheDocument(); + }); + + it('renders feature emojis', () => { + render(BrandPanel); + expect(screen.getByText('📅')).toBeInTheDocument(); + expect(screen.getByText('🍳')).toBeInTheDocument(); + expect(screen.getByText('🛒')).toBeInTheDocument(); + }); +}); -- 2.49.1 From d5d85d1156643efea529c6cf5a1fee4ae5ee26c0 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:45:11 +0200 Subject: [PATCH 04/25] rename backend service --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 10fa0dc..3b7e8a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,11 +16,11 @@ services: timeout: 3s retries: 5 - app: + backend: build: context: ./backend dockerfile: Dockerfile - container_name: mealprep-app + container_name: mealprep-backend ports: - "8080:8080" environment: @@ -40,9 +40,9 @@ services: ports: - "3000:3000" environment: - BACKEND_URL: http://app:8080 + BACKEND_URL: http://backend:8080 depends_on: - - app + - backend volumes: pgdata: -- 2.49.1 From d3a851829854a49a65d067981233de6b5a6038ff Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:45:54 +0200 Subject: [PATCH 05/25] feat(auth): add SignupForm component with validation and password toggle Form with name/email/password fields, client-side validation, inline error messages, and password show/hide toggle. Uses native form action for progressive enhancement. Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 15 +++ frontend/package.json | 1 + frontend/src/lib/auth/SignupForm.svelte | 154 +++++++++++++++++++++++ frontend/src/lib/auth/SignupForm.test.ts | 125 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 frontend/src/lib/auth/SignupForm.svelte create mode 100644 frontend/src/lib/auth/SignupForm.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7df3349..2eea8fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.5.0", "@vitest/ui": "^4.1.2", "jsdom": "^29.0.1", @@ -1809,6 +1810,20 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee0cbb9..5733cbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.5.0", "@vitest/ui": "^4.1.2", "jsdom": "^29.0.1", diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte new file mode 100644 index 0000000..5637dd9 --- /dev/null +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -0,0 +1,154 @@ + + +
+

+ Konto erstellen +

+ +

+ Danach richtest du deinen Haushalt ein. +

+ + +
+ + + {#if errors.displayName} +

+ {errors.displayName} +

+ {/if} +
+ + +
+ + + {#if errors.email} +

+ {errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if errors.password} +

+ {errors.password} +

+ {/if} +
+ + + + + +

+ Du hast bereits ein Konto? Anmelden +

+
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts new file mode 100644 index 0000000..e7fdddb --- /dev/null +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import SignupForm from './SignupForm.svelte'; + +describe('SignupForm', () => { + it('renders all form fields with correct labels', () => { + render(SignupForm); + expect(screen.getByLabelText('Dein Name')).toBeInTheDocument(); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders submit button with correct text', () => { + render(SignupForm); + expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument(); + }); + + it('renders login link', () => { + render(SignupForm); + const link = screen.getByRole('link', { name: /anmelden/i }); + expect(link).toHaveAttribute('href', '/login'); + }); + + it('renders heading and subtitle', () => { + render(SignupForm); + expect(screen.getByText('Konto erstellen')).toBeInTheDocument(); + expect(screen.getByText('Danach richtest du deinen Haushalt ein.')).toBeInTheDocument(); + }); + + it('password field is initially of type password', () => { + render(SignupForm); + const input = screen.getByLabelText('Passwort'); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches to text and back', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const input = screen.getByLabelText('Passwort'); + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('shows validation error for empty name on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(email, 'test@example.com'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument(); + }); + + it('shows validation error for invalid email on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'notanemail'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + }); + + it('shows validation error for short password on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'test@example.com'); + await user.type(password, 'short'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); + + it('shows no validation errors when all fields are valid', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'test@example.com'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.queryByText('Name ist erforderlich')).not.toBeInTheDocument(); + expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument(); + expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument(); + }); + + it('renders placeholders on inputs', () => { + render(SignupForm); + expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 596652d6e4666a61b641557f8d991fee306acff8 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:47:36 +0200 Subject: [PATCH 06/25] feat(auth): add signup page with form action Composes BrandPanel + SignupForm in responsive split layout. Server action POSTs to /v1/auth/signup and redirects to /household/setup on success. Co-Authored-By: Claude Opus 4.6 --- .../routes/(public)/signup/+page.server.ts | 23 +++++ .../src/routes/(public)/signup/+page.svelte | 14 +++ .../(public)/signup/page.server.test.ts | 85 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 frontend/src/routes/(public)/signup/+page.server.ts create mode 100644 frontend/src/routes/(public)/signup/+page.svelte create mode 100644 frontend/src/routes/(public)/signup/page.server.test.ts diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts new file mode 100644 index 0000000..383c855 --- /dev/null +++ b/frontend/src/routes/(public)/signup/+page.server.ts @@ -0,0 +1,23 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions } from './$types'; + +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const displayName = formData.get('displayName') as string; + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/auth/signup', { + body: { displayName, email, password } + }); + + if (error) { + return fail(400, { error: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }); + } + + redirect(303, '/household/setup'); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte new file mode 100644 index 0000000..70027d2 --- /dev/null +++ b/frontend/src/routes/(public)/signup/+page.svelte @@ -0,0 +1,14 @@ + + + +
+ +
+
+ +
+
+
diff --git a/frontend/src/routes/(public)/signup/page.server.test.ts b/frontend/src/routes/(public)/signup/page.server.test.ts new file mode 100644 index 0000000..0a18c07 --- /dev/null +++ b/frontend/src/routes/(public)/signup/page.server.test.ts @@ -0,0 +1,85 @@ +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('signup 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; + } + + it('calls POST /v1/auth/signup with form data', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/auth/signup', { + body: { + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + } + }); + }); + + it('redirects to /household/setup on success', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/household/setup'); + } + }); + + it('returns fail with error message on API error', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 409, message: 'Email already registered' } + }); + + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + }); +}); -- 2.49.1 From bfa8f20fe3b70a9c97a29b93d8d184961f1fa95e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:48:23 +0200 Subject: [PATCH 07/25] test(auth): add no-nav-chrome regression test for signup page Verifies signup page renders form and brand panel but no navigation elements (tabs, sidebar, links to app routes). Co-Authored-By: Claude Opus 4.6 --- .../src/routes/(public)/signup/page.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 frontend/src/routes/(public)/signup/page.test.ts diff --git a/frontend/src/routes/(public)/signup/page.test.ts b/frontend/src/routes/(public)/signup/page.test.ts new file mode 100644 index 0000000..9efe985 --- /dev/null +++ b/frontend/src/routes/(public)/signup/page.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Page from './+page.svelte'; + +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/signup') }) + }; +}); + +describe('signup page', () => { + it('renders the signup form', () => { + render(Page); + expect(screen.getByText('Konto erstellen')).toBeInTheDocument(); + }); + + it('renders the brand panel', () => { + render(Page); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('does not render any navigation chrome', () => { + render(Page); + // No nav element should exist + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + // No app shell nav links + expect(screen.queryByText('Planer')).not.toBeInTheDocument(); + expect(screen.queryByText('Rezepte')).not.toBeInTheDocument(); + expect(screen.queryByText('Einkauf')).not.toBeInTheDocument(); + expect(screen.queryByText('Einstellungen')).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From b71c98662b3a9e92ca1d5c8d31aabeac73ad1e97 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:56:49 +0200 Subject: [PATCH 08/25] fix(auth): use --green-dark on submit button for WCAG AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --green (#3D8C4A) gives 4.16:1 against white — fails AA. --green-dark (#2E6E39) passes comfortably. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.svelte | 2 +- frontend/src/lib/auth/SignupForm.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte index 5637dd9..00c440b 100644 --- a/frontend/src/lib/auth/SignupForm.svelte +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -139,7 +139,7 @@ diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts index e7fdddb..fc92dcf 100644 --- a/frontend/src/lib/auth/SignupForm.test.ts +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -116,6 +116,12 @@ describe('SignupForm', () => { expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument(); }); + it('submit button uses --green-dark for WCAG AA contrast', () => { + render(SignupForm); + const button = screen.getByRole('button', { name: /konto erstellen/i }); + expect(button.className).toContain('bg-[var(--green-dark)]'); + }); + it('renders placeholders on inputs', () => { render(SignupForm); expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); -- 2.49.1 From 75a13d9df184da14cb1c406c3fd8b28bcc0a30d1 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:57:47 +0200 Subject: [PATCH 09/25] fix(auth): style login link green/font-medium per spec Spec shows green text with font-weight 500, no underline by default. Was dark text with underline. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.svelte | 2 +- frontend/src/lib/auth/SignupForm.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte index 00c440b..ec1cf02 100644 --- a/frontend/src/lib/auth/SignupForm.svelte +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -149,6 +149,6 @@ class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)] md:text-[13px]" > - Du hast bereits ein Konto? Anmelden + Du hast bereits ein Konto? Anmelden

diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts index fc92dcf..f891b69 100644 --- a/frontend/src/lib/auth/SignupForm.test.ts +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -16,10 +16,12 @@ describe('SignupForm', () => { expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument(); }); - it('renders login link', () => { + it('renders login link with correct href and styling', () => { render(SignupForm); const link = screen.getByRole('link', { name: /anmelden/i }); expect(link).toHaveAttribute('href', '/login'); + expect(link.className).toContain('text-[var(--green)]'); + expect(link.className).toContain('font-medium'); }); it('renders heading and subtitle', () => { -- 2.49.1 From afcea6590dc4df45e6652796f4bfa542cd60dae5 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:58:59 +0200 Subject: [PATCH 10/25] feat(auth): add autocomplete attributes to signup form inputs name, email, new-password for better browser/password-manager support. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.svelte | 3 +++ frontend/src/lib/auth/SignupForm.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte index ec1cf02..8ba5125 100644 --- a/frontend/src/lib/auth/SignupForm.svelte +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -67,6 +67,7 @@ id="displayName" name="displayName" placeholder="z.B. Sarah" + autocomplete="name" bind:value={displayName} class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none {errors.displayName ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}" @@ -91,6 +92,7 @@ id="email" name="email" placeholder="du@beispiel.de" + autocomplete="email" bind:value={email} class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none {errors.email ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}" @@ -116,6 +118,7 @@ id="password" name="password" placeholder="Mindestens 8 Zeichen" + autocomplete="new-password" bind:value={password} class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] pr-[44px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none {errors.password ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}" diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts index f891b69..20778df 100644 --- a/frontend/src/lib/auth/SignupForm.test.ts +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -124,6 +124,13 @@ describe('SignupForm', () => { expect(button.className).toContain('bg-[var(--green-dark)]'); }); + it('inputs have correct autocomplete attributes', () => { + render(SignupForm); + expect(screen.getByLabelText('Dein Name')).toHaveAttribute('autocomplete', 'name'); + expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email'); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'new-password'); + }); + it('renders placeholders on inputs', () => { render(SignupForm); expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); -- 2.49.1 From 845e669cde174c9c8280afc08dd8081600065c13 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:00:02 +0200 Subject: [PATCH 11/25] feat(auth): add page title to signup screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets Konto erstellen — Mealprep via svelte:head for browser tab and accessibility. Co-Authored-By: Claude Opus 4.6 --- frontend/src/routes/(public)/signup/+page.svelte | 4 ++++ frontend/src/routes/(public)/signup/page.test.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte index 70027d2..984298d 100644 --- a/frontend/src/routes/(public)/signup/+page.svelte +++ b/frontend/src/routes/(public)/signup/+page.svelte @@ -3,6 +3,10 @@ import SignupForm from '$lib/auth/SignupForm.svelte'; + + Konto erstellen — Mealprep + +
diff --git a/frontend/src/routes/(public)/signup/page.test.ts b/frontend/src/routes/(public)/signup/page.test.ts index 9efe985..c8fde4d 100644 --- a/frontend/src/routes/(public)/signup/page.test.ts +++ b/frontend/src/routes/(public)/signup/page.test.ts @@ -20,6 +20,11 @@ describe('signup page', () => { expect(screen.getByText('Mealprep')).toBeInTheDocument(); }); + it('sets the page title', () => { + render(Page); + expect(document.title).toBe('Konto erstellen — Mealprep'); + }); + it('does not render any navigation chrome', () => { render(Page); // No nav element should exist -- 2.49.1 From 82840bb42091dd8b88e940b8d26c2e33075ed269 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:01:03 +0200 Subject: [PATCH 12/25] fix(auth): center signup form on wide desktop screens Form container now horizontally centered on md+ viewports, left-aligned on mobile for full-width usage. Co-Authored-By: Claude Opus 4.6 --- frontend/src/routes/(public)/signup/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte index 984298d..db668d6 100644 --- a/frontend/src/routes/(public)/signup/+page.svelte +++ b/frontend/src/routes/(public)/signup/+page.svelte @@ -10,8 +10,8 @@
-
-
+
+
-- 2.49.1 From bd9e1334e0ca669ba7527314433883e378c3e861 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:02:33 +0200 Subject: [PATCH 13/25] feat(auth): add server-side validation to signup form action Validates displayName, email, password server-side before calling the backend API. Handles null from formData.get() safely. Returns structured field errors via fail(400, { errors }). Co-Authored-By: Claude Opus 4.6 --- .../routes/(public)/signup/+page.server.ts | 33 ++++++++++-- .../(public)/signup/page.server.test.ts | 51 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts index 383c855..8fa6ff5 100644 --- a/frontend/src/routes/(public)/signup/+page.server.ts +++ b/frontend/src/routes/(public)/signup/+page.server.ts @@ -5,17 +5,40 @@ import type { Actions } from './$types'; export const actions = { default: async ({ request, fetch }) => { const formData = await request.formData(); - const displayName = formData.get('displayName') as string; - const email = formData.get('email') as string; - const password = formData.get('password') as string; + const displayName = (formData.get('displayName') ?? '').toString().trim(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + if (!displayName) { + errors.displayName = 'Name ist erforderlich'; + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (password.length < 8) { + errors.password = 'Mindestens 8 Zeichen'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, displayName, email }); + } const api = apiClient(fetch); - const { data, error } = await api.POST('/v1/auth/signup', { + const { error } = await api.POST('/v1/auth/signup', { body: { displayName, email, password } }); if (error) { - return fail(400, { error: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }); + return fail(400, { + errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }, + displayName, + email + }); } redirect(303, '/household/setup'); diff --git a/frontend/src/routes/(public)/signup/page.server.test.ts b/frontend/src/routes/(public)/signup/page.server.test.ts index 0a18c07..defc113 100644 --- a/frontend/src/routes/(public)/signup/page.server.test.ts +++ b/frontend/src/routes/(public)/signup/page.server.test.ts @@ -68,6 +68,56 @@ describe('signup form action', () => { } }); + it('rejects empty displayName with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: '', + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.displayName).toBe('Name ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects invalid email with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'notanemail', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects short password with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'short' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBe('Mindestens 8 Zeichen'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('handles missing form fields without crashing', async () => { + const fd = new FormData(); + const event = { + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + + const result = await actions.default(event); + expect(result.status).toBe(400); + expect(result.data.errors.displayName).toBe('Name ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + it('returns fail with error message on API error', async () => { mockPost.mockResolvedValue({ data: undefined, @@ -81,5 +131,6 @@ describe('signup form action', () => { })); expect(result.status).toBe(400); + expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.'); }); }); -- 2.49.1 From 6d0f00c8fb155e08f2a21e8781f5bda2261b54cf Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:06:21 +0200 Subject: [PATCH 14/25] feat(auth): add use:enhance and server error display to signup form SignupForm now uses use:enhance for progressive enhancement. Accepts form prop for server-side error display. Shows general form errors in a banner and field-specific errors inline. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.svelte | 39 +++++++++++++++++-- frontend/src/lib/auth/SignupForm.test.ts | 32 +++++++++++++++ .../src/routes/(public)/signup/+page.svelte | 4 +- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte index 8ba5125..d04e657 100644 --- a/frontend/src/lib/auth/SignupForm.svelte +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -1,8 +1,19 @@ -
+

+ {#if formError} +

+ {formError} +

+ {/if} +

-- 2.49.1 From 7de18740f216e4012a814b6ff8f6f33be3f100e6 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:07:07 +0200 Subject: [PATCH 15/25] test(auth): add multi-error test for empty form submission Verifies all three validation errors (name, email, password) appear simultaneously when submitting a completely empty form. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts index 8c375b8..842200f 100644 --- a/frontend/src/lib/auth/SignupForm.test.ts +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -163,6 +163,18 @@ describe('SignupForm', () => { expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); }); + it('shows all three validation errors when form submitted empty', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument(); + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); + it('renders placeholders on inputs', () => { render(SignupForm); expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); -- 2.49.1 From b3607ca47a8359cedd9787220a1f231354d5c4cd Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 15:07:42 +0200 Subject: [PATCH 16/25] test(auth): add password length boundary tests (7 fails, 8 passes) Parameterized test verifying the exact boundary of the 8-character minimum password requirement. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/SignupForm.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts index 842200f..897579d 100644 --- a/frontend/src/lib/auth/SignupForm.test.ts +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -175,6 +175,25 @@ describe('SignupForm', () => { expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); }); + it.each([ + { length: 7, shouldFail: true }, + { length: 8, shouldFail: false } + ])('password with $length chars $shouldFail ? fails : passes validation', async ({ length, shouldFail }) => { + const user = userEvent.setup(); + render(SignupForm); + + await user.type(screen.getByLabelText('Dein Name'), 'Sarah'); + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.type(screen.getByLabelText('Passwort'), 'a'.repeat(length)); + await user.click(screen.getByRole('button', { name: /konto erstellen/i })); + + if (shouldFail) { + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + } else { + expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument(); + } + }); + it('renders placeholders on inputs', () => { render(SignupForm); expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); -- 2.49.1 From c27c97ff7ddecc00bbcc23d884a2df1b7903709d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 16:18:49 +0200 Subject: [PATCH 17/25] feat(auth): add LoginForm component with validation and password toggle Email/password fields, client-side validation, password show/hide, server error display via form prop, signup link. Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/auth/LoginForm.svelte | 154 ++++++++++++++++++++++++ frontend/src/lib/auth/LoginForm.test.ts | 117 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 frontend/src/lib/auth/LoginForm.svelte create mode 100644 frontend/src/lib/auth/LoginForm.test.ts diff --git a/frontend/src/lib/auth/LoginForm.svelte b/frontend/src/lib/auth/LoginForm.svelte new file mode 100644 index 0000000..166bf51 --- /dev/null +++ b/frontend/src/lib/auth/LoginForm.svelte @@ -0,0 +1,154 @@ + + + +

+ Willkommen zurück +

+ +

+ Melde dich an, um fortzufahren. +

+ + +
+ + + {#if errors.email} +

+ {errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if errors.password} +

+ {errors.password} +

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

+ {formError} +

+ {/if} + + + + + +

+ Noch kein Konto? Registrieren +

+ diff --git a/frontend/src/lib/auth/LoginForm.test.ts b/frontend/src/lib/auth/LoginForm.test.ts new file mode 100644 index 0000000..d338b02 --- /dev/null +++ b/frontend/src/lib/auth/LoginForm.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import LoginForm from './LoginForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('LoginForm', () => { + it('renders email and password fields with correct labels', () => { + render(LoginForm); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders heading and subtitle', () => { + render(LoginForm); + expect(screen.getByText('Willkommen zurück')).toBeInTheDocument(); + expect(screen.getByText('Melde dich an, um fortzufahren.')).toBeInTheDocument(); + }); + + it('renders submit button', () => { + render(LoginForm); + expect(screen.getByRole('button', { name: /anmelden/i })).toBeInTheDocument(); + }); + + it('renders signup link', () => { + render(LoginForm); + const link = screen.getByRole('link', { name: /registrieren/i }); + expect(link).toHaveAttribute('href', '/signup'); + expect(link.className).toContain('text-[var(--green)]'); + expect(link.className).toContain('font-medium'); + }); + + it('submit button uses --green-dark for WCAG AA', () => { + render(LoginForm); + const button = screen.getByRole('button', { name: /anmelden/i }); + expect(button.className).toContain('bg-[var(--green-dark)]'); + }); + + it('password field is initially of type password', () => { + render(LoginForm); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches type', async () => { + const user = userEvent.setup(); + render(LoginForm); + + const input = screen.getByLabelText('Passwort'); + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('inputs have correct autocomplete attributes', () => { + render(LoginForm); + expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email'); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'current-password'); + }); + + it('shows validation error for invalid email on submit', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'notanemail'); + await user.type(screen.getByLabelText('Passwort'), 'password123'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + }); + + it('shows validation error for empty password on submit', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.getByText('Passwort ist erforderlich')).toBeInTheDocument(); + }); + + it('shows no errors when fields are valid', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.type(screen.getByLabelText('Passwort'), 'password123'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument(); + expect(screen.queryByText('Passwort ist erforderlich')).not.toBeInTheDocument(); + }); + + it('displays server-side form error from form prop', () => { + render(LoginForm, { + props: { + form: { + errors: { form: 'E-Mail oder Passwort ist falsch.' }, + email: 'test@example.com' + } + } + }); + expect(screen.getByText('E-Mail oder Passwort ist falsch.')).toBeInTheDocument(); + }); + + it('renders placeholders', () => { + render(LoginForm); + expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Dein Passwort')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 73acc0c638162f69e591205d569c4e3fa90b0351 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 16:20:02 +0200 Subject: [PATCH 18/25] feat(auth): add login server action with validation and redirect POSTs to /v1/auth/login, validates email/password server-side, redirects to ?redirect param or /planner on success. Returns generic error on bad credentials to prevent enumeration. Co-Authored-By: Claude Opus 4.6 --- .../src/routes/(public)/login/+page.server.ts | 41 ++++++ .../routes/(public)/login/page.server.test.ts | 120 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 frontend/src/routes/(public)/login/+page.server.ts create mode 100644 frontend/src/routes/(public)/login/page.server.test.ts diff --git a/frontend/src/routes/(public)/login/+page.server.ts b/frontend/src/routes/(public)/login/+page.server.ts new file mode 100644 index 0000000..dd6912b --- /dev/null +++ b/frontend/src/routes/(public)/login/+page.server.ts @@ -0,0 +1,41 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions } from './$types'; + +export const actions = { + default: async ({ request, url, fetch }) => { + const formData = await request.formData(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (!password) { + errors.password = 'Passwort ist erforderlich'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, email }); + } + + const api = apiClient(fetch); + const { error } = await api.POST('/v1/auth/login', { + body: { email, password } + }); + + if (error) { + return fail(400, { + errors: { form: 'E-Mail oder Passwort ist falsch.' }, + email + }); + } + + const redirectTo = url.searchParams.get('redirect') || '/planner'; + redirect(303, redirectTo); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/login/page.server.test.ts b/frontend/src/routes/(public)/login/page.server.test.ts new file mode 100644 index 0000000..a3f636e --- /dev/null +++ b/frontend/src/routes/(public)/login/page.server.test.ts @@ -0,0 +1,120 @@ +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('login form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createEvent(formData: Record, searchParams = '') { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + request: { formData: () => Promise.resolve(fd) }, + url: new URL(`http://localhost/login${searchParams}`), + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + it('calls POST /v1/auth/login with form data', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/auth/login', { + body: { + email: 'sarah@example.com', + password: 'password123' + } + }); + }); + + it('redirects to /planner on success by default', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/planner'); + } + }); + + it('redirects to ?redirect param when present', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent( + { email: 'sarah@example.com', password: 'password123' }, + '?redirect=%2Frecipes%2Fabc' + )); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/recipes/abc'); + } + }); + + it('rejects empty email with validation error', async () => { + const result = await actions.default(createEvent({ + email: '', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects empty password with validation error', async () => { + const result = await actions.default(createEvent({ + email: 'sarah@example.com', + password: '' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBe('Passwort ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('returns fail with form error on API error', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 401, message: 'Invalid credentials' } + }); + + const result = await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBe('E-Mail oder Passwort ist falsch.'); + }); +}); -- 2.49.1 From 999e54de86e26d346fa8a8357b66455d32f74575 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 16:21:31 +0200 Subject: [PATCH 19/25] feat(auth): build login page with LoginForm, brand panel, and title Replaces placeholder with full login page: brand panel left, LoginForm right, svelte:head title, signup link, no-nav-chrome. Co-Authored-By: Claude Opus 4.6 --- .../src/routes/(public)/login/+page.svelte | 26 ++++++++--- .../src/routes/(public)/login/page.test.ts | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 frontend/src/routes/(public)/login/page.test.ts diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte index d2406d2..79a85b8 100644 --- a/frontend/src/routes/(public)/login/+page.svelte +++ b/frontend/src/routes/(public)/login/+page.svelte @@ -1,11 +1,25 @@ -
-