Compare commits

...

34 Commits

Author SHA1 Message Date
df95462094 refactor(staples): convert dynamic userEvent import to static in CategorySection test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:30:19 +02:00
2d6ddf0e48 fix(staples): apply design-system styles to nav links and settings heading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:29:53 +02:00
73b33ee956 fix(staples): apply design-system button spec to StapleChip (13px, tracking, font-sans)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:28:50 +02:00
8daaa0e21d fix(staples): pass ctx from URL through load function; fix script order in page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:27:43 +02:00
45b7e7b003 fix(staples): add role guard — only planer role can toggle staples
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:40 +02:00
3581af2bf9 fix(staples): forward backend error status code instead of always 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:06 +02:00
21b873b85b fix(staples): validate isStaple is boolean before forwarding to backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:35 +02:00
65f18cfb43 test(staples): cover API failure fallback in page load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:07 +02:00
7b497be1c1 test(staples): add empty categories edge case to StaplesManager
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:23:48 +02:00
7979076f5e feat(invite): stub household invite page as onboarding Continue target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:46 +02:00
d68a9d9312 refactor(setup): redirect to /household/staples?ctx=onboarding after household creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:33 +02:00
97175e7d9d feat(staples): add staples page with onboarding and settings layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:08 +02:00
3550d681dc feat(staples): load categories and ingredients, group by category
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:14:25 +02:00
54df70a442 feat(staples): add PATCH proxy server route for ingredient staple toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:13:02 +02:00
d577e0231c feat(staples): add StaplesManager with optimistic toggle and debounced PATCH
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:11:51 +02:00
376dc03646 feat(staples): add CategorySection component with eyebrow heading and chip row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:07:51 +02:00
7bdc049461 feat(staples): add StapleChip component with aria-pressed toggle and focus ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:06:32 +02:00
7c66dcad3a refactor(onboarding): clarify test comment and remove unused response mock
HouseholdSetupForm.test.ts: explain that touched+empty drives the $derived
error, not a submit event on the disabled button.
page.server.test.ts: remove unused response key from mockSuccess() —
household creation doesn't set a session cookie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:32:44 +02:00
01a321caa9 test(onboarding): add ProgressSidebar test for currentStep=3 (all prior steps completed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:31:56 +02:00
2d1604492d feat(onboarding): add max-length validation for household name (100 chars)
Fails fast before the API call with a clear German error message.
Tests boundary: 100 chars accepted, 101 rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:31:13 +02:00
3742364956 fix(onboarding): make HouseholdSetupForm subtitle responsive (12px mobile, 14px desktop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:30:23 +02:00
36dfea34cc fix(onboarding): make HouseholdSetupForm heading responsive and use font-medium
text-[18px] md:text-[28px] matches auth form pattern.
font-medium (500) replaces font-semibold (600) per design system rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:29:52 +02:00
66525484a6 fix(onboarding): correct Tailwind arbitrary font-family syntax in HouseholdSetupForm
font-['var(--font-display)'] → font-[var(--font-display)] so Fraunces
display font is applied correctly to the h1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:29:21 +02:00
e5614ccf30 refactor(onboarding): remove aria-hidden workaround from progress sidebar
Replace getByText with getByRole(heading) in page test to disambiguate
the duplicate "Haushalt benennen" text between sidebar and form.
Revert defaultIgnore change in test-setup.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:28:46 +02:00
6de7f5a9b5 feat(onboarding): add A2 household setup page with responsive progress sidebar layout
Desktop: 300px ProgressSidebar (step 1 active) + flex form area.
Mobile: "Schritt 1 von 3" eyebrow + HouseholdSetupForm.
Also stubs /household/staples as redirect target for A3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:20:02 +02:00
e85a7ca313 feat(onboarding): add household setup page server action and load guard
Creates household via POST /v1/households, redirects to /household/staples.
Load guard redirects users who already have a household to /planner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:14:39 +02:00
175bfbe7dd feat(onboarding): add HouseholdSetupForm component with disabled-until-valid continue button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:13:41 +02:00
b9ef06fd73 feat(onboarding): add ProgressSidebar component with 3-step active/completed/future states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:08:38 +02:00
09333ccc0a test(auth): verify security context is stored in session after login and signup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:55:25 +02:00
93ce1eaeac refactor(auth): add comments, clearContext on logout, explain session auth
- Add comment to SecurityConfig explaining why CSRF is disabled
- Add SecurityContextHolder.clearContext() to logout for clean thread state
- Add Javadoc on authenticateInSession() explaining manual session setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:52:16 +02:00
61249af086 feat(auth): add secure flag to JSESSIONID cookie and test JSESSIONID cookie setting
- Add secure: true to cookies.set() in login and signup actions
- Add tests verifying JSESSIONID is forwarded to browser on successful
  login and signup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:50:34 +02:00
16f0feb8d5 fix(auth): fix mock responses in tests and block open redirect in login
- Add response object to mockSuccess() in login and signup tests so
  response.headers.get() no longer throws
- Validate ?redirect= param: must start with / and not // to prevent
  redirecting users to external domains

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:48:48 +02:00
0aa65214fc fix(auth): resolve broken signup/login flow end-to-end
Three root causes fixed:

1. CSRF blocked all backend POSTs — Spring Security's CSRF filter ran
   before permitAll() authorization, returning 401 for signup and login.
   Disabled CSRF since SvelteKit is the only client (never the browser
   directly) and handles its own CSRF via Origin header checks.

2. Login/signup didn't establish Spring Security authentication — they
   stored email in the HTTP session manually but never set the
   SecurityContext, so Principal in /v1/auth/me was always null and
   hooks.server.ts redirected every authenticated request to /login.
   Fixed with authenticateInSession() helper that sets and persists
   the SecurityContext under SPRING_SECURITY_CONTEXT_KEY. Login also
   now invalidates the old session before creating a new one to prevent
   session fixation.

3. redirect() missing throw in hooks.server.ts, signup action, and
   login action — SvelteKit never saw the redirect, so pages silently
   reloaded with no navigation. Also forward JSESSIONID from backend
   response to browser explicitly, since SvelteKit does not
   auto-forward Set-Cookie for cross-origin server-side fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:31:29 +02:00
ab3363eeec refactor(auth): use shared BrandPanel on login page
Login page now uses the same BrandPanel component as signup
instead of an inline brand panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:45:22 +02:00
32 changed files with 1509 additions and 30 deletions

View File

@@ -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();
}

View File

@@ -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()

View File

@@ -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"))

View File

@@ -15,7 +15,7 @@ function isPublicRoute(pathname: string): boolean {
function loginRedirect(pathname: string): never {
const target = '/login?redirect=' + encodeURIComponent(pathname);
redirect(302, target);
throw redirect(302, target);
}
export const handle: Handle = async ({ event, resolve }) => {

View 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>

View 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');
});
});

