feat(join): A4 — Join household (accept invite) #61
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
50
frontend/src/lib/components/SegmentedControl.svelte
Normal 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>
|
||||
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
31
frontend/src/lib/components/Toast.svelte
Normal file
31
frontend/src/lib/components/Toast.svelte
Normal 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}
|
||||
23
frontend/src/lib/components/Toast.test.ts
Normal file
23
frontend/src/lib/components/Toast.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
frontend/src/routes/(app)/members/+page.server.ts
Normal file
17
frontend/src/routes/(app)/members/+page.server.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -1 +1,97 @@
|
||||
<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',
|
||||
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 handleRegenerate() {
|
||||
const res = await fetch('/members/invites', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
activeInvite = await res.json();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Mitglieder</h1>
|
||||
|
||||
<MemberGrid
|
||||
{members}
|
||||
currentUserId={data.currentUserId}
|
||||
{isPlanner}
|
||||
showInviteCard={isPlanner}
|
||||
onremove={handleRemove}
|
||||
onrolechange={handleRoleChange}
|
||||
oninviteclick={() => (showInvitePanel = !showInvitePanel)}
|
||||
/>
|
||||
|
||||
{#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)} />
|
||||
</main>
|
||||
|
||||
17
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
17
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
onclick
|
||||
}: {
|
||||
onclick: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="invite-card"
|
||||
{onclick}
|
||||
style="background: var(--color-surface); border-radius: var(--radius-xl); border: 2px dashed var(--color-border); padding: 20px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%;"
|
||||
>
|
||||
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">+</span>
|
||||
<span>Einladen</span>
|
||||
</button>
|
||||
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
50
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<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' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 20px; margin-top: 16px; border: 1px solid var(--color-border);"
|
||||
>
|
||||
<p
|
||||
style="font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 0 12px;"
|
||||
>
|
||||
{invite.shareUrl || invite.inviteCode}
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
|
||||
<button type="button" data-testid="copy-btn" onclick={copy}>
|
||||
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||
</button>
|
||||
|
||||
<button type="button" data-testid="regenerate-btn" onclick={onregenerate}>
|
||||
Neu generieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; font-size: 0.875rem;">
|
||||
Läuft ab: {formatExpiry(invite.expiresAt)}
|
||||
</p>
|
||||
</div>
|
||||
34
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
34
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
215
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
215
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
|
||||
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 roleOptions = [
|
||||
{ value: 'planner', label: 'Planer' },
|
||||
{ value: 'member', label: 'Mitglied' }
|
||||
];
|
||||
|
||||
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
||||
|
||||
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"
|
||||
style="
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
{isCurrentUser ? 'border: 2px solid var(--color-success);' : ''}
|
||||
"
|
||||
class="member-card"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
style="
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success-dark);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
<!-- Name row -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
|
||||
<span style="font-weight: 500;">{member.displayName}</span>
|
||||
{#if isCurrentUser}
|
||||
<span
|
||||
style="
|
||||
background: var(--color-success-tint);
|
||||
color: var(--color-success-dark);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
"
|
||||
>Du</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Role badge or SegmentedControl -->
|
||||
{#if editingRole}
|
||||
<SegmentedControl
|
||||
options={roleOptions}
|
||||
value={member.role}
|
||||
onchange={(newValue) => {
|
||||
onrolechange(member, newValue);
|
||||
editingRole = false;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span style="font-size: 13px; color: var(--color-text-muted);">
|
||||
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Kebab button -->
|
||||
{#if isPlanner && !isCurrentUser}
|
||||
<button
|
||||
data-testid="kebab-btn"
|
||||
type="button"
|
||||
class="kebab-btn"
|
||||
onclick={() => { menuOpen = true; }}
|
||||
aria-label="Optionen"
|
||||
style="
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
"
|
||||
>⋮</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style="
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
"
|
||||
onclick={() => { menuOpen = false; editingRole = true; }}
|
||||
>Rolle ändern</button>
|
||||
<button
|
||||
type="button"
|
||||
style="
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-danger, #e53e3e);
|
||||
"
|
||||
onclick={() => { menuOpen = false; onremove(member); }}
|
||||
>Entfernen</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.kebab-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.kebab-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.member-card:hover .kebab-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
147
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
147
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal 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>
|
||||
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal 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;">
|
||||
<h2 style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
||||
<p>
|
||||
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-remove-btn"
|
||||
onclick={onconfirm}
|
||||
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; 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,0.4); 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: var(--color-surface); border-radius: var(--radius-xl); padding: 24px; max-width: 400px; width: 90%;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="remove-dialog-title" style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
||||
<p>
|
||||
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-remove-btn"
|
||||
onclick={onconfirm}
|
||||
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal 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 });
|
||||
};
|
||||
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal 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, { status: response?.status ?? 201 });
|
||||
};
|
||||
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user