Compare commits

..

46 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
999e54de86 feat(auth): build login page with LoginForm, brand panel, and title
Replaces placeholder with full login page: brand panel left,
LoginForm right, svelte:head title, signup link, no-nav-chrome.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:21:31 +02:00
73acc0c638 feat(auth): add login server action with validation and redirect
POSTs to /v1/auth/login, validates email/password server-side,
redirects to ?redirect param or /planner on success.
Returns generic error on bad credentials to prevent enumeration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:20:02 +02:00
c27c97ff7d feat(auth): add LoginForm component with validation and password toggle
Email/password fields, client-side validation, password show/hide,
server error display via form prop, signup link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:18:49 +02:00
b3607ca47a test(auth): add password length boundary tests (7 fails, 8 passes)
Parameterized test verifying the exact boundary of the 8-character
minimum password requirement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:07:42 +02:00
7de18740f2 test(auth): add multi-error test for empty form submission
Verifies all three validation errors (name, email, password) appear
simultaneously when submitting a completely empty form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:07:07 +02:00
6d0f00c8fb feat(auth): add use:enhance and server error display to signup form
SignupForm now uses use:enhance for progressive enhancement.
Accepts form prop for server-side error display. Shows general
form errors in a banner and field-specific errors inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:06:21 +02:00
bd9e1334e0 feat(auth): add server-side validation to signup form action
Validates displayName, email, password server-side before calling
the backend API. Handles null from formData.get() safely.
Returns structured field errors via fail(400, { errors }).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:02:33 +02:00
82840bb420 fix(auth): center signup form on wide desktop screens
Form container now horizontally centered on md+ viewports,
left-aligned on mobile for full-width usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:01:03 +02:00
845e669cde feat(auth): add page title to signup screen
Sets <title>Konto erstellen — Mealprep</title> via svelte:head
for browser tab and accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:00:02 +02:00
afcea6590d feat(auth): add autocomplete attributes to signup form inputs
name, email, new-password for better browser/password-manager support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:58:59 +02:00
75a13d9df1 fix(auth): style login link green/font-medium per spec
Spec shows green text with font-weight 500, no underline by default.
Was dark text with underline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:57:47 +02:00
b71c98662b fix(auth): use --green-dark on submit button for WCAG AA contrast
--green (#3D8C4A) gives 4.16:1 against white — fails AA.
--green-dark (#2E6E39) passes comfortably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:56:49 +02:00
38 changed files with 2204 additions and 36 deletions

View File

@@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.List;
@RestController @RestController
@RequestMapping("/v1/auth") @RequestMapping("/v1/auth")
@@ -26,7 +32,7 @@ public class AuthController {
@Valid @RequestBody SignupRequest request, @Valid @RequestBody SignupRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
UserResponse user = authService.signup(request); UserResponse user = authService.signup(request);
httpRequest.getSession(true); authenticateInSession(user.email(), "user", httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
} }
@@ -35,17 +41,37 @@ public class AuthController {
@Valid @RequestBody LoginRequest request, @Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
UserResponse user = authService.login(request); UserResponse user = authService.login(request);
HttpSession session = httpRequest.getSession(true); // Session fixation protection: invalidate old session before creating new one
session.setAttribute("user_email", user.email()); 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)); 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") @PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) { public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false); HttpSession session = httpRequest.getSession(false);
if (session != null) { if (session != null) {
session.invalidate(); session.invalidate();
} }
SecurityContextHolder.clearContext();
return ResponseEntity.noContent().build(); 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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -20,9 +18,9 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(csrf -> csrf // CSRF is disabled: SvelteKit is the only client and submits form actions
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // server-side, so the browser never calls the backend directly.
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) .csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").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.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; 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.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.UUID; import java.util.UUID;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 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) @ExtendWith(MockitoExtension.class)
class AuthControllerTest { class AuthControllerTest {
@@ -95,6 +99,40 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.systemRole").value("user")); .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 @Test
void logoutShouldReturn204() throws Exception { void logoutShouldReturn204() throws Exception {
mockMvc.perform(post("/v1/auth/logout")) mockMvc.perform(post("/v1/auth/logout"))

View File

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

View 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 &rarr;
</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>

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

View File

@@ -1,8 +1,19 @@
<script lang="ts"> <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 displayName = $state('');
let email = $state(''); let email = $state('');
let password = $state(''); let password = $state('');
let showPassword = $state(false); let showPassword = $state(false);
let formError = $state('');
let errors = $state({ let errors = $state({
displayName: '', displayName: '',
@@ -10,12 +21,24 @@
password: '' password: ''
}); });
function handleSubmit(event: SubmitEvent) { $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; let hasError = false;
errors.displayName = ''; errors.displayName = '';
errors.email = ''; errors.email = '';
errors.password = ''; errors.password = '';
formError = '';
if (!displayName.trim()) { if (!displayName.trim()) {
errors.displayName = 'Name ist erforderlich'; errors.displayName = 'Name ist erforderlich';
@@ -33,13 +56,17 @@
hasError = true; hasError = true;
} }
if (hasError) { return hasError;
}
function handleSubmit(event: SubmitEvent) {
if (validate()) {
event.preventDefault(); event.preventDefault();
} }
} }
</script> </script>
<form method="POST" novalidate onsubmit={handleSubmit}> <form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
<h1 <h1
class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)] class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)]
md:text-[28px]" md:text-[28px]"
@@ -67,6 +94,7 @@
id="displayName" id="displayName"
name="displayName" name="displayName"
placeholder="z.B. Sarah" placeholder="z.B. Sarah"
autocomplete="name"
bind:value={displayName} 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 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)]'}" {errors.displayName ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
@@ -91,6 +119,7 @@
id="email" id="email"
name="email" name="email"
placeholder="du@beispiel.de" placeholder="du@beispiel.de"
autocomplete="email"
bind:value={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 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)]'}" {errors.email ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
@@ -116,6 +145,7 @@
id="password" id="password"
name="password" name="password"
placeholder="Mindestens 8 Zeichen" placeholder="Mindestens 8 Zeichen"
autocomplete="new-password"
bind:value={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 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)]'}" {errors.password ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
@@ -136,10 +166,16 @@
{/if} {/if}
</div> </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 --> <!-- Submit button -->
<button <button
type="submit" type="submit"
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white" 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 &rarr; Konto erstellen &rarr;
</button> </button>
@@ -149,6 +185,6 @@
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)] class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
md:text-[13px]" md:text-[13px]"
> >
Du hast bereits ein Konto? <a href="/login" class="text-[var(--color-text)] underline">Anmelden</a> Du hast bereits ein Konto? <a href="/login" class="font-medium text-[var(--green)] hover:underline">Anmelden</a>
</p> </p>
</form> </form>

