diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql similarity index 100% rename from backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql rename to backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte new file mode 100644 index 0000000..a83104b --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -0,0 +1,50 @@ + + +
+ {#each options as option (option.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/SegmentedControl.test.ts b/frontend/src/lib/components/SegmentedControl.test.ts new file mode 100644 index 0000000..708e21c --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..47fb71a --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,31 @@ + + +{#if visible} +
+ {message} + +
+{/if} diff --git a/frontend/src/lib/components/Toast.test.ts b/frontend/src/lib/components/Toast.test.ts new file mode 100644 index 0000000..114b29b --- /dev/null +++ b/frontend/src/lib/components/Toast.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts new file mode 100644 index 0000000..6e63458 --- /dev/null +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -0,0 +1,17 @@ +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 + }; +}; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index a4722af..5e54c8f 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -1 +1,97 @@ -

Mitglieder

+ + +Mitglieder — Mealprep + +
+

Mitglieder

+ + (showInvitePanel = !showInvitePanel)} + /> + + {#if showInvitePanel && isPlanner && activeInvite} + + {/if} + + (removeTarget = null)} + /> + + (toastVisible = false)} /> +
diff --git a/frontend/src/routes/(app)/members/InviteCard.svelte b/frontend/src/routes/(app)/members/InviteCard.svelte new file mode 100644 index 0000000..d5d0e55 --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/routes/(app)/members/InviteCard.test.ts b/frontend/src/routes/(app)/members/InviteCard.test.ts new file mode 100644 index 0000000..5974e5d --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte new file mode 100644 index 0000000..0eb9075 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -0,0 +1,50 @@ + + +
+

+ {invite.shareUrl || invite.inviteCode} +

+ +
+ + + +
+ +

+ Läuft ab: {formatExpiry(invite.expiresAt)} +

+
diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts new file mode 100644 index 0000000..77ba4f3 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -0,0 +1,34 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte new file mode 100644 index 0000000..cabcd40 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -0,0 +1,215 @@ + + +
+ +
+ {initials} +
+ + +
+ {member.displayName} + {#if isCurrentUser} + Du + {/if} +
+ + + {#if editingRole} + { + onrolechange(member, newValue); + editingRole = false; + }} + /> + {:else} + + {member.role === 'planner' ? 'Planer' : 'Mitglied'} + + {/if} + + + {#if isPlanner && !isCurrentUser} + + + + {#if menuOpen} +
+ + +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberCard.test.ts b/frontend/src/routes/(app)/members/MemberCard.test.ts new file mode 100644 index 0000000..a63bf41 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.test.ts @@ -0,0 +1,147 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberGrid.svelte b/frontend/src/routes/(app)/members/MemberGrid.svelte new file mode 100644 index 0000000..9e994ca --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.svelte @@ -0,0 +1,67 @@ + + +
+ {#each sortedMembers as m (m.userId)} + onrolechange(m, role)} + /> + {/each} + {#if isPlanner && showInviteCard} + + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberGrid.test.ts b/frontend/src/routes/(app)/members/MemberGrid.test.ts new file mode 100644 index 0000000..1745123 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/RemoveDialog.svelte b/frontend/src/routes/(app)/members/RemoveDialog.svelte new file mode 100644 index 0000000..996db2f --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.svelte @@ -0,0 +1,86 @@ + + +{#if show} + {#if isMobile()} + +
+

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {:else} +
+
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {/if} +{/if} diff --git a/frontend/src/routes/(app)/members/RemoveDialog.test.ts b/frontend/src/routes/(app)/members/RemoveDialog.test.ts new file mode 100644 index 0000000..9da8f9b --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/[userId]/+server.ts b/frontend/src/routes/(app)/members/[userId]/+server.ts new file mode 100644 index 0000000..d53d711 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/+server.ts @@ -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 }); +}; diff --git a/frontend/src/routes/(app)/members/[userId]/server.test.ts b/frontend/src/routes/(app)/members/[userId]/server.test.ts new file mode 100644 index 0000000..6bc2b06 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/server.test.ts @@ -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); + }); +}); diff --git a/frontend/src/routes/(app)/members/invites/+server.ts b/frontend/src/routes/(app)/members/invites/+server.ts new file mode 100644 index 0000000..76d1f44 --- /dev/null +++ b/frontend/src/routes/(app)/members/invites/+server.ts @@ -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, { status: response?.status ?? 201 }); +}; diff --git a/frontend/src/routes/(app)/members/page.server.test.ts b/frontend/src/routes/(app)/members/page.server.test.ts new file mode 100644 index 0000000..55f2bc5 --- /dev/null +++ b/frontend/src/routes/(app)/members/page.server.test.ts @@ -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).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).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(); + }); +});