Compare commits
65 Commits
9626bde694
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
| df95462094 | |||
| 2d6ddf0e48 | |||
| 73b33ee956 | |||
| 8daaa0e21d | |||
| 45b7e7b003 | |||
| 3581af2bf9 | |||
| 21b873b85b | |||
| 65f18cfb43 | |||
| 7b497be1c1 | |||
| 7979076f5e | |||
| d68a9d9312 | |||
| 97175e7d9d | |||
| 3550d681dc | |||
| 54df70a442 | |||
| d577e0231c | |||
| 376dc03646 | |||
| 7bdc049461 | |||
| 7c66dcad3a | |||
| 01a321caa9 | |||
| 2d1604492d | |||
| 3742364956 | |||
| 36dfea34cc | |||
| 66525484a6 | |||
| e5614ccf30 | |||
| 6de7f5a9b5 | |||
| e85a7ca313 | |||
| 175bfbe7dd | |||
| b9ef06fd73 | |||
| 09333ccc0a | |||
| 93ce1eaeac | |||
| 61249af086 | |||
| 16f0feb8d5 | |||
| 0aa65214fc | |||
| ab3363eeec | |||
| 999e54de86 | |||
| 73acc0c638 | |||
| c27c97ff7d | |||
| b3607ca47a | |||
| 7de18740f2 | |||
| 6d0f00c8fb | |||
| bd9e1334e0 | |||
| 82840bb420 | |||
| 845e669cde | |||
| afcea6590d | |||
| 75a13d9df1 | |||
| b71c98662b | |||
| bfa8f20fe3 | |||
| 596652d6e4 | |||
| d3a8518298 | |||
| d5d85d1156 | |||
| e8fe69a543 | |||
| 56fc7e6052 | |||
| 66cf538454 | |||
| 682580e11d | |||
| 5c066d33ef | |||
| 4bd020fa16 | |||
| bd8e901685 | |||
| aeaca76534 | |||
| 32550377aa | |||
| 92c7d8f92e | |||
| cc74c0042a | |||
| 2bdb1010f8 | |||
| d7f317587e | |||
| 05bf66de56 | |||
| db4b01ca77 |
@@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/auth")
|
||||
@@ -26,7 +32,7 @@ public class AuthController {
|
||||
@Valid @RequestBody SignupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.signup(request);
|
||||
httpRequest.getSession(true);
|
||||
authenticateInSession(user.email(), "user", httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@@ -35,17 +41,37 @@ public class AuthController {
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.login(request);
|
||||
HttpSession session = httpRequest.getSession(true);
|
||||
session.setAttribute("user_email", user.email());
|
||||
// Session fixation protection: invalidate old session before creating new one
|
||||
var oldSession = httpRequest.getSession(false);
|
||||
if (oldSession != null) {
|
||||
oldSession.invalidate();
|
||||
}
|
||||
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an authenticated Spring Security context and stores it in the HTTP session
|
||||
* so that subsequent requests from the same session are recognised as authenticated.
|
||||
* We do this manually because we are not using Spring Security's built-in form login.
|
||||
*/
|
||||
private void authenticateInSession(String email, String role, HttpServletRequest request) {
|
||||
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
||||
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
request.getSession(true).setAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -20,9 +18,9 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
// CSRF is disabled: SvelteKit is the only client and submits form actions
|
||||
// server-side, so the browser never calls the backend directly.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
@@ -10,16 +10,20 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerTest {
|
||||
@@ -95,6 +99,40 @@ class AuthControllerTest {
|
||||
.andExpect(jsonPath("$.data.systemRole").value("user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldStoreSecurityContextInSession() throws Exception {
|
||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
||||
|
||||
when(authService.signup(any(SignupRequest.class))).thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/auth/signup")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(request().sessionAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
notNullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldStoreSecurityContextInSession() throws Exception {
|
||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||
var response = UserResponse.withHousehold(
|
||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||
UUID.randomUUID(), "Smith family", "planner", "user");
|
||||
|
||||
when(authService.login(any(LoginRequest.class))).thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(request().sessionAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
notNullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutShouldReturn204() throws Exception {
|
||||
mockMvc.perform(post("/v1/auth/logout"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
frontend/src/app.d.ts
vendored
2
frontend/src/app.d.ts
vendored
@@ -12,7 +12,7 @@ declare global {
|
||||
rolle: 'planer' | 'mitglied';
|
||||
};
|
||||
haushalt?: {
|
||||
id: string;
|
||||
id: string | undefined;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
const event = {
|
||||
url: new URL(`http://localhost${pathname}`),
|
||||
cookies: {
|
||||
get: vi.fn().mockReturnValue(cookie)
|
||||
get: vi.fn().mockImplementation((name: string) => {
|
||||
if (name === 'JSESSIONID') return cookie;
|
||||
return undefined;
|
||||
})
|
||||
},
|
||||
locals: {} as any,
|
||||
fetch: vi.fn()
|
||||
@@ -39,15 +42,32 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('redirects unauthenticated requests on protected routes', async () => {
|
||||
const { event, resolve } = createEvent('/planner');
|
||||
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])(
|
||||
'allows public route %s without auth',
|
||||
async (path) => {
|
||||
const { event, resolve } = createEvent(path);
|
||||
await handle({ event, resolve });
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
|
||||
'allows static asset %s without auth',
|
||||
async (path) => {
|
||||
const { event, resolve } = createEvent(path);
|
||||
await handle({ event, resolve });
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
}
|
||||
);
|
||||
|
||||
it('redirects unauthenticated requests to /login with redirect param', async () => {
|
||||
const { event, resolve } = createEvent('/recipes/abc');
|
||||
try {
|
||||
await handle({ event, resolve });
|
||||
// If using SvelteKit redirect, it throws
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(302);
|
||||
expect(e.location).toBe('/login');
|
||||
expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,7 +101,37 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('redirects to /login when session validation fails', async () => {
|
||||
it('handles user without household gracefully', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
id: '456',
|
||||
displayName: 'Neu',
|
||||
householdId: null,
|
||||
householdName: null,
|
||||
householdRole: null,
|
||||
email: 'neu@example.com',
|
||||
systemRole: 'user'
|
||||
}
|
||||
},
|
||||
error: undefined
|
||||
});
|
||||
|
||||
const { event, resolve } = createEvent('/planner', 'valid-session');
|
||||
await handle({ event, resolve });
|
||||
expect(event.locals.benutzer).toEqual({
|
||||
id: '456',
|
||||
name: 'Neu',
|
||||
rolle: 'mitglied'
|
||||
});
|
||||
expect(event.locals.haushalt).toEqual({
|
||||
id: undefined,
|
||||
name: 'Kein Haushalt'
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('redirects to /login with redirect param when session validation fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
|
||||
|
||||
const { event, resolve } = createEvent('/planner', 'bad-session');
|
||||
@@ -90,7 +140,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(302);
|
||||
expect(e.location).toBe('/login');
|
||||
expect(e.location).toBe('/login?redirect=%2Fplanner');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,38 +2,48 @@ 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'];
|
||||
|
||||
function isPublicRoute(pathname: string): boolean {
|
||||
if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
|
||||
}
|
||||
|
||||
function loginRedirect(pathname: string): never {
|
||||
const target = '/login?redirect=' + encodeURIComponent(pathname);
|
||||
throw redirect(302, target);
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (isPublicRoute(event.url.pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const sessionCookie = event.cookies.get('session');
|
||||
const sessionCookie = event.cookies.get('JSESSIONID');
|
||||
if (!sessionCookie) {
|
||||
redirect(302, '/login');
|
||||
loginRedirect(event.url.pathname);
|
||||
}
|
||||
|
||||
const api = apiClient(event.fetch);
|
||||
const { data, error } = await api.GET('/v1/auth/me');
|
||||
|
||||
if (error || !data?.data) {
|
||||
redirect(302, '/login');
|
||||
loginRedirect(event.url.pathname);
|
||||
}
|
||||
|
||||
const user = data.data;
|
||||
event.locals.benutzer = {
|
||||
id: user.id!,
|
||||
name: user.displayName!,
|
||||
rolle: user.householdRole as 'planer' | 'mitglied'
|
||||
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
|
||||
};
|
||||
event.locals.haushalt = {
|
||||
id: user.householdId!,
|
||||
name: user.householdName!
|
||||
id: user.householdId ?? undefined,
|
||||
name: user.householdName ?? 'Kein Haushalt'
|
||||
};
|
||||
|
||||
return resolve(event);
|
||||
|
||||
32
frontend/src/lib/auth/BrandPanel.svelte
Normal file
32
frontend/src/lib/auth/BrandPanel.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-[var(--green-dark)] flex flex-col items-center justify-center
|
||||
px-6 py-7 text-center
|
||||
md:w-[440px] md:min-h-screen md:px-10 md:py-12"
|
||||
>
|
||||
<span class="text-[28px] md:text-[64px]" aria-hidden="true">🥗</span>
|
||||
|
||||
<h1
|
||||
class="mt-2 font-[var(--font-display)] text-[22px] font-medium tracking-[-0.02em] text-white
|
||||
md:mt-3 md:text-[36px]"
|
||||
>
|
||||
Mealprep
|
||||
</h1>
|
||||
|
||||
<p class="mt-1 text-[12px] text-[var(--green-light)] md:mt-2 md:text-[15px]">
|
||||
Plan meals, eat well, waste less
|
||||
</p>
|
||||
|
||||
<div class="mt-8 hidden gap-3 md:flex">
|
||||
{#each [{ emoji: '📅', label: 'Plan' }, { emoji: '🍳', label: 'Cook' }, { emoji: '🛒', label: 'Shop' }] as feature (feature.label)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 rounded-lg bg-white/10 px-4 py-3"
|
||||
>
|
||||
<span class="text-[18px]">{feature.emoji}</span>
|
||||
<span class="text-[10px] text-[var(--green-light)]">{feature.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
34
frontend/src/lib/auth/BrandPanel.test.ts
Normal file
34
frontend/src/lib/auth/BrandPanel.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
154
frontend/src/lib/auth/LoginForm.svelte
Normal file
154
frontend/src/lib/auth/LoginForm.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type FormResult = {
|
||||
errors?: Record<string, string>;
|
||||
email?: string;
|
||||
} | null;
|
||||
|
||||
let { form = null }: { form?: FormResult } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let showPassword = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
let errors = $state({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form?.errors) {
|
||||
errors.email = form.errors.email ?? '';
|
||||
errors.password = form.errors.password ?? '';
|
||||
formError = form.errors.form ?? '';
|
||||
if (form.email) email = form.email;
|
||||
}
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
let hasError = false;
|
||||
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
formError = '';
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailPattern.test(email)) {
|
||||
errors.email = 'Ungültige E-Mail-Adresse';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Passwort ist erforderlich';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
if (validate()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
|
||||
<h1
|
||||
class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)]
|
||||
md:text-[28px]"
|
||||
>
|
||||
Willkommen zurück
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mb-[20px] text-[12px] text-[var(--color-text-muted)]
|
||||
md:mb-[32px] md:text-[14px]"
|
||||
>
|
||||
Melde dich an, um fortzufahren.
|
||||
</p>
|
||||
|
||||
<!-- Email field -->
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="email"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
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)]'}"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||
{errors.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password field -->
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="password"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Passwort
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Dein Passwort"
|
||||
autocomplete="current-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)]'}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 text-[12px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
{#if errors.password}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||
{errors.password}
|
||||
</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}
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
|
||||
>
|
||||
Anmelden →
|
||||
</button>
|
||||
|
||||
<!-- Signup link -->
|
||||
<p
|
||||
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
|
||||
md:text-[13px]"
|
||||
>
|
||||
Noch kein Konto? <a href="/signup" class="font-medium text-[var(--green)] hover:underline">Registrieren</a>
|
||||
</p>
|
||||
</form>
|
||||
117
frontend/src/lib/auth/LoginForm.test.ts
Normal file
117
frontend/src/lib/auth/LoginForm.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
190
frontend/src/lib/auth/SignupForm.svelte
Normal file
190
frontend/src/lib/auth/SignupForm.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type FormResult = {
|
||||
errors?: Record<string, string>;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
|
||||
let { form = null }: { form?: FormResult } = $props();
|
||||
|
||||
let displayName = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let showPassword = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
let errors = $state({
|
||||
displayName: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form?.errors) {
|
||||
errors.displayName = form.errors.displayName ?? '';
|
||||
errors.email = form.errors.email ?? '';
|
||||
errors.password = form.errors.password ?? '';
|
||||
formError = form.errors.form ?? '';
|
||||
if (form.displayName) displayName = form.displayName;
|
||||
if (form.email) email = form.email;
|
||||
}
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
let hasError = false;
|
||||
|
||||
errors.displayName = '';
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
formError = '';
|
||||
|
||||
if (!displayName.trim()) {
|
||||
errors.displayName = 'Name ist erforderlich';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailPattern.test(email)) {
|
||||
errors.email = 'Ungültige E-Mail-Adresse';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.password = 'Mindestens 8 Zeichen';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
if (validate()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
|
||||
<h1
|
||||
class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)]
|
||||
md:text-[28px]"
|
||||
>
|
||||
Konto erstellen
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mb-[20px] text-[12px] text-[var(--color-text-muted)]
|
||||
md:mb-[32px] md:text-[14px]"
|
||||
>
|
||||
Danach richtest du deinen Haushalt ein.
|
||||
</p>
|
||||
|
||||
<!-- Name field -->
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="displayName"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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)]'}"
|
||||
/>
|
||||
{#if errors.displayName}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||
{errors.displayName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Email field -->
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="email"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
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)]'}"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||
{errors.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password field -->
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="password"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Passwort
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
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)]'}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 text-[12px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
{#if errors.password}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
|
||||
{errors.password}
|
||||
</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}
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
|
||||
>
|
||||
Konto erstellen →
|
||||
</button>
|
||||
|
||||
<!-- Login link -->
|
||||
<p
|
||||
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
|
||||
md:text-[13px]"
|
||||
>
|
||||
Du hast bereits ein Konto? <a href="/login" class="font-medium text-[var(--green)] hover:underline">Anmelden</a>
|
||||
</p>
|
||||
</form>
|
||||
203
frontend/src/lib/auth/SignupForm.test.ts
Normal file
203
frontend/src/lib/auth/SignupForm.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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';
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
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 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', () => {
|
||||
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('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('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('displays server-side form error when form prop has errors', () => {
|
||||
render(SignupForm, {
|
||||
props: {
|
||||
form: {
|
||||
errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
|
||||
displayName: 'Sarah',
|
||||
email: 'sarah@example.com'
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(
|
||||
screen.getByText('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays server-side field errors from form prop', () => {
|
||||
render(SignupForm, {
|
||||
props: {
|
||||
form: {
|
||||
errors: { email: 'Ungültige E-Mail-Adresse' },
|
||||
displayName: 'Sarah',
|
||||
email: 'bad'
|
||||
}
|
||||
}
|
||||
});
|
||||
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.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();
|
||||
expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Mindestens 8 Zeichen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import AppShell from './AppShell.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => {
|
||||
const { readable } = require('svelte/store');
|
||||
vi.mock('$app/stores', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
return {
|
||||
page: readable({ url: new URL('http://localhost/planner') })
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { desktopNavSections } from './nav';
|
||||
import { desktopNavSections, isActiveRoute } from './nav';
|
||||
|
||||
let { appName, householdName }: { appName: string; householdName: string } = $props();
|
||||
</script>
|
||||
@@ -24,14 +24,15 @@
|
||||
{section.title}
|
||||
</p>
|
||||
{#each section.items as item (item.href)}
|
||||
{@const active = $page.url.pathname.startsWith(item.href)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
|
||||
: ''}"
|
||||
: 'hover:bg-[var(--color-subtle)]'}"
|
||||
>
|
||||
<span class="w-[20px] text-[16px] text-center">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import DesktopSidebar from './DesktopSidebar.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => {
|
||||
const { readable } = require('svelte/store');
|
||||
vi.mock('$app/stores', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
return {
|
||||
page: readable({ url: new URL('http://localhost/planner') })
|
||||
};
|
||||
|
||||
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal file
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
|
||||
const { pageStore } = vi.hoisted(() => {
|
||||
const { writable } = require('svelte/store');
|
||||
const pageStore = writable({ url: new URL('http://localhost/planner') });
|
||||
return { pageStore };
|
||||
});
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: pageStore
|
||||
}));
|
||||
|
||||
import MobileTabBar from './MobileTabBar.svelte';
|
||||
|
||||
describe('MobileTabBar active state per route', () => {
|
||||
it.each([
|
||||
['/planner', 'Planer'],
|
||||
['/recipes', 'Rezepte'],
|
||||
['/shopping', 'Einkauf'],
|
||||
['/settings', 'Einstellungen']
|
||||
])('on %s, %s is active and others are not', (route, expectedActiveLabel) => {
|
||||
pageStore.set({ url: new URL(`http://localhost${route}`) });
|
||||
const { unmount } = render(MobileTabBar);
|
||||
|
||||
const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) });
|
||||
expect(activeLink).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
const allLinks = screen.getAllByRole('link');
|
||||
const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel));
|
||||
for (const link of inactiveLinks) {
|
||||
expect(link).not.toHaveAttribute('aria-current');
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { mobileNavItems } from './nav';
|
||||
import { mobileNavItems, isActiveRoute } from './nav';
|
||||
</script>
|
||||
|
||||
<nav
|
||||
@@ -8,7 +8,7 @@
|
||||
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
|
||||
>
|
||||
{#each mobileNavItems as item (item.href)}
|
||||
{@const active = $page.url.pathname.startsWith(item.href)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
@@ -16,6 +16,7 @@
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-[16px]">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import MobileTabBar from './MobileTabBar.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => {
|
||||
const { readable } = require('svelte/store');
|
||||
vi.mock('$app/stores', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
return {
|
||||
page: readable({ url: new URL('http://localhost/planner') })
|
||||
};
|
||||
@@ -39,6 +39,14 @@ describe('MobileTabBar', () => {
|
||||
expect(plannerLink).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders icons for each nav item', () => {
|
||||
render(MobileTabBar);
|
||||
expect(screen.getByText('📅')).toBeInTheDocument();
|
||||
expect(screen.getByText('📖')).toBeInTheDocument();
|
||||
expect(screen.getByText('🛒')).toBeInTheDocument();
|
||||
expect(screen.getByText('⚙️')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('non-active items do not have aria-current', () => {
|
||||
render(MobileTabBar);
|
||||
const recipesLink = screen.getByRole('link', { name: /rezepte/i });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { mobileNavItems } from './nav';
|
||||
import { mobileNavItems, isActiveRoute } from './nav';
|
||||
</script>
|
||||
|
||||
<nav
|
||||
@@ -8,14 +8,15 @@
|
||||
class="hidden md:flex lg:hidden gap-2 items-center p-2"
|
||||
>
|
||||
{#each mobileNavItems as item (item.href)}
|
||||
{@const active = $page.url.pathname.startsWith(item.href)}
|
||||
{@const active = isActiveRoute(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
|
||||
: ''}"
|
||||
: 'hover:bg-[var(--color-subtle)]'}"
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import TabletNavBar from './TabletNavBar.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => {
|
||||
const { readable } = require('svelte/store');
|
||||
vi.mock('$app/stores', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
return {
|
||||
page: readable({ url: new URL('http://localhost/planner') })
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mobileNavItems, desktopNavSections } from './nav';
|
||||
import { mobileNavItems, desktopNavSections, isActiveRoute } from './nav';
|
||||
|
||||
describe('nav config', () => {
|
||||
describe('mobileNavItems', () => {
|
||||
@@ -39,4 +39,22 @@ describe('nav config', () => {
|
||||
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActiveRoute', () => {
|
||||
it('matches exact route', () => {
|
||||
expect(isActiveRoute('/planner', '/planner')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches sub-route', () => {
|
||||
expect(isActiveRoute('/planner', '/planner/week')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match route with similar prefix', () => {
|
||||
expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match unrelated route', () => {
|
||||
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,26 +10,30 @@ export interface NavSection {
|
||||
}
|
||||
|
||||
export const mobileNavItems: NavItem[] = [
|
||||
{ href: '/planner', label: 'Planer', icon: 'calendar' },
|
||||
{ href: '/recipes', label: 'Rezepte', icon: 'book' },
|
||||
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' }
|
||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
||||
];
|
||||
|
||||
export function isActiveRoute(href: string, pathname: string): boolean {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
}
|
||||
|
||||
export const desktopNavSections: NavSection[] = [
|
||||
{
|
||||
title: 'Plan',
|
||||
items: [
|
||||
{ href: '/planner', label: 'Planer', icon: 'calendar' },
|
||||
{ href: '/recipes', label: 'Rezepte', icon: 'book' },
|
||||
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' }
|
||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||
{ href: '/shopping', label: 'Einkauf', icon: '🛒' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Haushalt',
|
||||
items: [
|
||||
{ href: '/members', label: 'Mitglieder', icon: 'users' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' }
|
||||
{ href: '/members', label: 'Mitglieder', icon: '👥' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
26
frontend/src/lib/onboarding/CategorySection.svelte
Normal file
26
frontend/src/lib/onboarding/CategorySection.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import StapleChip from './StapleChip.svelte';
|
||||
|
||||
type Ingredient = { id: string; name: string; isStaple: boolean };
|
||||
|
||||
let { name, ingredients, onToggle }: {
|
||||
name: string;
|
||||
ingredients: Ingredient[];
|
||||
onToggle: (id: string, value: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class="text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)] mb-[8px]">
|
||||
{name}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[6px]">
|
||||
{#each ingredients as ingredient (ingredient.id)}
|
||||
<StapleChip
|
||||
name={ingredient.name}
|
||||
selected={ingredient.isStaple}
|
||||
onToggle={(value) => onToggle(ingredient.id, value)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
55
frontend/src/lib/onboarding/CategorySection.test.ts
Normal file
55
frontend/src/lib/onboarding/CategorySection.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import CategorySection from './CategorySection.svelte';
|
||||
|
||||
const mockIngredients = [
|
||||
{ id: '1', name: 'Olivenöl', isStaple: true },
|
||||
{ id: '2', name: 'Butter', isStaple: false },
|
||||
{ id: '3', name: 'Kokosöl', isStaple: false }
|
||||
];
|
||||
|
||||
describe('CategorySection', () => {
|
||||
it('renders the category name as a heading', () => {
|
||||
render(CategorySection, {
|
||||
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
|
||||
});
|
||||
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a chip for each ingredient', () => {
|
||||
render(CategorySection, {
|
||||
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Butter' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Kokosöl' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects isStaple state on each chip', () => {
|
||||
render(CategorySection, {
|
||||
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onToggle with ingredient id and new value when chip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(CategorySection, {
|
||||
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle }
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Butter' }));
|
||||
expect(onToggle).toHaveBeenCalledWith('2', true);
|
||||
});
|
||||
|
||||
it('renders an empty category without crashing', () => {
|
||||
render(CategorySection, {
|
||||
props: { name: 'Leer', ingredients: [], onToggle: vi.fn() }
|
||||
});
|
||||
expect(screen.getByText('Leer')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/onboarding/StapleChip.svelte
Normal file
20
frontend/src/lib/onboarding/StapleChip.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { name, selected, onToggle }: {
|
||||
name: string;
|
||||
selected: boolean;
|
||||
onToggle: (value: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onToggle(!selected)}
|
||||
class="inline-flex font-sans text-[13px] font-medium tracking-[0.04em] px-[12px] py-[6px] rounded-full border cursor-pointer
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)]
|
||||
{selected
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-light)] text-[var(--green-dark)]'
|
||||
: 'bg-[var(--color-surface)] border-[var(--color-border)] text-[var(--color-text-muted)]'}"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
53
frontend/src/lib/onboarding/StapleChip.test.ts
Normal file
53
frontend/src/lib/onboarding/StapleChip.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import StapleChip from './StapleChip.svelte';
|
||||
|
||||
describe('StapleChip', () => {
|
||||
it('renders a button with the ingredient name', () => {
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is aria-pressed="false" when unselected', () => {
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('is aria-pressed="true" when selected', () => {
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('calls onToggle with true when unselected chip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onToggle with false when selected chip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
|
||||
expect(onToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('has a visible focus ring class for keyboard accessibility', () => {
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
|
||||
const btn = screen.getByRole('button', { name: 'Olivenöl' });
|
||||
expect(btn.className).toContain('focus-visible:outline');
|
||||
});
|
||||
|
||||
it('uses design-system button text spec: 13px, tracking, font-sans', () => {
|
||||
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
|
||||
const btn = screen.getByRole('button', { name: 'Olivenöl' });
|
||||
expect(btn.className).toContain('text-[13px]');
|
||||
expect(btn.className).toContain('tracking-[0.04em]');
|
||||
expect(btn.className).toContain('font-sans');
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import CategorySection from './CategorySection.svelte';
|
||||
|
||||
type Ingredient = { id: string; name: string; isStaple: boolean };
|
||||
type Category = { id: string; name: string; ingredients: Ingredient[] };
|
||||
|
||||
let { categories, context }: {
|
||||
categories: Category[];
|
||||
context: 'onboarding' | 'settings';
|
||||
} = $props();
|
||||
|
||||
let stapleState = $state<Record<string, boolean>>({});
|
||||
let errorMessage = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const cat of categories) {
|
||||
for (const ing of cat.ingredients) {
|
||||
initial[ing.id] = ing.isStaple;
|
||||
}
|
||||
}
|
||||
stapleState = initial;
|
||||
});
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return ((...args: any[]) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
const debouncedPatchers: Record<string, (id: string, value: boolean) => void> = {};
|
||||
|
||||
function getPatcher(id: string) {
|
||||
if (!debouncedPatchers[id]) {
|
||||
debouncedPatchers[id] = debounce(async (ingredientId: string, value: boolean) => {
|
||||
const previous = !value;
|
||||
const res = await fetch(`/household/staples`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: ingredientId, isStaple: value })
|
||||
});
|
||||
if (!res.ok) {
|
||||
stapleState[ingredientId] = previous;
|
||||
errorMessage = 'Vorrat konnte nicht gespeichert werden.';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
return debouncedPatchers[id];
|
||||
}
|
||||
|
||||
function handleToggle(ingredientId: string, newValue: boolean) {
|
||||
errorMessage = '';
|
||||
stapleState[ingredientId] = newValue;
|
||||
getPatcher(ingredientId)(ingredientId, newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if errorMessage}
|
||||
<p class="mb-[12px] text-[12px] text-[var(--color-error)]">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
data-testid="category-grid"
|
||||
class="grid grid-cols-1 gap-[24px_32px] {context === 'settings' ? 'md:grid-cols-3' : 'md:grid-cols-2'}"
|
||||
>
|
||||
{#each categories as category (category.id)}
|
||||
<CategorySection
|
||||
name={category.name}
|
||||
ingredients={category.ingredients.map(ing => ({
|
||||
...ing,
|
||||
isStaple: stapleState[ing.id] ?? ing.isStaple
|
||||
}))}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
107
frontend/src/lib/onboarding/StaplesManager.test.ts
Normal file
107
frontend/src/lib/onboarding/StaplesManager.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import StaplesManager from './StaplesManager.svelte';
|
||||
|
||||
const mockCategories = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
name: 'Öle & Fette',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
|
||||
{ id: 'ing-2', name: 'Butter', isStaple: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
name: 'Gewürze',
|
||||
ingredients: [
|
||||
{ id: 'ing-3', name: 'Salz', isStaple: true },
|
||||
{ id: 'ing-4', name: 'Pfeffer', isStaple: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
describe('StaplesManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders all categories', () => {
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gewürze')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all chips with correct initial aria-pressed state', () => {
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Salz' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('clicking a chip immediately updates aria-pressed (optimistic)', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
|
||||
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||
expect(butter).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(butter);
|
||||
expect(butter).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('rapid clicks on same chip result in exactly one fetch call after debounce', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
|
||||
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||
await user.click(butter);
|
||||
await user.click(butter);
|
||||
await user.click(butter);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(300);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reverts chip and shows error when PATCH fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
|
||||
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||
await user.click(butter);
|
||||
expect(butter).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(butter).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByText(/konnte nicht gespeichert werden/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses 2-column grid class in onboarding context', () => {
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||
const grid = screen.getByTestId('category-grid');
|
||||
expect(grid.className).toContain('md:grid-cols-2');
|
||||
});
|
||||
|
||||
it('uses 3-column grid class in settings context', () => {
|
||||
render(StaplesManager, { props: { categories: mockCategories, context: 'settings' } });
|
||||
const grid = screen.getByTestId('category-grid');
|
||||
expect(grid.className).toContain('md:grid-cols-3');
|
||||
});
|
||||
|
||||
it('renders without crashing when categories is empty', () => {
|
||||
render(StaplesManager, { props: { categories: [], context: 'onboarding' } });
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<div class="hidden md:flex md:w-1/2 bg-[var(--green)] items-center justify-center">
|
||||
<span class="font-[var(--font-display)] text-4xl text-white font-medium">Mealprep</span>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{@render children()}
|
||||
|
||||
47
frontend/src/routes/(public)/login/+page.server.ts
Normal file
47
frontend/src/routes/(public)/login/+page.server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const email = (formData.get('email') ?? '').toString().trim();
|
||||
const password = (formData.get('password') ?? '').toString();
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
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, response } = await api.POST('/v1/auth/login', {
|
||||
body: { email, password }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return fail(400, {
|
||||
errors: { form: 'E-Mail oder Passwort ist falsch.' },
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
|
||||
if (sessionId) {
|
||||
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
|
||||
}
|
||||
|
||||
const raw = url.searchParams.get('redirect');
|
||||
const redirectTo = raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/planner';
|
||||
throw redirect(303, redirectTo);
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,2 +1,20 @@
|
||||
<h1 class="text-2xl font-medium">Anmelden</h1>
|
||||
<p class="text-[var(--color-text-muted)] mt-2">Login-Formular folgt.</p>
|
||||
<script lang="ts">
|
||||
import BrandPanel from '$lib/auth/BrandPanel.svelte';
|
||||
import LoginForm from '$lib/auth/LoginForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anmelden — Mealprep</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile: stacked, Desktop: side by side -->
|
||||
<div class="flex min-h-screen flex-col md:flex-row">
|
||||
<BrandPanel />
|
||||
<div class="flex flex-1 flex-col items-start justify-center px-[20px] py-[24px] md:items-center md:px-[56px] md:py-[48px]">
|
||||
<div class="w-full max-w-[380px]">
|
||||
<LoginForm {form} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
175
frontend/src/routes/(public)/login/page.server.test.ts
Normal file
175
frontend/src/routes/(public)/login/page.server.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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<string, string>, 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;
|
||||
}
|
||||
|
||||
function mockSuccess() {
|
||||
return {
|
||||
data: { data: { id: '123' } },
|
||||
error: undefined,
|
||||
response: { headers: { get: vi.fn().mockReturnValue(null) } }
|
||||
};
|
||||
}
|
||||
|
||||
it('calls POST /v1/auth/login with form data', async () => {
|
||||
mockPost.mockResolvedValue(mockSuccess());
|
||||
|
||||
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(mockSuccess());
|
||||
|
||||
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(mockSuccess());
|
||||
|
||||
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('falls back to /planner when ?redirect= is an absolute URL', async () => {
|
||||
mockPost.mockResolvedValue(mockSuccess());
|
||||
|
||||
try {
|
||||
await actions.default(createEvent(
|
||||
{ email: 'sarah@example.com', password: 'password123' },
|
||||
'?redirect=https%3A%2F%2Fevil.com'
|
||||
));
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(303);
|
||||
expect(e.location).toBe('/planner');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to /planner when ?redirect= is a protocol-relative URL', async () => {
|
||||
mockPost.mockResolvedValue(mockSuccess());
|
||||
|
||||
try {
|
||||
await actions.default(createEvent(
|
||||
{ email: 'sarah@example.com', password: 'password123' },
|
||||
'?redirect=%2F%2Fevil.com'
|
||||
));
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(303);
|
||||
expect(e.location).toBe('/planner');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets JSESSIONID cookie on successful login', async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { data: { id: '123' } },
|
||||
error: undefined,
|
||||
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } }
|
||||
});
|
||||
|
||||
const event = createEvent({ email: 'sarah@example.com', password: 'password123' });
|
||||
try {
|
||||
await actions.default(event);
|
||||
} catch {
|
||||
// redirect throws
|
||||
}
|
||||
|
||||
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'abc123', expect.objectContaining({ path: '/', secure: true }));
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
44
frontend/src/routes/(public)/login/page.test.ts
Normal file
44
frontend/src/routes/(public)/login/page.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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/login') })
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
describe('login page', () => {
|
||||
it('renders the login form', () => {
|
||||
render(Page);
|
||||
expect(screen.getByText('Willkommen zurück')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the brand panel', () => {
|
||||
render(Page);
|
||||
expect(screen.getByText('Mealprep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets the page title', () => {
|
||||
render(Page);
|
||||
expect(document.title).toBe('Anmelden — Mealprep');
|
||||
});
|
||||
|
||||
it('does not render any navigation chrome', () => {
|
||||
render(Page);
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Planer')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a link to the signup page', () => {
|
||||
render(Page);
|
||||
const link = screen.getByRole('link', { name: /registrieren/i });
|
||||
expect(link).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
});
|
||||
51
frontend/src/routes/(public)/signup/+page.server.ts
Normal file
51
frontend/src/routes/(public)/signup/+page.server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const displayName = (formData.get('displayName') ?? '').toString().trim();
|
||||
const email = (formData.get('email') ?? '').toString().trim();
|
||||
const password = (formData.get('password') ?? '').toString();
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
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 { error, response } = await api.POST('/v1/auth/signup', {
|
||||
body: { displayName, email, password }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return fail(400, {
|
||||
errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
|
||||
displayName,
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
|
||||
if (sessionId) {
|
||||
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
|
||||
}
|
||||
|
||||
throw redirect(303, '/household/setup');
|
||||
}
|
||||
} satisfies Actions;
|
||||
20
frontend/src/routes/(public)/signup/+page.svelte
Normal file
20
frontend/src/routes/(public)/signup/+page.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import BrandPanel from '$lib/auth/BrandPanel.svelte';
|
||||
import SignupForm from '$lib/auth/SignupForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Konto erstellen — Mealprep</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile: stacked, Desktop: side by side -->
|
||||
<div class="flex min-h-screen flex-col md:flex-row">
|
||||
<BrandPanel />
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-[20px] py-[24px] md:px-[56px] md:py-[48px]">
|
||||
<div class="w-full max-w-[380px]">
|
||||
<SignupForm {form} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
161
frontend/src/routes/(public)/signup/page.server.test.ts
Normal file
161
frontend/src/routes/(public)/signup/page.server.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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<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: '123' } },
|
||||
error: undefined,
|
||||
response: { headers: { get: vi.fn().mockReturnValue(null) } }
|
||||
};
|
||||
}
|
||||
|
||||
it('calls POST /v1/auth/signup with form data', async () => {
|
||||
mockPost.mockResolvedValue(mockSuccess());
|
||||
|
||||
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(mockSuccess());
|
||||
|
||||
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('sets JSESSIONID cookie on successful signup', async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { data: { id: '123' } },
|
||||
error: undefined,
|
||||
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=xyz789; Path=/; HttpOnly') } }
|
||||
});
|
||||
|
||||
const event = createRequest({ displayName: 'Sarah', email: 'sarah@example.com', password: 'password123' });
|
||||
try {
|
||||
await actions.default(event);
|
||||
} catch {
|
||||
// redirect throws
|
||||
}
|
||||
|
||||
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'xyz789', expect.objectContaining({ path: '/', secure: true }));
|
||||
});
|
||||
|
||||
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,
|
||||
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);
|
||||
expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
});
|
||||
});
|
||||
38
frontend/src/routes/(public)/signup/page.test.ts
Normal file
38
frontend/src/routes/(public)/signup/page.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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('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
|
||||
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();
|
||||
});
|
||||
});
|
||||
7
frontend/src/routes/household/invite/+page.svelte
Normal file
7
frontend/src/routes/household/invite/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<svelte:head>
|
||||
<title>Mitglieder einladen — 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)]">A4 — Mitglieder einladen (coming soon)</p>
|
||||
</div>
|
||||
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?ctx=onboarding');
|
||||
}
|
||||
} 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?ctx=onboarding');
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
28
frontend/src/routes/household/staples/+page.server.ts
Normal file
28
frontend/src/routes/household/staples/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const api = apiClient(fetch);
|
||||
|
||||
const [categoriesResult, ingredientsResult] = await Promise.all([
|
||||
api.GET('/v1/ingredient-categories'),
|
||||
api.GET('/v1/ingredients')
|
||||
]);
|
||||
|
||||
const rawCategories = categoriesResult.data ?? [];
|
||||
const rawIngredients = ingredientsResult.data ?? [];
|
||||
|
||||
const categories = rawCategories.map((cat) => ({
|
||||
id: cat.id!,
|
||||
name: cat.name!,
|
||||
ingredients: rawIngredients
|
||||
.filter((ing) => ing.category?.id === cat.id)
|
||||
.map((ing) => ({
|
||||
id: ing.id!,
|
||||
name: ing.name!,
|
||||
isStaple: ing.isStaple ?? false
|
||||
}))
|
||||
}));
|
||||
|
||||
return { categories, ctx: url.searchParams.get('ctx') };
|
||||
};
|
||||
51
frontend/src/routes/household/staples/+page.svelte
Normal file
51
frontend/src/routes/household/staples/+page.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import ProgressSidebar from '$lib/components/ProgressSidebar.svelte';
|
||||
import StaplesManager from '$lib/onboarding/StaplesManager.svelte';
|
||||
|
||||
type Category = { id: string; name: string; ingredients: { id: string; name: string; isStaple: boolean }[] };
|
||||
|
||||
let { data }: { data: { categories: Category[]; ctx: string | null } } = $props();
|
||||
|
||||
const isOnboarding = $derived(data.ctx === 'onboarding');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Vorräte einrichten — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isOnboarding}
|
||||
<div class="flex min-h-screen bg-[var(--color-page)]">
|
||||
<!-- Desktop sidebar -->
|
||||
<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={2} />
|
||||
</aside>
|
||||
|
||||
<!-- Main area -->
|
||||
<main class="flex flex-1 flex-col">
|
||||
<!-- Mobile step indicator -->
|
||||
<p class="md:hidden px-6 pt-6 text-sm text-[var(--color-text-muted)]">Schritt 2 von 3</p>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 p-6">
|
||||
<StaplesManager categories={data.categories} context="onboarding" />
|
||||
</div>
|
||||
|
||||
<!-- Footer navigation -->
|
||||
<div class="flex justify-between p-6">
|
||||
<a
|
||||
href="/planner"
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>Überspringen</a>
|
||||
<a
|
||||
href="/household/invite"
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
|
||||
>Weiter</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
|
||||
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
|
||||
<StaplesManager categories={data.categories} context="settings" />
|
||||
</div>
|
||||
{/if}
|
||||
33
frontend/src/routes/household/staples/+server.ts
Normal file
33
frontend/src/routes/household/staples/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ request, fetch, locals }) => {
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, isStaple } = body;
|
||||
|
||||
if (!id) {
|
||||
return json({ error: 'id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof isStaple !== 'boolean') {
|
||||
return json({ error: 'isStaple must be a boolean' }, { status: 400 });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error } = await api.PATCH('/v1/ingredients/{id}', {
|
||||
params: { path: { id } },
|
||||
body: { isStaple }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const status = (error as { status?: number }).status ?? 500;
|
||||
return json({ error: 'Failed to update ingredient' }, { status });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
107
frontend/src/routes/household/staples/page.server.test.ts
Normal file
107
frontend/src/routes/household/staples/page.server.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
const mockCategories = [
|
||||
{ id: 'cat-1', name: 'Öle & Fette' },
|
||||
{ id: 'cat-2', name: 'Gewürze' }
|
||||
];
|
||||
|
||||
const mockIngredients = [
|
||||
{ id: 'ing-1', name: 'Olivenöl', isStaple: true, category: { id: 'cat-1', name: 'Öle & Fette' } },
|
||||
{ id: 'ing-2', name: 'Butter', isStaple: false, category: { id: 'cat-1', name: 'Öle & Fette' } },
|
||||
{ id: 'ing-3', name: 'Salz', isStaple: true, category: { id: 'cat-2', name: 'Gewürze' } }
|
||||
];
|
||||
|
||||
describe('household staples page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
function mockApiResponses() {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/v1/ingredient-categories') {
|
||||
return Promise.resolve({ data: mockCategories, error: undefined });
|
||||
}
|
||||
if (path === '/v1/ingredients') {
|
||||
return Promise.resolve({ data: mockIngredients, error: undefined });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('passes ctx from url searchParams into returned data', async () => {
|
||||
mockApiResponses();
|
||||
const url = new URL('http://localhost/household/staples?ctx=onboarding');
|
||||
const result = await load({ fetch: vi.fn(), url } as any);
|
||||
expect(result.ctx).toBe('onboarding');
|
||||
});
|
||||
|
||||
it('returns ctx as null when no ctx param is present', async () => {
|
||||
mockApiResponses();
|
||||
const url = new URL('http://localhost/household/staples');
|
||||
const result = await load({ fetch: vi.fn(), url } as any);
|
||||
expect(result.ctx).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches both categories and ingredients in parallel', async () => {
|
||||
mockApiResponses();
|
||||
await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
|
||||
|
||||
const calls = mockGet.mock.calls.map((c) => c[0]);
|
||||
expect(calls).toContain('/v1/ingredient-categories');
|
||||
expect(calls).toContain('/v1/ingredients');
|
||||
});
|
||||
|
||||
it('groups ingredients by category id', async () => {
|
||||
mockApiResponses();
|
||||
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
|
||||
|
||||
expect(result.categories).toHaveLength(2);
|
||||
const oele = result.categories.find((c: any) => c.id === 'cat-1');
|
||||
expect(oele.ingredients).toHaveLength(2);
|
||||
expect(oele.ingredients[0].name).toBe('Olivenöl');
|
||||
});
|
||||
|
||||
it('preserves isStaple flag on each ingredient', async () => {
|
||||
mockApiResponses();
|
||||
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
|
||||
|
||||
const oele = result.categories.find((c: any) => c.id === 'cat-1');
|
||||
expect(oele.ingredients.find((i: any) => i.name === 'Olivenöl').isStaple).toBe(true);
|
||||
expect(oele.ingredients.find((i: any) => i.name === 'Butter').isStaple).toBe(false);
|
||||
});
|
||||
|
||||
it('categories without ingredients are included with empty array', async () => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/v1/ingredient-categories') {
|
||||
return Promise.resolve({ data: [...mockCategories, { id: 'cat-3', name: 'Leer' }], error: undefined });
|
||||
}
|
||||
if (path === '/v1/ingredients') {
|
||||
return Promise.resolve({ data: mockIngredients, error: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
|
||||
const leer = result.categories.find((c: any) => c.id === 'cat-3');
|
||||
expect(leer).toBeDefined();
|
||||
expect(leer.ingredients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty categories when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
|
||||
expect(result.categories).toEqual([]);
|
||||
});
|
||||
});
|
||||
82
frontend/src/routes/household/staples/page.test.ts
Normal file
82
frontend/src/routes/household/staples/page.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const mockCategories = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
name: 'Öle & Fette',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
|
||||
{ id: 'ing-2', name: 'Butter', isStaple: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
describe('staples page — onboarding context (?ctx=onboarding)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders ProgressSidebar with step 2 active', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step');
|
||||
});
|
||||
|
||||
it('renders Continue button linking to /household/invite', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
const continueLink = screen.getByRole('link', { name: /weiter/i });
|
||||
expect(continueLink).toHaveAttribute('href', '/household/invite');
|
||||
});
|
||||
|
||||
it('renders Skip button linking to /planner', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
const skipLink = screen.getByRole('link', { name: /überspringen/i });
|
||||
expect(skipLink).toHaveAttribute('href', '/planner');
|
||||
});
|
||||
|
||||
it('renders the StaplesManager with categories', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets the page title', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(document.title).toBe('Vorräte einrichten — Mealplan');
|
||||
});
|
||||
|
||||
it('renders mobile step indicator Schritt 2 von 3', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
|
||||
expect(screen.getByText(/schritt 2 von 3/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('staples page — settings context (no ctx)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not render ProgressSidebar', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.queryByTestId('step-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Continue or Skip buttons', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.queryByRole('link', { name: /weiter/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /überspringen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a settings heading', () => {
|
||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
93
frontend/src/routes/household/staples/server.test.ts
Normal file
93
frontend/src/routes/household/staples/server.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockPatch = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ PATCH: mockPatch })
|
||||
}));
|
||||
|
||||
describe('household staples PATCH handler', () => {
|
||||
let PATCH: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPatch.mockReset();
|
||||
const mod = await import('./+server');
|
||||
PATCH = mod.PATCH;
|
||||
});
|
||||
|
||||
function createRequest(body: object, rolle: 'planer' | 'mitglied' = 'planer') {
|
||||
return {
|
||||
request: {
|
||||
json: () => Promise.resolve(body)
|
||||
},
|
||||
fetch: vi.fn(),
|
||||
locals: { benutzer: { rolle } }
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('calls backend PATCH /v1/ingredients/{id} with isStaple', async () => {
|
||||
mockPatch.mockResolvedValue({ data: {}, error: undefined });
|
||||
|
||||
await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith('/v1/ingredients/{id}', {
|
||||
params: { path: { id: 'ing-1' } },
|
||||
body: { isStaple: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 204 on success', async () => {
|
||||
mockPatch.mockResolvedValue({ data: {}, error: undefined });
|
||||
|
||||
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('returns 500 when backend returns a 500 error', async () => {
|
||||
mockPatch.mockResolvedValue({ data: undefined, error: { status: 500, message: 'error' } });
|
||||
|
||||
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('forwards backend 404 status when ingredient not found', async () => {
|
||||
mockPatch.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||
|
||||
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 when id is missing', async () => {
|
||||
const response = await PATCH(createRequest({ isStaple: true }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when isStaple is missing', async () => {
|
||||
const response = await PATCH(createRequest({ id: 'ing-1' }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when caller has mitglied role', async () => {
|
||||
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }, 'mitglied'));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when isStaple is not a boolean', async () => {
|
||||
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: 'yes' }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
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';
|
||||
@@ -10,6 +10,8 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test-setup.ts']
|
||||
},
|
||||
// Required for vitest: resolves Svelte to client entry (not server).
|
||||
// SvelteKit's plugin overrides this for SSR builds — verified safe.
|
||||
resolve: {
|
||||
conditions: ['browser']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user