View File

@@ -3,6 +3,10 @@ import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event'; import { userEvent } from '@testing-library/user-event';
import SignupForm from './SignupForm.svelte'; import SignupForm from './SignupForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('SignupForm', () => { describe('SignupForm', () => {
it('renders all form fields with correct labels', () => { it('renders all form fields with correct labels', () => {
render(SignupForm); render(SignupForm);
@@ -16,10 +20,12 @@ describe('SignupForm', () => {
expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument();
}); });
it('renders login link', () => { it('renders login link with correct href and styling', () => {
render(SignupForm); render(SignupForm);
const link = screen.getByRole('link', { name: /anmelden/i }); const link = screen.getByRole('link', { name: /anmelden/i });
expect(link).toHaveAttribute('href', '/login'); expect(link).toHaveAttribute('href', '/login');
expect(link.className).toContain('text-[var(--green)]');
expect(link.className).toContain('font-medium');
}); });
it('renders heading and subtitle', () => { it('renders heading and subtitle', () => {
@@ -116,6 +122,78 @@ describe('SignupForm', () => {
expect(screen.queryByText('Mindestens 8 Zeichen')).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', () => { it('renders placeholders on inputs', () => {
render(SignupForm); render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();

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

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

View File

@@ -1,11 +1,20 @@
<div class="flex min-h-screen"> <script lang="ts">
<div class="hidden md:flex md:w-1/2 bg-[var(--green)] items-center justify-center"> import BrandPanel from '$lib/auth/BrandPanel.svelte';
<span class="font-[var(--font-display)] text-4xl text-white font-medium">Mealprep</span> import LoginForm from '$lib/auth/LoginForm.svelte';
</div>
<div class="flex-1 flex items-center justify-center p-6"> let { form } = $props();
<div> </script>
<h1 class="text-2xl font-medium">Anmelden</h1>
<p class="text-[var(--color-text-muted)] mt-2">Login-Formular folgt.</p> <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> </div>
</div> </div>

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

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

View File

@@ -3,21 +3,49 @@ import { apiClient } from '$lib/server/api';
import type { Actions } from './$types'; import type { Actions } from './$types';
export const actions = { export const actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch, cookies }) => {
const formData = await request.formData(); const formData = await request.formData();
const displayName = formData.get('displayName') as string; const displayName = (formData.get('displayName') ?? '').toString().trim();
const email = formData.get('email') as string; const email = (formData.get('email') ?? '').toString().trim();
const password = formData.get('password') as string; 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 api = apiClient(fetch);
const { data, error } = await api.POST('/v1/auth/signup', { const { error, response } = await api.POST('/v1/auth/signup', {
body: { displayName, email, password } body: { displayName, email, password }
}); });
if (error) { if (error) {
return fail(400, { error: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }); return fail(400, {
errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
displayName,
email
});
} }
redirect(303, '/household/setup'); 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; } satisfies Actions;

View File

@@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import BrandPanel from '$lib/auth/BrandPanel.svelte'; import BrandPanel from '$lib/auth/BrandPanel.svelte';
import SignupForm from '$lib/auth/SignupForm.svelte'; import SignupForm from '$lib/auth/SignupForm.svelte';
let { form } = $props();
</script> </script>
<svelte:head>
<title>Konto erstellen — Mealprep</title>
</svelte:head>
<!-- Mobile: stacked, Desktop: side by side --> <!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row"> <div class="flex min-h-screen flex-col md:flex-row">
<BrandPanel /> <BrandPanel />
<div class="flex flex-1 flex-col justify-center px-[20px] py-[24px] 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="max-w-[380px]"> <div class="w-full max-w-[380px]">
<SignupForm /> <SignupForm {form} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -30,8 +30,16 @@ describe('signup form action', () => {
} as any; } 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 () => { it('calls POST /v1/auth/signup with form data', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); mockPost.mockResolvedValue(mockSuccess());
try { try {
await actions.default(createRequest({ await actions.default(createRequest({
@@ -53,7 +61,7 @@ describe('signup form action', () => {
}); });
it('redirects to /household/setup on success', async () => { it('redirects to /household/setup on success', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); mockPost.mockResolvedValue(mockSuccess());
try { try {
await actions.default(createRequest({ await actions.default(createRequest({
@@ -68,6 +76,73 @@ 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: '',
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 () => { it('returns fail with error message on API error', async () => {
mockPost.mockResolvedValue({ mockPost.mockResolvedValue({
data: undefined, data: undefined,
@@ -81,5 +156,6 @@ describe('signup form action', () => {
})); }));
expect(result.status).toBe(400); expect(result.status).toBe(400);
expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
}); });
}); });

View File

@@ -20,6 +20,11 @@ describe('signup page', () => {
expect(screen.getByText('Mealprep')).toBeInTheDocument(); 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', () => { it('does not render any navigation chrome', () => {
render(Page); render(Page);
// No nav element should exist // No nav element should exist

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