Compare commits

75 Commits

Author SHA1 Message Date
32bfcd5c16 chore: merge master into feat/issue-21-join-household
Resolved conflicts in HouseholdService, HouseholdController,
V026 migration, and both household test classes in favour of
the more complete invite implementation on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:00:11 +02:00
9a644b5640 fix(test): update AuthControllerTest to verify authenticateInSession delegation
After extracting authenticateInSession to AuthService, the mock doesn't
populate the session. Replace session-attribute assertions with verify()
calls that confirm the controller correctly delegates to authService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:50 +02:00
df0d453b69 fix(join): replace rounded-2xl with --radius-xl design token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:58 +02:00
26256ef492 fix(join): add accessible name to permissions list in HouseholdIdentityPanel
The <ul> had no programmatic association to its visible label.
Screen readers could not announce what the list represents.
Add aria-label="Als Mitglied kannst du" directly on the <ul>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:43 +02:00
ccfc72ab38 fix(join): update password toggle aria-label with state
Static aria-label "Passwort anzeigen" stayed unchanged after the password
became visible, giving screen readers wrong information. Bind label to
showPassword state: "Passwort anzeigen" / "Passwort verbergen".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:16 +02:00
230ee5a067 fix(join): use secure: !dev for JSESSIONID cookie to work in local dev
Hardcoded secure: true silently drops the cookie on HTTP (localhost),
causing the post-join redirect to bounce back to /login. Use $app/environment
dev flag so the cookie works in development while remaining Secure in production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:25:48 +02:00
0b182a33fd refactor(auth): extract authenticateInSession to AuthService
Remove duplicated private authenticateInSession from AuthController and
HouseholdController. Add a single public implementation on AuthService
with session fixation protection built in. HouseholdController now
injects AuthService and passes role "user" for invite-accepted accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:24:58 +02:00
73af11e84b fix(invite): reject invalidated invites in acceptInvite
Same invalidatedAt gap as getInviteInfo: a superseded invite (status
still 'pending', invalidatedAt set) could still be used to create an
account and join the household.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:22:54 +02:00
0ab1ba0b1b fix(invite): reject invalidated invites in getInviteInfo
Superseded invites had invalidatedAt set but status stayed 'pending',
so they passed the validity check and could still be viewed and accepted.
Add invalidatedAt != null guard to getInviteInfo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:22:07 +02:00
44fd398701 fix(invite): saveAndFlush invalidation before INSERT + set invalidated_at on accept
- createInvite: use saveAndFlush when invalidating existing invite so the
  UPDATE is guaranteed to hit the DB before the new INSERT, preventing
  duplicate key violation on uq_household_invite_active
- acceptInvite: also set invalidated_at when marking invite as used, so
  accepted invites are fully removed from the partial unique index and
  cannot block future invite creation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:04:24 +02:00