View 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>

View 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();
});
});

View 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>

View 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();
});
});

View 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>

View 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');
});
});

View 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>

View 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();
});
});

View File

@@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api';
import type { Actions } from './$types';
export const actions = {
default: async ({ request, url, fetch }) => {
default: async ({ request, url, fetch, cookies }) => {
const formData = await request.formData();
const email = (formData.get('email') ?? '').toString().trim();
const password = (formData.get('password') ?? '').toString();
@@ -24,7 +24,7 @@ export const actions = {
}
const api = apiClient(fetch);
const { error } = await api.POST('/v1/auth/login', {
const { error, response } = await api.POST('/v1/auth/login', {
body: { email, password }
});
@@ -35,7 +35,13 @@ export const actions = {
});
}
const redirectTo = url.searchParams.get('redirect') || '/planner';
redirect(303, redirectTo);
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;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import BrandPanel from '$lib/auth/BrandPanel.svelte';
import LoginForm from '$lib/auth/LoginForm.svelte';
let { form } = $props();
@@ -10,13 +11,7 @@
<!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row">
<div
class="bg-[var(--green)] flex flex-col items-center justify-center
px-6 py-7 text-center
md:w-1/2 md:min-h-screen md:px-10 md:py-12"
>
<span class="font-[var(--font-display)] text-4xl text-white font-medium">Mealprep</span>
</div>
<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} />

View File

@@ -31,8 +31,16 @@ describe('login form action', () => {
} 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({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
@@ -52,7 +60,7 @@ describe('login form action', () => {
});
it('redirects to /planner on success by default', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
@@ -67,7 +75,7 @@ describe('login form action', () => {
});
it('redirects to ?redirect param when present', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
@@ -81,6 +89,53 @@ describe('login form action', () => {
}
});
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: '',

View File

@@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api';
import type { Actions } from './$types';
export const actions = {
default: async ({ request, fetch }) => {
default: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const displayName = (formData.get('displayName') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim();
@@ -29,7 +29,7 @@ export const actions = {
}
const api = apiClient(fetch);
const { error } = await api.POST('/v1/auth/signup', {
const { error, response } = await api.POST('/v1/auth/signup', {
body: { displayName, email, password }
});
@@ -41,6 +41,11 @@ export const actions = {
});
}
redirect(303, '/household/setup');
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;

View File

@@ -12,7 +12,7 @@
<!-- 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="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>

View File

@@ -30,8 +30,16 @@ describe('signup form action', () => {
} 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({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
@@ -53,7 +61,7 @@ describe('signup form action', () => {
});
it('redirects to /household/setup on success', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
@@ -68,6 +76,23 @@ describe('signup form action', () => {
}
});
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: '',

View 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>

View 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;

View 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>

View 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();
});
});

View 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();
});
});

View 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') };
};

View 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}

View 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 });
};

View 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([]);
});
});

View 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();
});
});

View 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();
});
});

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';