6aed303627 fix(join): permit /v1/invites/** (not just /*) + match panel color to login
- SecurityConfig: /** covers /v1/invites/{code}/accept (two path segments);
  /* only matched one segment so the accept endpoint was returning 401
- HouseholdIdentityPanel + page: use --green-dark bg (matching BrandPanel
  on login) instead of --green-tint; text updated to white/--green-light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:00:47 +02:00
c5ec3396b2 fix(migration): deduplicate active invites before creating unique index in V026
Dev databases that accumulated multiple pending invites before V026 was
written would fail to create uq_household_invite_active. Added a cleanup
UPDATE that marks all-but-the-latest invite per household as invalidated
before the index is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:51:19 +02:00
6950b3d8db feat(join): implement A4 join household page (/join/[token])
- schema.d.ts: add GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest; update acceptInvite operation
- hooks.server.ts: add /join to PUBLIC_ROUTES; redirect authenticated users on /join/* to /
- +page.server.ts: load validates token (invalid:true on 404); action creates account + joins + sets session cookie
- HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list
- JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, pre-fill
- +page.svelte: no-chrome layout, mobile (banner+form stacked) / desktop (400px panel + flex:1) split, invalid-token inline state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:31:03 +02:00
92f25e56fc feat(invite): add GET /v1/invites/{code} + rework POST accept as signup+join
- V027 migration: add invited_by FK column on household_invite
- HouseholdInvite entity: add invitedBy field, set on createInvite
- New DTOs: InviteInfoResponse, AcceptInviteRequest
- HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction
- HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join
- SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:24:26 +02:00
b577b7a0f8 fix(members): add error toasts for invite failures + Content-Type header
- handleInviteClick: show toast and bail early when POST /invites fails
- handleRegenerate: show toast when regeneration POST fails
- handleRoleChange: add Content-Type: application/json header on PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
69d695b2c4 fix(members): guard against removing the last planner from household
removeMember now checks the planner count before deleting a planner
member. Throws ConflictException("Cannot remove the last planner")
when only one planner remains, matching the spec requirement in S4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
43227b2265 feat(backend): make invite share URL base configurable via app.base-url
Replaces hardcoded \"https://yourapp.com\" with a Spring property.

- application.yml: app.base-url defaults to http://localhost:5173
- application-docker.yml: reads APP_BASE_URL env var, same default
- HouseholdService: injects @Value("${app.base-url}") and uses it in
  toInviteResponse() to build shareUrl
- HouseholdServiceTest: sets field via ReflectionTestUtils in @BeforeEach;
  adds test asserting shareUrl starts with configured base URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
a6683d06bb fix(members/invites): unwrap ApiResponse before returning to client
POST /members/invites was returning the full ApiResponseInviteResponse
wrapper. The client set activeInvite directly from the response body,
so shareUrl/inviteCode/expiresAt were missing (nested under .data).
Fixed to return data?.data — the inner InviteResponse — matching the
shape that InvitePanel and page.server.ts already expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
ed0f3c21fe fix(members): create invite on first click when no active invite exists
When activeInvite is null and the user clicks the invite card, POST to
/members/invites first to generate a code, then toggle the panel open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
dbf2951f09 feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
d6bfd2cb46 fix(members): match settings page padding and h1 typography
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
9ccd367d74 feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)
Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
6aef12fa3c feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
27b7058d31 feat(members): implement DELETE/PATCH member + GET invites backend endpoints
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:34:22 +02:00
60d84c0c94 fix(members): add error toasts for invite failures + Content-Type header
- handleInviteClick: show toast and bail early when POST /invites fails
- handleRegenerate: show toast when regeneration POST fails
- handleRoleChange: add Content-Type: application/json header on PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:31:19 +02:00
9d3be84a0c fix(members): guard against removing the last planner from household
removeMember now checks the planner count before deleting a planner
member. Throws ConflictException("Cannot remove the last planner")
when only one planner remains, matching the spec requirement in S4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:30:55 +02:00
2ad75cc1b7 spec(staples): tile redesign, seed catalog & add-ingredient flow
Adds two spec files for issue #59:
- staples-settings-redesign.html — 5 initial concept variations
- staples-settings-tile.html    — chosen direction: tile layout (matching
  SettingsCard shell), German seed catalog (~100 ingredients / 8 categories),
  and inline per-tile add-ingredient flow with duplicate detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:28:57 +02:00
eb5699577b feat(backend): make invite share URL base configurable via app.base-url
Replaces hardcoded \"https://yourapp.com\" with a Spring property.

- application.yml: app.base-url defaults to http://localhost:5173
- application-docker.yml: reads APP_BASE_URL env var, same default
- HouseholdService: injects @Value("${app.base-url}") and uses it in
  toInviteResponse() to build shareUrl
- HouseholdServiceTest: sets field via ReflectionTestUtils in @BeforeEach;
  adds test asserting shareUrl starts with configured base URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:07:53 +02:00
05476ecaab fix(members/invites): unwrap ApiResponse before returning to client
POST /members/invites was returning the full ApiResponseInviteResponse
wrapper. The client set activeInvite directly from the response body,
so shareUrl/inviteCode/expiresAt were missing (nested under .data).
Fixed to return data?.data — the inner InviteResponse — matching the
shape that InvitePanel and page.server.ts already expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:57:13 +02:00
c40b0fe095 fix(members): create invite on first click when no active invite exists
When activeInvite is null and the user clicks the invite card, POST to
/members/invites first to generate a code, then toggle the panel open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:55:00 +02:00
4e67ff4258 feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:54:26 +02:00
df3b774f0c fix(members): match settings page padding and h1 typography
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:45:32 +02:00
1b5704c8b5 feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)
Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:01:08 +02:00
b04f2c51d2 feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:42:52 +02:00
d1e4b6c49e feat(members): implement DELETE/PATCH member + GET invites backend endpoints
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:41:38 +02:00
27163e3d72 feat(nav): remove Mitglieder link from desktop sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:32:37 +02:00
5904102b1a refactor(settings): document benutzer non-null assertion in page.server.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:23:31 +02:00
d66120b191 refactor(settings): replace hardcoded #C0BFB8 with --color-border-hover token
Add --color-border-hover to the design system neutrals and replace the
hardcoded hex in all three card definitions (settings hub ×2, SettingsCard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:23:22 +02:00
98c8aa9610 refactor(settings): remove dead accent prop from SettingsCard
The green left border was removed from the design — the accent prop,
data-accent attribute, and inline style were never used on the hub page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:22:54 +02:00
af275642b0 refactor(staples): remove redundant {#if !isOnboarding} guards in else block
The outer {:else} already guarantees isOnboarding is false — inner guards
were always-true dead conditions unreachable by tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:22:20 +02:00
dde78baa84 fix(settings): remove green left border from Vorräte card — no active state on hub page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:07:30 +02:00
6e559d9f9d refactor(staples): remove ctx=settings — default view is settings, only ctx=onboarding differs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:57:48 +02:00
ef39a97f57 fix(settings): align hub page with V2 spec — padding, H1, card radius/padding/sizes
- Content area: p-[16px_20px] md:p-[40px_56px], max-w-[820px] grid
- H1: display font, 28px, weight 500, tracking -0.02em
- Cards: --radius-xl, p-[28px], shadow-card
- Card title: 16px (was 14px)
- Stat number: 28px font-light tracking-[-0.02em] (was 36px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:49:56 +02:00
824bb9445f refactor(staples): move household/staples route into (app) group — adds sidebar nav
Onboarding context uses fixed inset overlay to cover AppShell.
Settings context inherits AppShell layout by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:47:25 +02:00
b0fc9f55c1 feat(nav): pass extraPaths to isActiveRoute in DesktopSidebar and MobileTabBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:45:32 +02:00
2ed5186ac8 feat(nav): add extraPaths to NavItem — Einstellungen active on /household/staples
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:44:11 +02:00
48802a04f7 feat(settings): add autosave hint text below StaplesManager on D3 when ctx=settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:30:19 +02:00
0b3d062ed1 feat(settings): add ← Einstellungen back-link on D3 staples page when ctx=settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:28:39 +02:00
109b41b434 feat(settings): implement settings hub page with three cards (Vorräte, Haushalt, Profil)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:27:18 +02:00
3f9fb900c4 feat(settings): add SettingsCard component with title, href, cta, meta, accent props
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:24:38 +02:00
33cccd3d63 feat(settings): add +page.server.ts loading stapleCount, memberCount, userName
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 16:23:15 +02:00
cfbde18435 test(planner): clear shared mocks before each RecipePickerDrawer test
Adds beforeEach(vi.clearAllMocks) to prevent shared vi.fn() state in
baseProps from leaking across tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:18:50 +02:00
4835231f6d test(planner): cover Entfernen hidden when slot.id is null
Adds regression test for the {#if slot.id} guard on the remove button —
QA flagged the missing negative test case for optimistic slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:18:16 +02:00
3f9bd2b226 refactor(planner): replace hardcoded values with design tokens
- border-radius: 10px → var(--radius-lg) in both tile components
- opacity: 0.42 → var(--opacity-dimmed) in DesktopDayTile
- var(--yellow) → var(--color-ring-today) for today ring and date circle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:17:50 +02:00
9423cd673c refactor(planner): type tag mapping callback as TagItem in server load
Replaces (t: any) with (t: TagItem) so the API response shape is
validated against the shared TagItem interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:16:50 +02:00
4c87d9c134 feat(planner): sanitize heroImageUrl before embedding in CSS url()
Extracts sanitizeForCssUrl helper that strips '"()\ before the URL
is embedded in url("..."). Prevents CSS injection via the hero image
field in inline style bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:16:10 +02:00
e5c361fe42 refactor(planner): import shared types in reasoningTags instead of re-declaring
Removes local TagItem, Recipe, SlotRecipe, Slot, SlotMap definitions
and imports Recipe, Slot, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:13:55 +02:00
a8a781f1e9 refactor(planner): import shared types in EmptyDayTile instead of re-declaring
Removes local TagItem, SuggestionRecipe, TopSuggestion, Slot interfaces
and imports Suggestion, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:13:25 +02:00
b0800ca4f3 refactor(planner): import shared types in DesktopDayTile instead of re-declaring
Removes local TagItem, SlotRecipe, Slot, Suggestion interfaces and
imports Recipe, Slot, Suggestion from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:12:51 +02:00
66447a7ea0 refactor(planner): export Slot and SlotMap from types.ts
Adds shared Slot and SlotMap interfaces so DesktopDayTile,
EmptyDayTile, and reasoningTags can import rather than re-declare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:12:03 +02:00
7f4413852d fix(planner): bump front face font sizes again
name: 17→19px, meta: 12→14px, tags: 10→12px,
day-abbr: 11→13px, day-num: 12→14px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:53:29 +02:00
eb3f6fad25 fix(planner): bump front face font sizes
name: 15→17px, meta: 10→12px, tags: 8→10px,
day-abbr: 9→11px, day-num: 10→12px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:52:05 +02:00
fc682bfc54 fix(planner): increase tile front face recipe name to 15px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:50:28 +02:00
38528a50e5 fix(planner): eliminate front-face bleed by removing preserve-3d
transform-style:preserve-3d on a parent with box-shadow/transition
causes Chrome to fail backface-visibility:hidden. Replace with
independent per-face rotateY transforms:
  front: 0deg → -180deg (flipped)
  back:  180deg → 0deg (flipped)
No preserve-3d needed — each face is its own compositing layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:49:28 +02:00
a43a8ec33f fix(planner): prevent front face bleeding through flipped card
overflow:hidden on direct children of preserve-3d flattens the 3D
context in Chrome, causing backface-visibility:hidden to fail.

Move border-radius + overflow to inner wrapper divs (.card-front-inner,
.card-back-inner) and keep the face elements themselves free of those
properties. Also add -webkit-backface-visibility:hidden and
will-change:transform for consistent GPU compositing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:47:10 +02:00
8679ebc6e3 fix(planner): fix flip tile pointer events and selected ring hover
- backface-visibility hides elements visually but not to pointer events;
  disable pointer events on the hidden face explicitly so the X button
  on the back face is clickable and the front face doesn't intercept clicks
- Add .scene-selected:hover rule so green ring is not overwritten by the
  higher-specificity .scene:hover box-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:42:06 +02:00
0ae1767649 feat(planner): align tile design with spec
Front face:
- Full dual gradient overlay (dark top 32% → transparent → dark bottom 55%)
- Day abbreviation + date number pill at top of each tile
- Recipe name 13px/weight-300 with text-shadow
- Meta line (cookTimeMin · effort) below name
- Glassmorphism tag pills (protein + cuisine only)
- State rings via box-shadow (yellow for today, green for selected)
- Dimming (opacity 0.42) on non-selected filled tiles

Back face:
- Koch-Modus as green primary button
- Entfernen as red outline (transparent bg)
- All buttons 11px / weight 500

EmptyDayTile: add day header + spec-aligned suggestion list layout
Page: remove external column header (now rendered inside each tile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:29:58 +02:00
d54ac6a37a feat(planner): use cuisine gradient as fallback when no protein tag
Fallback chain: heroImageUrl → protein gradient → cuisine gradient → surface.
Also rename --gradient-cuisine-italienisch → --gradient-cuisine-deutsch
(actual seed tag) with an earthy warm-grey colour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:12:03 +02:00
d901310897 feat(backend): add heroImageUrl and tags to RecipeSummaryResponse
GET /v1/recipes was returning RecipeSummaryResponse with no tags and
only heroImagePreview. The planner frontend needs protein tags to pick
gradient backgrounds for tiles without a hero image.

- Replace JPQL constructor projection with entity query + LEFT JOIN FETCH tags
- Map Recipe entity to RecipeSummaryResponse in service (includes tags + heroImageUrl)
- Drop heroImagePreview in favour of heroImageUrl on the summary DTO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:03:03 +02:00
ed4cdbf230 fix(planner): merge recipe tags into slotMap from data.recipes
SlotRecipe from the week-plan API carries no tags, so the protein
gradient lookup in DesktopDayTile always fell through to --color-surface.
Build a recipeById lookup from data.recipes and spread tags onto each
slot's recipe when constructing slotMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:55:55 +02:00
75228058a6 fix(planner): align protein gradient CSS vars with actual seed tag names
- Rename --gradient-protein-ei → --gradient-protein-eier (tag is 'Eier')
- Add --gradient-protein-kaese for tag 'Käse' (was missing entirely)

The only protein tags in seed data are Käse, Hülsenfrüchte, Eier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:37:54 +02:00
b919a716f5 fix(planner): rename gradient-protein-veg → gradient-protein-vegetarisch
The CSS variable key must match the actual tag name after umlaut
transliteration. 'veg' would never match a real tag named 'vegetarisch'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:32:05 +02:00
389500c1dd fix(planner): transliterate German umlauts in protein gradient CSS key
'Hähnchen'.toLowerCase() → 'hähnchen' which never matched the CSS var
--gradient-protein-haehnchen. Add toCssKey() to replace ä→ae, ö→oe,
ü→ue, ß→ss so gradient fallbacks actually resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:31:06 +02:00
8709e85d80 fix(planner): increase card front recipe name font size to 15px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:24:20 +02:00
358edb9a12 fix(planner): improve DesktopDayTile visual polish
- Add dark gradient scrim on card front so recipe name is always readable
  over images and protein/cuisine gradients
- Style card-back actions as proper buttons (border, padding, border-radius)
  instead of unstyled browser defaults
- Add meta chips for cookTimeMin and effort
- Scope Entfernen inside isPlanner guard alongside Gericht tauschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:23:05 +02:00
87 changed files with 6816 additions and 326 deletions

View File

@@ -7,6 +7,7 @@ COPY src src
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache libwebp
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080

View File

@@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
@RestController
@RequestMapping("/v1/auth")
@@ -32,7 +27,7 @@ public class AuthController {
@Valid @RequestBody SignupRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.signup(request);
authenticateInSession(user.email(), "user", httpRequest);
authService.authenticateInSession(user.email(), "user", httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
}
@@ -41,30 +36,10 @@ public class AuthController {
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.login(request);
// Session fixation protection: invalidate old session before creating new one
var oldSession = httpRequest.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
return ResponseEntity.ok(ApiResponse.success(user));
}
/**
* Creates an authenticated Spring Security context and stores it in the HTTP session
* so that subsequent requests from the same session are recognised as authenticated.
* We do this manually because we are not using Spring Security's built-in form login.
*/
private void authenticateInSession(String email, String role, HttpServletRequest request) {
var auth = UsernamePasswordAuthenticationToken.authenticated(
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false);

View File

@@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember;
import jakarta.servlet.http.HttpServletRequest;
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.crypto.password.PasswordEncoder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class AuthService {
@@ -82,6 +90,24 @@ public class AuthService {
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
/**
* Establishes an authenticated Spring Security session for the given user.
* Invalidates any existing session first (session fixation protection).
*/
public void authenticateInSession(String email, String role, HttpServletRequest request) {
var oldSession = request.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
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);
}
private UserResponse toUserResponse(UserAccount user) {
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
.map(member -> UserResponse.withHousehold(

View File

@@ -24,11 +24,13 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/v1/invites/**").permitAll()
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(session -> session
.sessionFixation().changeSessionId()
.maximumSessions(1));
return http.build();

View File

@@ -1,7 +1,9 @@
package com.recipeapp.household;
import com.recipeapp.auth.AuthService;
import com.recipeapp.common.ApiResponse;
import com.recipeapp.household.dto.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -9,15 +11,19 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/v1")
public class HouseholdController {
private final HouseholdService householdService;
private final AuthService authService;
public HouseholdController(HouseholdService householdService) {
public HouseholdController(HouseholdService householdService, AuthService authService) {
this.householdService = householdService;
this.authService = authService;
}
@PostMapping("/households")
@@ -40,17 +46,49 @@ public class HouseholdController {
return ResponseEntity.ok(members);
}
@GetMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
return invite
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
InviteResponse response = householdService.createInvite(principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
@DeleteMapping("/households/mine/members/{userId}")
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
householdService.removeMember(principal.getName(), userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/households/mine/members/{userId}")
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
Principal principal,
@PathVariable UUID userId,
@Valid @RequestBody ChangeRoleRequest request) {
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/invites/{code}")
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
InviteInfoResponse response = householdService.getInviteInfo(code);
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/invites/{code}/accept")
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
Principal principal,
@PathVariable String code) {
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
@PathVariable String code,
@Valid @RequestBody AcceptInviteRequest request,
HttpServletRequest httpRequest) {
AcceptInviteResponse response = householdService.acceptInvite(
code, request.name(), request.email(), request.password());
authService.authenticateInSession(request.email(), "user", httpRequest);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
}

View File

@@ -10,4 +10,6 @@ import java.util.UUID;
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
List<HouseholdMember> findByHouseholdId(UUID householdId);
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
long countByHouseholdIdAndRole(UUID householdId, String role);
}

View File

@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.dto.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
@@ -17,12 +18,15 @@ import com.recipeapp.recipe.TagRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class HouseholdService {
@@ -35,6 +39,10 @@ public class HouseholdService {
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.base-url}")
private String baseUrl;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -46,7 +54,8 @@ public class HouseholdService {
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
VarietyScoreConfigRepository varietyScoreConfigRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
@@ -55,6 +64,7 @@ public class HouseholdService {
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
@@ -91,42 +101,121 @@ public class HouseholdService {
.toList();
}
@Transactional
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getRole().equals(newRole)) {
return toMemberResponse(target);
}
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot degrade the last planner");
}
}
target.setRole(newRole);
return toMemberResponse(householdMemberRepository.save(target));
}
@Transactional
public void removeMember(String requesterEmail, UUID targetUserId) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
throw new ConflictException("Planner cannot remove yourself");
}
if ("planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot remove the last planner");
}
}
householdMemberRepository.delete(target);
}
@Transactional(readOnly = true)
public Optional<InviteResponse> getActiveInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdInviteRepository
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
.map(this::toInviteResponse);
}
@Transactional
public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
.ifPresent(existing -> {
existing.setInvalidatedAt(Instant.now());
householdInviteRepository.saveAndFlush(existing);
});
String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt));
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
invite.setInvitedBy(member.getUser());
householdInviteRepository.save(invite);
return new InviteResponse(
invite.getInviteCode(),
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt());
return toInviteResponse(invite);
}
@Transactional(readOnly = true)
public InviteInfoResponse getInviteInfo(String code) {
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if ("used".equals(invite.getStatus())
|| invite.getInvalidatedAt() != null
|| invite.getExpiresAt().isBefore(Instant.now())) {
throw new ResourceNotFoundException("Invite not found or invalid");
}
String inviterName = invite.getInvitedBy() != null
? invite.getInvitedBy().getDisplayName()
: invite.getHousehold().getCreatedBy().getDisplayName();
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
}
@Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
throw new ConflictException("Email already registered");
}
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
if ("used".equals(invite.getStatus())) {
throw new ConflictException("Invite code already used");
}
if (invite.getExpiresAt().isBefore(Instant.now())) {
throw new ValidationException("Invite code has expired");
if ("used".equals(invite.getStatus())
|| invite.getInvalidatedAt() != null
|| invite.getExpiresAt().isBefore(Instant.now())) {
throw new ResourceNotFoundException("Invite not found or invalid");
}
UserAccount user = userAccountRepository.save(
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
invite.setStatus("used");
invite.setInvalidatedAt(Instant.now());
householdInviteRepository.save(invite);
Household household = invite.getHousehold();
@@ -204,4 +293,11 @@ public class HouseholdService {
member.getRole(),
member.getJoinedAt());
}
private InviteResponse toInviteResponse(HouseholdInvite invite) {
return new InviteResponse(
invite.getInviteCode(),
baseUrl + "/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AcceptInviteRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password
) {}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record ChangeRoleRequest(
@NotBlank
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
String role
) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.household.dto;
public record InviteInfoResponse(
String householdName,
String inviterName
) {}

View File

@@ -1,5 +1,6 @@
package com.recipeapp.household.entity;
import com.recipeapp.auth.entity.UserAccount;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@@ -16,6 +17,10 @@ public class HouseholdInvite {
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "invited_by")
private UserAccount invitedBy;
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
private String inviteCode;
@@ -25,6 +30,9 @@ public class HouseholdInvite {
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "invalidated_at")
private Instant invalidatedAt;
protected HouseholdInvite() {}
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
@@ -35,8 +43,12 @@ public class HouseholdInvite {
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public UserAccount getInvitedBy() { return invitedBy; }
public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; }
public String getInviteCode() { return inviteCode; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getExpiresAt() { return expiresAt; }
public Instant getInvalidatedAt() { return invalidatedAt; }
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
}

View File

@@ -1,6 +1,5 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
import com.recipeapp.recipe.entity.Recipe;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -17,9 +16,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
@Query("""
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview)
FROM Recipe r
SELECT r FROM Recipe r
LEFT JOIN FETCH r.tags
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
@@ -27,7 +25,7 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
ORDER BY r.createdAt DESC
""")
List<RecipeSummaryResponse> findFiltered(
List<Recipe> findFiltered(
@Param("householdId") UUID householdId,
@Param("search") String search,
@Param("effort") String effort,

View File

@@ -42,7 +42,15 @@ public class RecipeService {
@Transactional(readOnly = true)
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Integer cookTimeMaxMin, String sort, int limit, int offset) {
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset)
.stream()
.map(r -> new RecipeSummaryResponse(
r.getId(), r.getName(), r.getServes(), r.getCookTimeMin(), r.getEffort(),
r.getHeroImageUrl(),
r.getTags().stream()
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
.toList()))
.toList();
}
@Transactional(readOnly = true)

View File

@@ -1,5 +1,6 @@
package com.recipeapp.recipe.dto;
import java.util.List;
import java.util.UUID;
public record RecipeSummaryResponse(
@@ -8,5 +9,6 @@ public record RecipeSummaryResponse(
short serves,
short cookTimeMin,
String effort,
String heroImagePreview
String heroImageUrl,
List<TagResponse> tags
) {}

View File

@@ -2,3 +2,6 @@ spring:
flyway:
locations: classpath:db/migration,classpath:db/seed
out-of-order: true
app:
base-url: ${APP_BASE_URL:http://localhost:5173}

View File

@@ -30,3 +30,6 @@ spring:
server:
port: 8080
app:
base-url: http://localhost:5173

View File

@@ -0,0 +1,17 @@
ALTER TABLE household_invite
ADD COLUMN invalidated_at timestamptz;
-- Mark all but the most-recent invite per household as invalidated,
-- so the unique partial index below can be created on dev databases
-- that accumulated multiple pending invites before this migration was added.
UPDATE household_invite
SET invalidated_at = NOW()
WHERE id NOT IN (
SELECT DISTINCT ON (household_id) id
FROM household_invite
ORDER BY household_id, expires_at DESC
);
CREATE UNIQUE INDEX uq_household_invite_active
ON household_invite (household_id)
WHERE invalidated_at IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE household_invite
ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL;

View File

@@ -10,19 +10,17 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.UUID;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
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)
@@ -100,7 +98,7 @@ class AuthControllerTest {
}
@Test
void signupShouldStoreSecurityContextInSession() throws Exception {
void signupShouldDelegateSessionCreationToAuthService() throws Exception {
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
@@ -109,14 +107,13 @@ class AuthControllerTest {
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()));
.andExpect(status().isCreated());
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
}
@Test
void loginShouldStoreSecurityContextInSession() throws Exception {
void loginShouldDelegateSessionCreationToAuthService() throws Exception {
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
var response = UserResponse.withHousehold(
UUID.randomUUID(), "sarah@example.com", "Sarah",
@@ -127,10 +124,9 @@ class AuthControllerTest {
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()));
.andExpect(status().isOk());
verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any());
}
@Test

View File

@@ -0,0 +1,51 @@
package com.recipeapp.auth;
import com.recipeapp.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class SecurityConfigTest extends AbstractIntegrationTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@Test
void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception {
// 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401
mockMvc.perform(get("/v1/invites/ANYCODE"))
.andExpect(status().isNotFound());
}
@Test
void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception {
// 400 = validation error (empty body), but NOT 401 — proves the path is permitted
mockMvc.perform(post("/v1/invites/ANYCODE/accept")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
void protectedEndpointRequiresAuthentication() throws Exception {
mockMvc.perform(get("/v1/households/mine"))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,7 +1,10 @@
package com.recipeapp.household;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipeapp.auth.AuthService;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ConflictException;
import com.recipeapp.household.dto.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -15,10 +18,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -32,6 +37,9 @@ class HouseholdControllerTest {
@Mock
private HouseholdService householdService;
@Mock
private AuthService authService;
@InjectMocks
private HouseholdController householdController;
@@ -104,16 +112,119 @@ class HouseholdControllerTest {
}
@Test
void acceptInviteShouldReturn200() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
void getActiveInviteShouldReturn200WithInvite() throws Exception {
var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
Instant.now().plusSeconds(172800));
when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response);
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
}
@Test
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
}
@Test
void deleteMemberShouldReturn204() throws Exception {
var memberId = UUID.randomUUID();
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
verify(householdService).removeMember("sarah@example.com", memberId);
}
@Test
void patchMemberRoleShouldReturn200() throws Exception {
var memberId = UUID.randomUUID();
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
var request = new ChangeRoleRequest("planner");
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
.thenReturn(memberResponse);
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.role").value("planner"));
}
@Test
void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception {
var response = new InviteInfoResponse("Smith family", "Sarah");
when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response);
mockMvc.perform(get("/v1/invites/ABC12XYZ"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
.andExpect(jsonPath("$.data.inviterName").value("Sarah"));
}
@Test
void getInviteInfoShouldReturn404WhenInvalid() throws Exception {
when(householdService.getInviteInfo("BADTOKEN"))
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
mockMvc.perform(get("/v1/invites/BADTOKEN"))
.andExpect(status().isNotFound());
}
@Test
void acceptInviteShouldReturn200AndCreateSession() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.thenReturn(response);
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
.principal(() -> "tom@example.com"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.householdName").value("Smith family"))
.andExpect(jsonPath("$.data.role").value("member"));
}
@Test
void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception {
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.thenThrow(new ConflictException("Email already registered"));
mockMvc.perform(post("/v1/invites/ABC12XYZ/accept")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception {
var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123");
when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123"))
.thenThrow(new ResourceNotFoundException("Invite not found or invalid"));
mockMvc.perform(post("/v1/invites/BADTOKEN/accept")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}
}

View File

@@ -17,11 +17,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
@@ -38,10 +41,16 @@ class HouseholdServiceTest {
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private TagRepository tagRepository;
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
@Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
@InjectMocks
private HouseholdService householdService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
}
private UserAccount testUser() {
return new UserAccount("sarah@example.com", "Sarah", "hashed");
}
@@ -132,85 +141,164 @@ class HouseholdServiceTest {
}
@Test
void acceptInviteShouldAddUserAsMember() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
InviteResponse result = householdService.createInvite("sarah@example.com");
assertThat(result.shareUrl()).startsWith("http://localhost:5173/join/");
assertThat(result.shareUrl()).endsWith(result.inviteCode());
}
// ── getInviteInfo ─────────────────────────────────────────────────────────
@Test
void getInviteInfoShouldReturnHouseholdNameAndInviterName() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ");
InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ");
assertThat(result.householdName()).isEqualTo("Smith family");
assertThat(result.role()).isEqualTo("member");
assertThat(result.inviterName()).isEqualTo("Sarah");
}
@Test
void acceptInviteShouldThrowWhenAlreadyInHousehold() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Other", user);
var member = new HouseholdMember(household, user, "member");
void getInviteInfoShouldThrow404WhenCodeNotFound() {
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.getInviteInfo("INVALID"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenCodeExpired() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
invite.setInvitedBy(owner);
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
invite.setStatus("used");
invite.setInvitedBy(owner);
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("USED123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getInviteInfoShouldThrow404WhenInviteIsInvalidated() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED"))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── acceptInvite (new: creates account + joins) ───────────────────────────
@Test
void acceptInviteShouldCreateAccountAndAddAsMember() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
invite.setInvitedBy(owner);
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member));
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
when(passwordEncoder.encode("secret123")).thenReturn("hashed");
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ"))
AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123");
assertThat(result.householdName()).isEqualTo("Smith family");
assertThat(result.role()).isEqualTo("member");
verify(userAccountRepository).save(any(UserAccount.class));
verify(householdMemberRepository).save(any(HouseholdMember.class));
}
@Test
void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() {
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true);
assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ConflictException.class);
}
@Test
void acceptInviteShouldThrowWhenCodeExpired() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
void acceptInviteShouldThrow404WhenCodeExpired() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600));
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED"))
.isInstanceOf(ValidationException.class);
assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void acceptInviteShouldThrowWhenCodeAlreadyUsed() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
void acceptInviteShouldThrow404WhenCodeAlreadyUsed() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400));
invite.setStatus("used");
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123"))
.isInstanceOf(ConflictException.class);
}
@Test
void acceptInviteShouldThrowWhenInviteNotFound() {
var user = new UserAccount("tom@example.com", "Tom", "hashed");
when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user));
when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty());
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID"))
assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void acceptInviteShouldThrowWhenUserNotFound() {
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
void acceptInviteShouldThrow404WhenInviteNotFound() {
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ"))
assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void acceptInviteShouldThrow404WhenInviteIsInvalidated() {
var owner = testUser();
var household = new Household("Smith family", owner);
var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400));
invite.setInvalidatedAt(Instant.now()); // superseded by a new invite
when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false);
when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite));
assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123"))
.isInstanceOf(ResourceNotFoundException.class);
}
@@ -223,6 +311,187 @@ class HouseholdServiceTest {
.isInstanceOf(ResourceNotFoundException.class);
}
// ── changeMemberRole ──────────────────────────────────────────────────────
@Test
void changeMemberRoleShouldUpdateRole() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
assertThat(result.role()).isEqualTo("planner");
}
@Test
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
assertThat(result.role()).isEqualTo("member");
verify(householdMemberRepository, never()).save(any());
}
@Test
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── removeMember ──────────────────────────────────────────────────────────
@Test
void removeMemberShouldDeleteMember() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
householdService.removeMember("sarah@example.com", targetId);
verify(householdMemberRepository).delete(targetMembership);
}
@Test
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var plannerId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("cannot remove yourself");
}
@Test
void removeMemberShouldThrow409WhenRemovingLastPlanner() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", targetId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── getActiveInvite ───────────────────────────────────────────────────────
@Test
void getActiveInviteShouldReturnActiveInviteResponse() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isPresent();
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
}
@Test
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test
void getActiveInviteShouldReturnEmptyWhenExpired() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test
void getMembersShouldReturnAllMembers() {
var user1 = testUser();
@@ -256,4 +525,23 @@ class HouseholdServiceTest {
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void createInviteShouldInvalidatePreviousActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
householdService.createInvite("sarah@example.com");
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
verify(householdInviteRepository).saveAndFlush(existingInvite);
verify(householdInviteRepository).save(any(HouseholdInvite.class));
}
}

View File

@@ -46,8 +46,9 @@ class RecipeControllerTest {
@Test
void listRecipesShouldReturn200WithPagination() throws Exception {
var tag = new TagResponse(UUID.randomUUID(), "Rind", "protein");
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
(short) 4, (short) 45, "medium", null);
(short) 4, (short) 45, "medium", "https://example.com/img.jpg", List.of(tag));
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
@@ -62,6 +63,9 @@ class RecipeControllerTest {
.param("offset", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese"))
.andExpect(jsonPath("$.data[0].heroImageUrl").value("https://example.com/img.jpg"))
.andExpect(jsonPath("$.data[0].tags[0].name").value("Rind"))
.andExpect(jsonPath("$.data[0].tags[0].tagType").value("protein"))
.andExpect(jsonPath("$.meta.pagination.total").value(1))
.andExpect(jsonPath("$.meta.pagination.hasMore").value(false));
}

View File

@@ -11,6 +11,7 @@
--color-surface: #f5f4ee;
--color-subtle: #edecea;
--color-border: #d8d7d0;
--color-border-hover: #c0bfb8;
--color-text: #1c1c18;
--color-text-muted: #6b6a63;
@@ -97,14 +98,15 @@
--gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
--gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%);
--gradient-protein-veg: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
--gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%);
--gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
--gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%);
--gradient-protein-ei: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
--gradient-protein-eier: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
--gradient-protein-kaese: linear-gradient(135deg, #fcd34d 0%, #d97706 100%);
--gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%);
/* ── Cuisine gradient tokens ────────────────────────────────────── */
--gradient-cuisine-italienisch: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
--gradient-cuisine-deutsch: linear-gradient(135deg, #78716c 0%, #44403c 100%);
--gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%);
--gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%);
--gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);

View File

@@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event);
});
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])(
it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])(
'allows public route %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
@@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => {
}
);
it('redirects authenticated user on /join/[token] to /', async () => {
const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session');
try {
await handle({ event, resolve });
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/');
}
});
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
'allows static asset %s without auth',
async (path) => {

View File

@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite'];
const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join'];
const STATIC_PREFIXES = ['/_app/', '/favicon'];
@@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never {
export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) {
const isJoinRoute = event.url.pathname.startsWith('/join/');
if (isJoinRoute && event.cookies.get('JSESSIONID')) {
throw redirect(302, '/');
}
return resolve(event);
}

View File

@@ -148,6 +148,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/invites/{code}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getInviteInfo"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/invites/{code}/accept": {
parameters: {
query?: never;
@@ -203,7 +219,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
get: operations["getActiveInvite"];
put?: never;
post: operations["createInvite"];
delete?: never;
@@ -212,6 +228,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/households/mine/members/{userId}": {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["removeMember"];
options?: never;
head?: never;
patch: operations["changeMemberRole"];
trace?: never;
};
"/v1/cooking-logs": {
parameters: {
query?: never;
@@ -721,6 +755,20 @@ export interface components {
data?: components["schemas"]["AcceptInviteResponse"];
meta?: components["schemas"]["Meta"];
};
InviteInfoResponse: {
householdName?: string;
inviterName?: string;
};
ApiResponseInviteInfoResponse: {
status?: string;
data?: components["schemas"]["InviteInfoResponse"];
meta?: components["schemas"]["Meta"];
};
AcceptInviteRequest: {
name: string;
email: string;
password: string;
};
Meta: {
pagination?: components["schemas"]["Pagination"];
};
@@ -763,6 +811,14 @@ export interface components {
/** Format: date-time */
joinedAt?: string;
};
ChangeRoleRequest: {
role: string;
};
ApiResponseMemberResponse: {
status?: string;
data?: components["schemas"]["MemberResponse"];
meta?: components["schemas"]["Meta"];
};
ApiResponseInviteResponse: {
status?: string;
data?: components["schemas"]["InviteResponse"];
@@ -1319,7 +1375,7 @@ export interface operations {
};
};
};
acceptInvite: {
getInviteInfo: {
parameters: {
query?: never;
header?: never;
@@ -1329,6 +1385,37 @@ export interface operations {
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseInviteInfoResponse"];
};
};
/** @description Not found */
404: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
};
};
acceptInvite: {
parameters: {
query?: never;
header?: never;
path: {
code: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AcceptInviteRequest"];
};
};
responses: {
/** @description OK */
200: {
@@ -1339,6 +1426,16 @@ export interface operations {
"*/*": components["schemas"]["ApiResponseAcceptInviteResponse"];
};
};
/** @description Email already registered */
409: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
/** @description Invite not found or invalid */
404: {
headers: { [name: string]: unknown };
content: { "*/*": components["schemas"]["ApiError"] };
};
};
};
listCategories: {
@@ -2010,6 +2107,97 @@ export interface operations {
};
};
};
getActiveInvite: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseInviteResponse"];
};
};
/** @description No active invite */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeMember: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
changeMemberRole: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangeRoleRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMemberResponse"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
listAuditLog: {
parameters: {
query?: {

View File

@@ -0,0 +1,50 @@
<script lang="ts">
let {
options,
value,
onchange
}: {
options: { value: string; label: string }[];
value: string;
onchange: (value: string) => void;
} = $props();
</script>
<div role="group" class="segmented-control">
{#each options as option (option.value)}
<button
type="button"
aria-pressed={option.value === value ? 'true' : 'false'}
class="segment"
class:active={option.value === value}
onclick={() => onchange(option.value)}
>
{option.label}
</button>
{/each}
</div>
<style>
.segmented-control {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-md);
padding: 2px;
}
.segment {
border: none;
cursor: pointer;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
border-radius: var(--radius-sm);
color: inherit;
}
.segment.active {
background: var(--color-page);
}
</style>

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SegmentedControl from './SegmentedControl.svelte';
const options = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
describe('SegmentedControl', () => {
it('renders all option labels', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
});
it('marks the active option with aria-pressed', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onchange with the new value when an option is clicked', async () => {
const onchange = vi.fn();
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
expect(onchange).toHaveBeenCalledWith('member');
});
});

View File

@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
title: string;
href: string;
cta: string;
meta?: string;
}
let { title, href, cta, meta }: Props = $props();
</script>
<a
{href}
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium text-[var(--color-text)]">
{title}
</span>
{#if meta}
<p data-testid="card-meta" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
{meta}
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{cta}
</span>
</a>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SettingsCard from './SettingsCard.svelte';
describe('SettingsCard', () => {
it('renders the title', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte')).toBeInTheDocument();
});
it('renders as an anchor tag with the given href', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/household/staples');
});
it('renders the cta text', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
it('renders meta text when provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } });
expect(screen.getByText('3 Mitglieder')).toBeInTheDocument();
});
it('does not render meta element when meta is not provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } });
expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
<script lang="ts">
const { message, visible, ondismiss }: {
message: string;
visible: boolean;
ondismiss?: () => void;
} = $props();
</script>
{#if visible}
<div
role="status"
style="
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-overlay);
border-radius: var(--radius-lg);
color: var(--color-text);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
"
>
<span>{message}</span>
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Toast from './Toast.svelte';
describe('Toast', () => {
it('is not mounted when visible is false', () => {
render(Toast, { props: { message: 'Hallo', visible: false } });
expect(screen.queryByRole('status')).toBeNull();
});
it('shows the message when visible is true', () => {
render(Toast, { props: { message: 'Gespeichert', visible: true } });
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
});
it('calls ondismiss when close button is clicked', async () => {
const ondismiss = vi.fn();
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
expect(ondismiss).toHaveBeenCalledOnce();
});
});

View File

@@ -57,13 +57,14 @@ const requiredTokens = [
'--gradient-protein-rind',
'--gradient-protein-fisch',
'--gradient-protein-tofu',
'--gradient-protein-veg',
'--gradient-protein-vegetarisch',
'--gradient-protein-schwein',
'--gradient-protein-lamm',
'--gradient-protein-ei',
'--gradient-protein-eier',
'--gradient-protein-kaese',
'--gradient-protein-huelsenfruechte',
// Cuisine gradient tokens
'--gradient-cuisine-italienisch',
'--gradient-cuisine-deutsch',
'--gradient-cuisine-asiatisch',
'--gradient-cuisine-indisch',
'--gradient-cuisine-mexikanisch',

View File

@@ -31,7 +31,7 @@ describe('AppShell', () => {
it('renders all navigation links from all nav variants', () => {
render(AppShell, { props: defaultProps });
const links = screen.getAllByRole('link');
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
expect(links).toHaveLength(13);
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
expect(links).toHaveLength(12);
});
});

View File

@@ -24,7 +24,7 @@
{section.title}
</p>
{#each section.items as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}

View File

@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
expect(screen.getByText('Einkauf')).toBeInTheDocument();
});
it('renders Household section with 2 items', () => {
it('renders Household section with Einstellungen', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Haushalt')).toBeInTheDocument();
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument();
});
it('has 5 navigation links total', () => {
it('has 4 navigation links total', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links).toHaveLength(4);
});
it('marks active item with aria-current="page"', () => {
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
expect(widget).toBeInTheDocument();
});
});
describe('DesktopSidebar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: Sidebar } = await import('./DesktopSidebar.svelte');
r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } });
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -8,7 +8,7 @@
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}

View File

@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
expect(recipesLink).not.toHaveAttribute('aria-current');
});
});
describe('MobileTabBar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: TabBar } = await import('./MobileTabBar.svelte');
r(TabBar);
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -34,9 +34,9 @@ describe('nav config', () => {
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
});
it('Household section has Members, Settings', () => {
it('Household section has Settings', () => {
const labels = desktopNavSections[1].items.map((item) => item.label);
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
expect(labels).toEqual(['Einstellungen']);
});
});
@@ -56,5 +56,35 @@ describe('nav config', () => {
it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
});
it('matches when pathname is in extraPaths', () => {
expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true);
});
it('matches sub-route of extraPath', () => {
expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true);
});
it('does not match extraPath with similar prefix', () => {
expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false);
});
it('returns false when extraPaths provided but no match', () => {
expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false);
});
});
describe('NavItem extraPaths', () => {
it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => {
const einstellungen = desktopNavSections
.flatMap((s) => s.items)
.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => {
const einstellungen = mobileNavItems.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
});
});

View File

@@ -2,6 +2,7 @@ export interface NavItem {
href: string;
label: string;
icon: string;
extraPaths?: string[];
}
export interface NavSection {
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
];
export function isActiveRoute(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(href + '/');
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
if (pathname === href || pathname.startsWith(href + '/')) return true;
if (extraPaths) {
return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
return false;
}
export const desktopNavSections: NavSection[] = [
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
{
title: 'Haushalt',
items: [
{ href: '/members', label: 'Mitglieder', icon: '👥' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
]
}
];

View File

@@ -1,32 +1,8 @@
<script lang="ts">
import EmptyDayTile from './EmptyDayTile.svelte';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
}
interface Slot {
id?: string | null;
slotDate?: string;
recipe?: SlotRecipe | null;
}
interface Suggestion {
recipe: any;
scoreDelta: number;
hasConflict: boolean;
}
import { formatDayAbbr } from '$lib/planner/week';
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
let {
slot,
@@ -57,10 +33,23 @@
} = $props();
const slotId = $derived(slot.id ?? '');
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
const dayAbbr = $derived(slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : '');
const dateNum = $derived(slot.slotDate ? slot.slotDate.slice(-2).replace(/^0/, '') : '');
const visibleTags = $derived(
(slot.recipe?.tags ?? []).filter((t) => t.tagType === 'protein' || t.tagType === 'cuisine')
);
const metaLine = $derived((() => {
const parts: string[] = [];
if (slot.recipe?.cookTimeMin) parts.push(`${slot.recipe.cookTimeMin} Min`);
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
return parts.join(' · ');
})());
function handleFlip() {
onflip?.(slotId);
}
@@ -72,12 +61,21 @@
}
}
const umlautMap: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
function toCssKey(name: string): string {
return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c);
}
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`;
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${proteinTag.name.toLowerCase()})`;
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
}
const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine');
if (cuisineTag?.name) {
return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`;
}
return 'var(--color-surface)';
})());
@@ -94,62 +92,92 @@
data-flipped={isFlipped}
data-dimmed={isDimmed}
class="scene"
class:scene-today={isToday && !isFlipped}
class:scene-selected={isFlipped}
class:scene-dimmed={isDimmed}
onclick={handleFlip}
onkeydown={handleKeydown}
>
<div class="card" class:flipped={isFlipped}>
<!-- FRONT -->
<div class="card-front" class:flipped={isFlipped}>
<div
class="card-front"
class="card-front-inner"
style="background: {gradientBackground}; background-size: cover; background-position: center;"
>
<p style="font-family: var(--font-display); font-size: 13px; padding: 8px; margin: 0; color: var(--color-text);">
{slot.recipe.name}
</p>
<!-- Full-tile dual gradient overlay -->
<div class="tile-overlay"></div>
<!-- Day header -->
<div class="tile-head">
<span class="tile-day-abbr">{dayAbbr}</span>
<span class="tile-day-num" class:dn-today={isToday} class:dn-selected={isFlipped}>
{dateNum}
</span>
</div>
<div class="card-back" aria-hidden={!isFlipped}>
<!-- Recipe info at bottom -->
<div class="tile-info">
<p class="tile-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="tile-meta">{metaLine}</p>
{/if}
{#if visibleTags.length > 0}
<div class="tile-tags">
{#each visibleTags as tag (tag.id)}
<span class="tile-tag" class:tag-today={isToday} class:tag-selected={isFlipped}>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</div> <!-- /.card-front-inner -->
</div>
<!-- BACK -->
<div class="card-back" class:flipped={isFlipped} aria-hidden={!isFlipped}>
<div class="card-back-inner">
<button
type="button"
aria-label="Schließen"
class="btn-close"
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
>
×
</button>
{#if slot.recipe.cookTimeMin}
<span style="font-size: 12px;">{slot.recipe.cookTimeMin} min</span>
<p class="back-name">{slot.recipe.name}</p>
{#if metaLine}
<p class="back-meta">{metaLine}</p>
{/if}
{#if slot.recipe.effort}
<span style="font-size: 12px; margin-left: 4px;">{slot.recipe.effort}</span>
{/if}
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;">
<a href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
</div>
<div class="back-actions">
<a class="btn-action btn-primary" href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
<a class="btn-action" href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
{#if isPlanner}
<button
type="button"
class="btn-action"
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
style="margin-top: 8px; display: block;"
>
Gericht tauschen
</button>
{/if}
{#if isPlanner && slot.id}
{#if slot.id}
<button
type="button"
class="btn-action btn-danger"
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
style="margin-top: 4px; display: block;"
>
Entfernen
</button>
{/if}
{/if}
</div>
</div> <!-- /.card-back-inner -->
</div>
</div>
{:else}
<EmptyDayTile
@@ -163,41 +191,222 @@
{/if}
<style>
/* ── Scene (outermost positioned element) ── */
.scene {
perspective: 900px;
height: 100%;
width: 100%;
cursor: pointer;
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(28,28,24,.06), 0 1px 2px rgba(28,28,24,.04);
transition: box-shadow .15s, opacity .15s;
}
.card {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 10px;
.scene:hover {
box-shadow: 0 6px 18px rgba(28,28,24,.14), 0 2px 6px rgba(28,28,24,.08);
}
.card.flipped {
transform: rotateY(180deg);
.scene-today {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 1px 3px rgba(28,28,24,.06);
}
.scene-today:hover {
box-shadow: 0 0 0 2px var(--color-ring-today), 0 6px 18px rgba(28,28,24,.14);
}
.scene-selected {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
/* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */
.scene-selected:hover {
box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14);
}
.scene-dimmed {
opacity: var(--opacity-dimmed);
pointer-events: none;
}
/* ── Card flip — independent face transforms, no preserve-3d ──
preserve-3d + box-shadow/transition on parent causes Chrome to
fail backface-visibility:hidden. Rotating each face independently
avoids the 3D context entirely. */
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 10px;
-webkit-backface-visibility: hidden;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.card-front { transform: rotateY(0deg); }
.card-front.flipped { transform: rotateY(-180deg); }
.card-back { transform: rotateY(180deg); pointer-events: none; }
.card-back.flipped { transform: rotateY(0deg); pointer-events: auto; }
.card-front.flipped { pointer-events: none; }
.card-front-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow: hidden;
}
.card-back {
transform: rotateY(180deg);
/* ── Front face ── */
.tile-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,.32) 0%,
rgba(0,0,0,0) 30%,
rgba(0,0,0,0) 45%,
rgba(0,0,0,.55) 100%
);
border-radius: inherit;
}
.tile-head {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px;
z-index: 2;
}
.tile-day-abbr {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .06em;
color: rgba(255,255,255,.85);
font-weight: 500;
}
.tile-day-num {
width: 20px; height: 20px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: rgba(255,255,255,.9);
background: rgba(255,255,255,.22);
}
.dn-today {
background: var(--color-ring-today) !important;
color: #fff !important;
}
.dn-selected {
background: var(--green-dark) !important;
color: #fff !important;
}
.tile-info {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 8px 9px 9px;
z-index: 2;
}
.tile-name {
font-size: 19px;
font-weight: 300;
color: #fff;
line-height: 1.3;
margin: 0;
text-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.tile-meta {
font-size: 14px;
color: rgba(255,255,255,.75);
margin: 2px 0 0;
}
.tile-tags {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 5px;
}
.tile-tag {
font-size: 12px;
font-weight: 500;
padding: 2px 5px;
border-radius: 2px;
background: rgba(255,255,255,.2);
color: rgba(255,255,255,.92);
backdrop-filter: blur(2px);
}
.tag-today { background: rgba(242,193,46,.35); }
.tag-selected { background: rgba(46,110,57,.45); }
/* ── Back face ── */
.card-back-inner {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow-y: auto;
background: var(--color-page);
border: 1px solid var(--color-border);
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: var(--color-text-muted);
padding: 2px 6px;
align-self: flex-end;
margin: -4px -4px 2px 0;
}
.btn-close:hover { color: var(--color-text); }
.back-name {
font-family: var(--font-display);
font-size: 13px;
font-weight: 300;
margin: 0 0 2px;
line-height: 1.3;
color: var(--color-text);
}
.back-meta {
font-size: 10px;
color: var(--color-text-muted);
margin: 0 0 10px;
}
.back-actions {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: auto;
}
.btn-action {
display: block;
width: 100%;
padding: 7px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
font-size: 11px;
font-weight: 500;
letter-spacing: .04em;
text-align: center;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
}
.btn-action:hover { background: var(--color-surface); }
.btn-primary {
background: var(--green-dark);
color: #fff;
border: none;
}
.btn-primary:hover { background: var(--green-dark); filter: brightness(1.1); }
.btn-danger {
color: #dc4c3e;
border-color: #dc4c3e;
background: transparent;
}
.btn-danger:hover { background: rgba(220,76,62,.08); }
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card-front, .card-back { transition: none; }
.scene { transition: none; }
}
</style>

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DesktopDayTile from './DesktopDayTile.svelte';
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
const filledSlot = {
id: 's1',
@@ -18,11 +19,34 @@ const filledSlot = {
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
describe('sanitizeForCssUrl', () => {
it('strips parentheses that could break out of url() context', () => {
expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')');
});
it('strips single quotes', () => {
expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'");
});
it('strips double quotes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"');
});
it('strips backslashes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\');
});
it('preserves a safe data URI unchanged', () => {
const safe = 'data:image/png;base64,abc123+/==';
expect(sanitizeForCssUrl(safe)).toBe(safe);
});
});
describe('DesktopDayTile — filled slot', () => {
describe('front face', () => {
it('renders recipe name on front face', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
expect(screen.getAllByText('Pasta Bolognese').length).toBeGreaterThanOrEqual(1);
});
it('has data-testid="day-meal-card" on the scene element', () => {
@@ -137,6 +161,15 @@ describe('DesktopDayTile — filled slot', () => {
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
});
it('hides Entfernen button when slot.id is null (optimistic slot)', () => {
const slotWithoutId = { ...filledSlot, id: null };
render(DesktopDayTile, { props: { slot: slotWithoutId, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
// flip the tile first so back face is visible
// activeSlotId must match slot.id to flip — but slot.id is null, so isFlipped = false
// The back face is still rendered in the DOM; check button is absent
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
});
it('calls onremove when Entfernen clicked', async () => {
const onremove = vi.fn();
const user = userEvent.setup();

View File

@@ -0,0 +1,8 @@
/**
* Strips characters that could break out of a CSS url() context or inject
* CSS into an inline style attribute. Safe data URIs (base64) are unaffected
* as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix.
*/
export function sanitizeForCssUrl(url: string): string {
return url.replace(/['"()\\]/g, '');
}

View File

@@ -1,31 +1,7 @@
<script lang="ts">
import { computeReasoningTags } from './reasoningTags';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SuggestionRecipe {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
tags?: TagItem[];
}
interface TopSuggestion {
recipe: SuggestionRecipe;
scoreDelta: number;
hasConflict: boolean;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: any | null;
}
import { formatDayAbbr } from '$lib/planner/week';
import type { Suggestion, SlotMap } from '$lib/planner/types';
let {
slotDate,
@@ -38,52 +14,60 @@
slotDate: string;
slotId: string;
isPlanner: boolean;
slotMap: Record<string, Slot>;
topSuggestion?: TopSuggestion;
slotMap: SlotMap;
topSuggestion?: Suggestion;
onaddrecipe?: () => void;
} = $props();
let reasoningTags = $derived(
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
);
const dayAbbr = $derived(slotDate ? formatDayAbbr(slotDate, 'short') : '');
const dateNum = $derived(slotDate ? slotDate.slice(-2).replace(/^0/, '') : '');
</script>
<div
data-testid="empty-day-tile"
role="group"
class="h-full flex flex-col gap-2 p-3"
style="border: 1px dashed var(--color-border);"
class="h-full flex flex-col overflow-hidden"
style="border: 1.5px dashed var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: 0 1px 3px rgba(28,28,24,.06);"
>
<!-- Day header -->
<div style="display: flex; align-items: center; justify-content: space-between; padding: 7px 8px 0; flex-shrink: 0;">
<span style="font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); font-weight: 500;">{dayAbbr}</span>
<span style="font-size: 10px; font-weight: 500; color: var(--color-text-muted);">{dateNum}</span>
</div>
<!-- CTA -->
{#if isPlanner}
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 6px 6px; gap: 2px; flex-shrink: 0; border-bottom: 1px solid var(--color-border);">
<button
type="button"
aria-label="Gericht wählen"
onclick={() => onaddrecipe?.()}
class="self-start font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
style="background: none; border: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
+ Gericht wählen
<span style="font-size: 18px; color: var(--color-border); line-height: 1;">+</span>
<span style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans);">Gericht wählen</span>
</button>
</div>
{/if}
{#if topSuggestion}
<p class="font-[var(--font-display)] text-[12px] text-[var(--color-text-muted)] leading-snug">
{topSuggestion.recipe.name}
</p>
{#if reasoningTags.length > 0}
<div class="flex flex-wrap gap-1">
<div style="display: flex; flex-direction: column; padding: 5px 7px 6px; flex: 1; overflow: hidden;">
<div style="font-size: 8px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--color-text-muted); padding: 3px 0 5px; border-bottom: 1px solid var(--color-subtle, var(--color-border)); margin-bottom: 2px;">Vorschlag</div>
<div style="display: flex; align-items: center; gap: 4px; padding: 5px 0;">
<span style="font-family: var(--font-display); font-size: 11px; font-weight: 300; color: var(--color-text); flex: 1; line-height: 1.2;">{topSuggestion.recipe.name}</span>
{#each reasoningTags as tag (tag.id)}
<span
data-testid="reasoning-tag"
class="inline-block rounded px-1.5 py-0.5 font-[var(--font-sans)] text-[11px] font-medium"
style={tag.color === 'green'
? 'background: var(--green-tint); color: var(--green-dark);'
: 'background: var(--yellow-tint); color: var(--yellow-text);'}
style="font-size: 8px; font-weight: 500; padding: 1px 4px; border-radius: 2px; white-space: nowrap; flex-shrink: 0; {tag.color === 'green' ? 'background: var(--green-tint); color: var(--green-dark);' : 'background: var(--yellow-tint); color: var(--yellow-text);'}"
>
{tag.label}
</span>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import RecipePickerDrawer from './RecipePickerDrawer.svelte';
@@ -18,6 +18,7 @@ const baseProps = {
};
describe('RecipePickerDrawer', () => {
beforeEach(() => vi.clearAllMocks());
describe('visibility', () => {
it('renders drawer content when open=true', () => {
render(RecipePickerDrawer, { props: baseProps });

View File

@@ -1,36 +1,11 @@
import type { Recipe, Slot, SlotMap } from '$lib/planner/types';
export interface ReasoningTag {
id: 'neues-protein' | 'aufwand-leicht';
label: string;
color: 'green' | 'yellow';
}
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface Recipe {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
tags?: TagItem[];
}
interface SlotRecipe {
id?: string;
tags?: TagItem[];
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: SlotRecipe | null;
}
type SlotMap = Record<string, Slot>;
export function computeReasoningTags(slotMap: SlotMap, recipe: Recipe): ReasoningTag[] {
const tags: ReasoningTag[] = [];

View File

@@ -13,6 +13,14 @@ export interface Recipe {
tags?: TagItem[];
}
export interface Slot {
id?: string | null;
slotDate?: string;
recipe?: Recipe | null;
}
export type SlotMap = Record<string, Slot>;
export interface Suggestion {
recipe: Recipe;
scoreDelta: number;

View File

@@ -14,7 +14,7 @@
</svelte:head>
{#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]">
<div class="fixed inset-0 z-50 flex 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} />
@@ -44,8 +44,10 @@
</main>
</div>
{:else}
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
<div class="p-[16px_20px] md:p-[40px_56px]">
<a href="/settings" class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] mb-4 inline-block">← Einstellungen</a>
<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" />
<p class="mt-4 font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</p>
</div>
{/if}

View File

@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toBeInTheDocument();
});
it('back-link points to /settings', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toHaveAttribute('href', '/settings');
});
it('renders hint text about autosave', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
});
it('renders hint text about next shopping list', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
});
it('does not render back-link in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
});
it('does not render hint text in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [membersRes, inviteRes] = await Promise.all([
api.GET('/v1/households/mine/members'),
api.GET('/v1/households/mine/invites')
]);
return {
members: membersRes.data ?? [],
currentUserId: locals.benutzer!.id,
activeInvite: inviteRes.data?.data ?? null,
householdName: locals.haushalt?.name ?? ''
};
};

View File

@@ -1 +1,114 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
<script lang="ts">
import { untrack } from 'svelte';
import MemberGrid from './MemberGrid.svelte';
import InvitePanel from './InvitePanel.svelte';
import RemoveDialog from './RemoveDialog.svelte';
import Toast from '$lib/components/Toast.svelte';
let { data } = $props();
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
let showInvitePanel = $state(false);
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
let toastMessage = $state('');
let toastVisible = $state(false);
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
const isPlanner = $derived(currentUserRole === 'planner');
function showToast(message: string) {
toastMessage = message;
toastVisible = true;
}
async function handleRoleChange(
member: { userId: string; displayName: string; role: string; joinedAt: string },
newRole: string
) {
const original = members.slice();
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
const res = await fetch('/members/' + member.userId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
});
if (!res.ok) {
members = original;
showToast('Fehler beim Ändern der Rolle');
}
}
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
removeTarget = member;
}
async function handleConfirmRemove() {
if (!removeTarget) return;
const target = removeTarget;
const original = members.slice();
removeTarget = null;
members = members.filter((m) => m.userId !== target.userId);
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
if (!res.ok) {
members = original;
showToast('Fehler beim Entfernen des Mitglieds');
}
}
async function handleInviteClick() {
if (!activeInvite) {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Einladung konnte nicht erstellt werden');
return;
}
}
showInvitePanel = !showInvitePanel;
}
async function handleRegenerate() {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Link konnte nicht erneuert werden');
}
}
</script>
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
<MemberGrid
{members}
currentUserId={data.currentUserId}
{isPlanner}
showInviteCard={isPlanner}
onremove={handleRemove}
onrolechange={handleRoleChange}
oninviteclick={handleInviteClick}
/>
{#if showInvitePanel && isPlanner && activeInvite}
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
{/if}
<RemoveDialog
show={removeTarget !== null}
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
onconfirm={handleConfirmRemove}
oncancel={() => (removeTarget = null)}
/>
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let {
onclick
}: {
onclick: () => void;
} = $props();
</script>
<button
type="button"
data-testid="invite-card"
{onclick}
class="invite-card"
>
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</button>
<style>
.invite-card {
background: white;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
min-height: 180px;
gap: 10px;
width: 100%;
}
.invite-card:hover {
border-color: var(--green-light);
background: var(--green-tint);
}
.invite-plus {
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-subtle);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--color-text-muted);
}
.invite-card:hover .invite-plus {
background: var(--green-light);
color: var(--green-dark);
}
.invite-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.invite-card:hover .invite-label {
color: var(--green-dark);
}
@media (max-width: 768px) {
.invite-card {
padding: 16px;
min-height: 120px;
}
.invite-plus {
width: 36px;
height: 36px;
font-size: 18px;
}
.invite-label {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InviteCard from './InviteCard.svelte';
describe('InviteCard', () => {
it('renders the invite tile', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('shows a descriptive label', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
});
it('calls onclick when tile is clicked', async () => {
const onclick = vi.fn();
render(InviteCard, { props: { onclick } });
await userEvent.click(screen.getByTestId('invite-card'));
expect(onclick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,139 @@
<script lang="ts">
let {
invite,
onregenerate
}: {
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
onregenerate: () => void;
} = $props();
let copied = $state(false);
function copy() {
if (navigator.clipboard) {
navigator.clipboard.writeText(invite.shareUrl);
}
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function formatExpiry(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const isExpiringSoon = $derived(
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
);
</script>
<div class="invite-panel">
<div class="invite-panel-title">Einladelink teilen</div>
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
<div class="invite-link-row">
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
{copied ? 'Kopiert ✓' : 'Kopieren'}
</button>
</div>
<div class="invite-expiry">
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
</div>
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
Neuen Link generieren
</button>
</div>
<style>
.invite-panel {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px;
margin-top: 8px;
}
.invite-panel-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.invite-panel-desc {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.invite-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.invite-link-box {
flex: 1;
background: var(--color-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--color-subtle);
}
.invite-expiry {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.invite-expiry span {
font-weight: 500;
}
.invite-expiry span.yellow {
background: var(--yellow-tint);
color: var(--yellow-text);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.btn-regen {
margin-top: 12px;
font-size: 12px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
display: block;
}
.btn-regen:hover {
color: var(--color-text);
}
</style>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InvitePanel from './InvitePanel.svelte';
const invite = {
inviteCode: 'ABC123XY',
shareUrl: 'https://example.com/join/ABC123XY',
expiresAt: '2026-12-01T00:00:00Z'
};
describe('InvitePanel', () => {
it('shows the invite URL', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
});
it('has a copy button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('has a regenerate button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
});
it('calls onregenerate when regenerate button is clicked', async () => {
const onregenerate = vi.fn();
render(InvitePanel, { props: { invite, onregenerate } });
await userEvent.click(screen.getByTestId('regenerate-btn'));
expect(onregenerate).toHaveBeenCalledOnce();
});
it('shows the panel title', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,386 @@
<script lang="ts">
import { browser } from '$app/environment';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
member,
isCurrentUser,
isPlanner,
onremove,
onrolechange
}: {
member: Member;
isCurrentUser: boolean;
isPlanner: boolean;
onremove: (member: Member) => void;
onrolechange: (member: Member, newRole: string) => void;
} = $props();
let menuOpen = $state(false);
let editingRole = $state(false);
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
const joinDateFormatted = $derived(
new Date(member.joinedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
);
let cardEl: HTMLElement | undefined = $state(undefined);
$effect(() => {
if (!browser || !menuOpen) return;
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
menuOpen = false;
}
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
$effect(() => {
if (!browser || !menuOpen) return;
function onClickAway(e: MouseEvent) {
if (cardEl && !cardEl.contains(e.target as Node)) {
menuOpen = false;
}
}
document.addEventListener('click', onClickAway);
return () => document.removeEventListener('click', onClickAway);
});
</script>
<article
bind:this={cardEl}
data-testid="member-card"
class="member-card"
class:own={isCurrentUser}
class:editing={editingRole}
>
<!-- Avatar -->
<div class="avatar" style="background: {avatarBg};">
{initials}
</div>
<!-- Name -->
<div class="member-name">{member.displayName}</div>
<!-- Role badge or inline role control -->
{#if editingRole}
<div role="group" class="role-control">
<button
type="button"
class="role-control-btn"
class:active={member.role === 'planner'}
onclick={() => {
if (member.role !== 'planner') {
onrolechange(member, 'planner');
editingRole = false;
}
}}
>Planer</button>
<button
type="button"
class="role-control-btn"
class:active={member.role === 'member'}
onclick={() => {
if (member.role !== 'member') {
onrolechange(member, 'member');
editingRole = false;
}
}}
>Mitglied</button>
</div>
{:else}
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
</span>
{/if}
<!-- Join date -->
<div class="join-date">seit {joinDateFormatted}</div>
<!-- Du badge (own card) or Abbrechen (when editing role) -->
{#if isCurrentUser}
<div class="self-badge-wrap">
<span class="self-badge">Du</span>
</div>
{:else if editingRole}
<button
type="button"
class="cancel-btn"
onclick={() => { editingRole = false; }}
>Abbrechen</button>
{/if}
<!-- Kebab button -->
{#if isPlanner && !isCurrentUser && !editingRole}
<button
data-testid="kebab-btn"
type="button"
class="kebab-btn"
onclick={() => { menuOpen = true; }}
aria-label="Optionen"
></button>
<!-- Dropdown -->
{#if menuOpen}
<div class="dropdown">
<button
type="button"
class="dropdown-item"
onclick={() => { menuOpen = false; editingRole = true; }}
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
<div class="dropdown-divider"></div>
<button
type="button"
class="dropdown-item danger"
onclick={() => { menuOpen = false; onremove(member); }}
><span class="dropdown-icon"></span>Entfernen</button>
</div>
{/if}
{/if}
</article>
<style>
.member-card {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.member-card.own {
border-color: var(--green-light);
}
.member-card.editing {
border-color: #B5D4F4;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
margin-bottom: 12px;
flex-shrink: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.role-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
white-space: nowrap;
}
.role-badge.planer {
background: var(--green-tint);
color: var(--green-dark);
}
.role-badge.mitglied {
background: var(--blue-tint);
color: var(--blue-dark);
}
.join-date {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.self-badge-wrap {
margin-top: 8px;
}
.self-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--green-tint);
color: var(--green-dark);
}
.cancel-btn {
margin-top: 8px;
font-size: 11px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.kebab-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-text-muted);
opacity: 0;
}
.kebab-btn:hover {
background: var(--color-subtle);
color: var(--color-text);
}
@media (hover: none) {
.kebab-btn {
opacity: 1;
}
}
.member-card:hover .kebab-btn,
.member-card:focus-within .kebab-btn {
opacity: 1;
}
.dropdown {
position: absolute;
top: 44px;
right: 12px;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
min-width: 160px;
z-index: 10;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
width: 100%;
background: none;
border: none;
text-align: left;
}
.dropdown-item:hover {
background: var(--color-subtle);
}
.dropdown-item.danger {
color: var(--color-error);
}
.dropdown-item.danger:hover {
background: var(--error-tint, #FDECEA);
}
.dropdown-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 2px 0;
}
.role-control {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-top: 8px;
width: 100%;
}
.role-control-btn {
flex: 1;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
background: white;
border: none;
cursor: pointer;
color: var(--color-text-muted);
}
.role-control-btn:first-child {
border-right: 1px solid var(--color-border);
}
.role-control-btn.active {
background: var(--green-dark);
color: white;
}
@media (max-width: 768px) {
.member-card {
padding: 16px;
min-height: auto;
}
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
margin-bottom: 8px;
}
.member-name {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import MemberCard from './MemberCard.svelte';
const plannerMember = {
userId: 'u1',
displayName: 'Sarah',
role: 'planner',
joinedAt: '2024-01-01T00:00:00Z'
};
const regularMember = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('MemberCard', () => {
it('shows the member display name', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
});
it('shows "Du"-badge when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
it('does not show kebab button when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('does not show kebab button when viewer is not a planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('shows kebab button for other members when viewer is planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
});
it('opens dropdown when kebab is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
expect(screen.getByText('Entfernen')).toBeInTheDocument();
});
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
const onremove = vi.fn();
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove,
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Entfernen'));
expect(onremove).toHaveBeenCalledWith(regularMember);
});
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('closes dropdown on Escape key', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Entfernen')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByText('Entfernen')).toBeNull();
});
it('shows formatted join date', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
});
it('shows Abbrechen button when editing role', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import MemberCard from './MemberCard.svelte';
import InviteCard from './InviteCard.svelte';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
members,
currentUserId,
isPlanner,
showInviteCard,
onremove,
onrolechange,
oninviteclick
}: {
members: Member[];
currentUserId: string;
isPlanner: boolean;
showInviteCard: boolean;
onremove: (member: any) => void;
onrolechange: (member: any, newRole: string) => void;
oninviteclick: () => void;
} = $props();
const sortedMembers = $derived(
[...members].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
})
);
</script>
<div class="member-grid">
{#each sortedMembers as m (m.userId)}
<MemberCard
member={m}
isCurrentUser={m.userId === currentUserId}
{isPlanner}
{onremove}
onrolechange={(m, role) => onrolechange(m, role)}
/>
{/each}
{#if isPlanner && showInviteCard}
<InviteCard onclick={oninviteclick} />
{/if}
</div>
<style>
.member-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.member-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MemberGrid from './MemberGrid.svelte';
const members = [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
];
describe('MemberGrid', () => {
it('renders all member cards', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
expect(screen.getByText('Tom')).toBeInTheDocument();
expect(screen.getByText('Anna')).toBeInTheDocument();
});
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('hides invite card when isPlanner is false', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u2',
isPlanner: false,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.queryByTestId('invite-card')).toBeNull();
});
it('shows "Du"-badge on the current user card', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: false,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import BottomSheet from '$lib/components/BottomSheet.svelte';
let {
show,
member,
onconfirm,
oncancel
}: {
show: boolean;
member: { userId: string; displayName: string; role: string; joinedAt: string };
onconfirm: () => void;
oncancel: () => void;
} = $props();
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
</script>
{#if show}
{#if isMobile()}
<BottomSheet open={show} onclose={oncancel}>
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</BottomSheet>
{:else}
<div
data-testid="dialog-backdrop"
role="presentation"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
>
<div
data-testid="remove-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="remove-dialog-title"
tabindex="-1"
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RemoveDialog from './RemoveDialog.svelte';
const member = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('RemoveDialog', () => {
it('is not rendered when show is false', () => {
render(RemoveDialog, {
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.queryByTestId('remove-dialog')).toBeNull();
});
it('shows the member displayName in dialog', () => {
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
expect(screen.getByText(/Tom/)).toBeInTheDocument();
});
it('calls onconfirm when confirm button is clicked', async () => {
const onconfirm = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm, oncancel: vi.fn() }
});
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
expect(onconfirm).toHaveBeenCalledOnce();
});
it('calls oncancel when cancel button is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
it('does NOT call oncancel when backdrop is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
const backdrop = screen.getByTestId('dialog-backdrop');
await userEvent.click(backdrop);
expect(oncancel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const DELETE: RequestHandler = async ({ fetch, params }) => {
const api = apiClient(fetch);
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } }
});
return new Response(null, { status: response?.status ?? 204 });
};
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
const body = await request.json();
const api = apiClient(fetch);
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } },
body
});
return json(data, { status: response?.status ?? 200 });
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockDelete = vi.fn();
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
}));
const USER_UUID = '22222222-2222-2222-2222-222222222222';
describe('members server routes', () => {
let DELETE: any;
let PATCH: any;
beforeEach(async () => {
mockDelete.mockReset();
mockPatch.mockReset();
vi.resetModules();
const mod = await import('./+server');
DELETE = mod.DELETE;
PATCH = mod.PATCH;
});
it('DELETE proxies to backend and returns 204', async () => {
mockDelete.mockResolvedValue({ response: { status: 204 } });
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: vi.fn() }
} as any;
const res = await DELETE(event);
expect(res.status).toBe(204);
});
it('PATCH proxies to backend and returns member response', async () => {
mockPatch.mockResolvedValue({
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
response: { status: 200 }
});
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: async () => ({ role: 'planner' }) }
} as any;
const res = await PATCH(event);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const POST: RequestHandler = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, response } = await api.POST('/v1/households/mine/invites');
return json(data?.data, { status: response?.status ?? 201 });
};

View File

@@ -0,0 +1,33 @@
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('invites server route', () => {
let POST: any;
beforeEach(async () => {
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+server');
POST = mod.POST;
});
it('POST returns unwrapped InviteResponse', async () => {
const invite = { inviteCode: 'ABC123', shareUrl: 'https://x.com/join/ABC123', expiresAt: '2026-12-01T00:00:00Z' };
mockPost.mockResolvedValue({
data: { status: 'success', data: invite },
response: { status: 200 }
});
const event = { fetch: vi.fn() } as any;
const res = await POST(event);
const body = await res.json();
expect(body.inviteCode).toBe('ABC123');
expect(body.shareUrl).toBe('https://x.com/join/ABC123');
expect(body.expiresAt).toBe('2026-12-01T00:00:00Z');
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/api', () => ({
apiClient: vi.fn(() => ({
GET: vi.fn()
}))
}));
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
describe('members page.server load', () => {
let load: any;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('returns members and currentUserId', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') {
return {
data: [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
]
};
}
if (path === '/v1/households/mine/invites') {
return {
data: {
data: {
inviteCode: 'ABC123',
shareUrl: 'https://x.com/join/ABC123',
expiresAt: '2024-12-01T00:00:00Z'
}
}
};
}
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.members).toHaveLength(2);
expect(result.currentUserId).toBe('u1');
expect(result.activeInvite).toBeDefined();
});
it('returns null activeInvite when no active invite exists', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') return { data: [] };
if (path === '/v1/households/mine/invites') return { data: null };
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.activeInvite).toBeNull();
});
});

View File

@@ -2,6 +2,7 @@ import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
import type { TagItem } from '$lib/planner/types';
export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week');
@@ -22,7 +23,7 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl,
tags: (r.tags ?? []).map((t: any) => ({ id: t.id, name: t.name, tagType: t.tagType }))
tags: (r.tags ?? []).map((t: TagItem) => ({ id: t.id, name: t.name, tagType: t.tagType }))
}));
if (weekPlanResult.error || !weekPlanResult.data?.id) {

View File

@@ -25,7 +25,20 @@
let days = $derived(weekDays(weekStart));
let slots = $derived(weekPlan?.slots ?? []);
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
// SlotRecipe from the API has no tags — merge from data.recipes by id
const recipeById = $derived(
Object.fromEntries((data.recipes ?? []).map((r: any) => [r.id, r]))
);
let slotMap = $derived(
Object.fromEntries(
slots.map((s: any) => [
s.slotDate,
s.recipe
? { ...s, recipe: { ...s.recipe, tags: recipeById[s.recipe.id]?.tags ?? [] } }
: s
])
)
);
// Default selected day: today if in this week, else first day
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
@@ -464,26 +477,9 @@
{#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today}
{@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
{@const isThisTileActive = drawerSlotId === day}
<div class="flex h-full flex-col">
<!-- Column header -->
<div class="mb-2 flex flex-col items-center gap-1">
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
{dayAbbr}
</p>
<div
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
{isTodayDay ? 'bg-[var(--yellow)] text-white' : 'bg-transparent text-[var(--color-text)]'}"
>
{dateNum}
</div>
</div>
<!-- Flip tile -->
<div class="min-h-0 flex-1">
<div class="h-full">
<DesktopDayTile
{slot}
isToday={isTodayDay}
@@ -499,7 +495,6 @@
onaddrecipe={() => handleEmptyTileAdd(day)}
/>
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [ingredientsRes, householdRes] = await Promise.all([
api.GET('/v1/ingredients'),
api.GET('/v1/households/mine')
]);
const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0;
const memberCount = householdRes.data?.data?.members?.length ?? 0;
return {
stapleCount,
memberCount,
// hooks.server.ts guarantees benutzer is set for all (app) routes
userName: locals.benutzer!.name
};
};

View File

@@ -1 +1,72 @@
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte';
interface Props {
data: {
stapleCount: number;
memberCount: number;
userName: string;
};
}
let { data }: Props = $props();
</script>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Einstellungen</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]">
<!-- Card 1: Vorräte (inline, conditional content) -->
<a
href="/household/staples"
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium">Vorräte</span>
{#if data.stapleCount > 0}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span
data-testid="staple-count"
class="font-[var(--font-display)] text-[28px] font-light leading-[1] tracking-[-0.02em] text-[var(--green-dark)]"
>{data.stapleCount}</span>
</p>
{:else}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
Noch keine Vorräte eingerichtet
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{#if data.stapleCount > 0}
Vorräte bearbeiten →
{:else}
Jetzt einrichten →
{/if}
</span>
</a>
<!-- Card 2: Haushalt (inline, needs data-testid on member count) -->
<a
href="/members"
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium">Haushalt</span>
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span data-testid="member-count">{data.memberCount}</span> Mitglieder
</p>
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
Mitglieder anzeigen →
</span>
</a>
<!-- Card 3: Profil (uses SettingsCard) -->
<SettingsCard
title="Profil"
href="/profile"
cta="Profil bearbeiten →"
meta={data.userName}
/>
</div>
</div>

View File

@@ -0,0 +1,105 @@
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 mockIngredients = [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false },
{ id: 'ing-3', name: 'Salz', isStaple: true }
];
const mockHousehold = {
status: 'OK',
data: {
id: 'hh-1',
name: 'Familie Raddatz',
members: [
{ userId: 'u-1', name: 'Marcel' },
{ userId: 'u-2', name: 'Anna' },
{ userId: 'u-3', name: 'Ben' }
]
}
};
const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } };
describe('settings 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/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
}
it('returns stapleCount as number of ingredients where isStaple=true', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(2);
});
it('returns memberCount as number of household members', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(3);
});
it('returns userName from locals.benutzer.name', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.userName).toBe('Marcel Raddatz');
});
it('fetches ingredients and household in parallel', async () => {
mockApiResponses();
await load({ fetch: vi.fn(), locals: mockLocals } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredients');
expect(calls).toContain('/v1/households/mine');
});
it('defaults stapleCount to 0 when ingredients API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(0);
});
it('defaults memberCount to 0 when household API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(0);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) {
return {
stapleCount: 14,
memberCount: 3,
userName: 'Marcel Raddatz',
...overrides
};
}
describe('settings page — hub', () => {
it('renders the page heading Einstellungen', () => {
render(Page, { props: { data: makeData() } });
expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument();
});
it('renders Vorräte card linking to /household/staples', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples');
expect(vorrateLink).toBeInTheDocument();
});
it('renders Haushalt card linking to /members', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const haushaltLink = links.find((l) => l.getAttribute('href') === '/members');
expect(haushaltLink).toBeInTheDocument();
});
it('renders Profil card linking to /profile', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const profilLink = links.find((l) => l.getAttribute('href') === '/profile');
expect(profilLink).toBeInTheDocument();
});
it('shows stapleCount as a number in the Vorräte card', () => {
render(Page, { props: { data: makeData({ stapleCount: 14 }) } });
expect(screen.getByTestId('staple-count')).toHaveTextContent('14');
});
it('shows memberCount in the Haushalt card', () => {
render(Page, { props: { data: makeData({ memberCount: 3 }) } });
expect(screen.getByTestId('member-count')).toHaveTextContent('3');
});
it('shows userName in the Profil card meta', () => {
render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } });
expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument();
});
it('shows empty state text when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument();
expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument();
});
it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument();
});
it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 5 }) } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { apiClient } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/invites/{code}', {
params: { path: { code: params.token } }
});
if (error || !data?.data) {
return { invalid: true };
}
return {
invalid: false,
householdName: data.data.householdName ?? '',
inviterName: data.data.inviterName ?? ''
};
};
export const actions = {
default: async ({ params, request, fetch, cookies }) => {
const formData = await request.formData();
const name = (formData.get('name') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim();
const password = (formData.get('password') ?? '').toString();
const errors: Record<string, string> = {};
if (!name) {
errors.name = '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, name, email });
}
const api = apiClient(fetch);
const { error, response } = await api.POST('/v1/invites/{code}/accept', {
params: { path: { code: params.token } },
body: { name, email, password }
});
if (error) {
if (error.status === 409) {
return fail(409, {
errors: {
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
},
name,
email
});
}
return fail(400, {
errors: { form: 'Einladung ungültig oder abgelaufen.' },
name,
email
});
}
const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev
});
}
redirect(303, '/');
}
} satisfies Actions;

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
import JoinForm from './JoinForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Haushalt beitreten — Mealplan</title>
</svelte:head>
{#if data.invalid}
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-6">
<div class="text-center">
<h1 class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
Einladung ungültig oder abgelaufen
</h1>
<p class="mt-3 font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Bitte bitte den Einladenden, einen neuen Link zu senden.
</p>
</div>
</div>
{:else}
<!-- Mobile layout (< 1024px): stacked banner + form -->
<!-- Desktop layout (≥ 1024px): two-column side by side -->
<div class="flex min-h-screen flex-col lg:flex-row">
<!-- Left / top: green-tint panel -->
<div class="bg-[var(--green-dark)] p-6 lg:flex lg:w-[400px] lg:flex-shrink-0 lg:items-center lg:justify-center lg:p-12">
<HouseholdIdentityPanel
householdName={data.householdName ?? ''}
inviterName={data.inviterName ?? ''}
/>
</div>
<!-- Right / bottom: form -->
<div class="flex flex-1 items-center justify-center bg-[var(--color-page)] p-6 lg:p-12">
<div class="w-full max-w-sm">
<h1 class="mb-6 font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-[var(--color-text)]">
Konto erstellen & beitreten
</h1>
<JoinForm {form} />
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let { householdName, inviterName }: { householdName: string; inviterName: string } = $props();
</script>
<div class="flex flex-col items-center gap-4 rounded-[var(--radius-xl)] bg-[var(--green-dark)] p-6 text-center">
<!-- App logo -->
<span class="text-[48px]" aria-hidden="true">🥗</span>
<!-- Household name -->
<div>
<h2
class="font-[var(--font-display)] text-[22px] font-semibold tracking-[-0.02em] text-white"
>
{householdName}
</h2>
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--green-light)]">
Eingeladen von {inviterName}
</p>
</div>
<!-- Permissions info box -->
<div class="w-full rounded-xl bg-white/10 px-4 py-3 text-left">
<p class="mb-2 font-[var(--font-sans)] text-[11px] font-medium uppercase tracking-wide text-[var(--green-light)]">
Als Mitglied kannst du
</p>
<ul aria-label="Als Mitglied kannst du" class="flex flex-col gap-1.5">
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Wochenplan einsehen
</li>
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Einkaufsliste abhaken
</li>
<li class="flex items-center gap-2 font-[var(--font-sans)] text-[13px] text-white">
<span class="font-semibold text-[var(--green-light)]" aria-hidden="true"></span>
Artikel zur Liste hinzufügen
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte';
describe('HouseholdIdentityPanel', () => {
it('renders household name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText('Smith family')).toBeInTheDocument();
});
it('renders inviter name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText(/Sarah/)).toBeInTheDocument();
});
it('renders all three member permissions', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument();
expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument();
});
it('renders app logo', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByText('🥗')).toBeInTheDocument();
});
it('permissions list has accessible name', () => {
render(HouseholdIdentityPanel, {
props: { householdName: 'Smith family', inviterName: 'Sarah' }
});
expect(screen.getByRole('list', { name: /als mitglied kannst du/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { enhance } from '$app/forms';
type FormData = {
errors?: Record<string, string>;
name?: string;
email?: string;
} | null;
let { form = null }: { form?: FormData } = $props();
let showPassword = $state(false);
</script>
<form method="POST" use:enhance>
<!-- Form-level error -->
{#if form?.errors?.form}
<p
class="mb-4 rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
>
{form.errors.form}
</p>
{/if}
<!-- Name field -->
<div class="mb-4">
<label
for="name"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
Name
</label>
<input
type="text"
id="name"
name="name"
autocomplete="given-name"
value={form?.name ?? ''}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.name ? 'border-[var(--color-error)]' : ''}"
/>
{#if form?.errors?.name}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.name}
</p>
{/if}
</div>
<!-- Email field -->
<div class="mb-4">
<label
for="email"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
E-Mail
</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
value={form?.email ?? ''}
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.email ? 'border-[var(--color-error)]' : ''}"
/>
{#if form?.errors?.email}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.email}
</p>
{/if}
</div>
<!-- Password field -->
<div class="mb-6">
<label
for="password"
class="mb-[6px] block font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]"
>
Passwort
</label>
<div class="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autocomplete="new-password"
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] pr-[80px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{form?.errors?.password ? 'border-[var(--color-error)]' : ''}"
/>
<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 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
{#if form?.errors?.password}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{form.errors.password}
</p>
{/if}
</div>
<!-- Submit button -->
<button
type="submit"
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] font-[var(--font-sans)] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
>
Haushalt beitreten
</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 JoinForm from './JoinForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('JoinForm', () => {
it('renders name, email and password fields', () => {
render(JoinForm);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
});
it('renders "Haushalt beitreten" submit button', () => {
render(JoinForm);
expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument();
});
it('password field is initially of type password', () => {
render(JoinForm);
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password');
});
it('password toggle switches type to text', async () => {
const user = userEvent.setup();
render(JoinForm);
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text');
});
it('password toggle aria-label updates to "Passwort verbergen" when visible', async () => {
const user = userEvent.setup();
render(JoinForm);
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(screen.getByRole('button', { name: /passwort verbergen/i })).toBeInTheDocument();
});
it('shows form-level error from form prop', () => {
render(JoinForm, {
props: {
form: {
errors: { form: 'Einladung ungültig oder abgelaufen.' }
}
}
});
expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument();
});
it('shows email-taken error with login link', () => {
render(JoinForm, {
props: {
form: {
errors: {
email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →'
}
}
}
});
expect(screen.getByText(/bereits registriert/)).toBeInTheDocument();
});
it('pre-fills name and email from form prop', () => {
render(JoinForm, {
props: {
form: {
errors: {},
name: 'Tom',
email: 'tom@example.com'
}
}
});
expect(screen.getByLabelText('Name')).toHaveValue('Tom');
expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com');
});
});

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
vi.mock('$app/environment', () => ({ dev: false }));
const mockGet = vi.fn();
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet, POST: mockPost })
}));
describe('join page load function', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function createLoadEvent(token: string) {
return {
params: { token },
fetch: vi.fn()
} as any;
}
it('returns householdName and inviterName for valid token', async () => {
mockGet.mockResolvedValue({
data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } },
error: undefined
});
const result = await load(createLoadEvent('ABC12XYZ'));
expect(result.invalid).toBeFalsy();
expect(result.householdName).toBe('Smith family');
expect(result.inviterName).toBe('Sarah');
});
it('returns invalid:true on 404 (expired/used/unknown token)', async () => {
mockGet.mockResolvedValue({
data: undefined,
error: { status: 404 }
});
const result = await load(createLoadEvent('BADTOKEN'));
expect(result.invalid).toBe(true);
});
});
describe('join page form action', () => {
let actions: any;
beforeEach(async () => {
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
actions = mod.actions;
});
function createRequest(token: string, formData: Record<string, string>) {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
fd.append(key, value);
}
return {
params: { token },
request: { formData: () => Promise.resolve(fd) },
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
}
it('calls POST /v1/invites/{token}/accept with form data', async () => {
mockPost.mockResolvedValue({
data: { data: { householdName: 'Smith family', role: 'member' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
try {
await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', {
params: { path: { code: 'ABC12XYZ' } },
body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' }
});
});
it('sets JSESSIONID cookie and redirects to / on success', async () => {
mockPost.mockResolvedValue({
data: { data: { householdName: 'Smith family', role: 'member' } },
error: undefined,
response: {
headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') }
}
});
const event = createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
});
try {
await actions.default(event);
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/');
}
expect(event.cookies.set).toHaveBeenCalledWith(
'JSESSIONID',
'abc123',
expect.objectContaining({ path: '/', secure: true })
);
});
it('returns 409 fail with email-taken message on conflict', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 409 },
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(409);
expect(result.data.errors.email).toContain('registriert');
});
it('returns 400 fail on invalid token (404 from backend)', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 404 },
response: { headers: { get: vi.fn().mockReturnValue(null) } }
});
const result = await actions.default(createRequest('BADTOKEN', {
name: 'Tom',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.form).toBeTruthy();
});
it('rejects empty name with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: '',
email: 'tom@example.com',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.name).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects invalid email with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'notanemail',
password: 'secret123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.email).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects short password with validation error', async () => {
const result = await actions.default(createRequest('ABC12XYZ', {
name: 'Tom',
email: 'tom@example.com',
password: 'short'
}));
expect(result.status).toBe(400);
expect(result.data.errors.password).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff