test(admin/invites): cover the four invite-status branches and form toggles

Each status (active / exhausted / revoked / expired) maps to a distinct
visual treatment via statusColor() — one focused test per branch
asserts the correct background class on a tbody element so the test
verifies user-observable behaviour rather than the internal switch.

Also covers: empty placeholder, loadError banner, filter chip
selection state, new-invite form toggle on button click, createError
message visibility inside the open form, created-invite success card
with shareable URL, revoke button gating to active invites only,
unlimited-uses display, no-expiry display.

16 tests, ~50 branches covered.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-09 20:29:52 +02:00
parent f812d205c4
commit bfb0ac6246

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AdminInvitesPage from './+page.svelte';
afterEach(cleanup);
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'i-1',
displayCode: 'XYZ-1234',
label: 'Familie',
useCount: 0,
maxUses: 5,
expiresAt: '2027-01-01T00:00:00Z',
status: 'active' as string,
shareableUrl: 'http://example.com/i/i-1',
...overrides
});
const baseData = (
overrides: Partial<{
invites: ReturnType<typeof makeInvite>[];
status: string;
loadError: string | null;
}> = {}
) => ({
invites: [],
status: 'active',
loadError: null,
...overrides
});
describe('admin/invites page', () => {
it('renders the page heading and the new-invite button', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await expect.element(page.getByRole('heading', { name: /einladungen/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /neue einladung/i })).toBeVisible();
});
it('renders the empty placeholder when the invite list is empty', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await expect.element(page.getByText('Keine aktiven Einladungen vorhanden.')).toBeVisible();
});
it('marks the active filter chip as selected when status is "active"', async () => {
render(AdminInvitesPage, { props: { data: baseData({ status: 'active' }) } });
const activeChip = (await page
.getByRole('link', { name: /^aktiv$/i })
.element()) as HTMLAnchorElement;
expect(activeChip.classList.contains('bg-primary')).toBe(true);
});
it('marks the show-all filter chip as selected when status is "all"', async () => {
render(AdminInvitesPage, { props: { data: baseData({ status: 'all' }) } });
const showAllChip = (await page
.getByRole('link', { name: /alle anzeigen/i })
.element()) as HTMLAnchorElement;
expect(showAllChip.classList.contains('bg-primary')).toBe(true);
});
it('renders the load-error banner when data.loadError is set', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ loadError: 'INVITE_LOAD_FAILED' }) }
});
const banner = document.querySelector('.bg-red-50');
expect(banner).not.toBeNull();
});
it('shows the new-invite form when the new-invite button is clicked', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
await expect.element(page.getByLabelText(/bezeichnung|label/i)).toBeVisible();
});
it('shows the createError message inside the form when form.createError is set and the form is open', async () => {
render(AdminInvitesPage, {
props: { data: baseData(), form: { createError: 'INVALID_INVITE' } }
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
const banners = document.querySelectorAll('.text-red-600');
expect(banners.length).toBeGreaterThan(0);
});
it('shows the created-invite success card with the shareable URL when form.created is set', async () => {
render(AdminInvitesPage, {
props: {
data: baseData(),
form: { created: makeInvite({ id: 'new', shareableUrl: 'http://example.com/i/new' }) }
}
});
await expect.element(page.getByText('Einladung erstellt')).toBeVisible();
await expect.element(page.getByText('http://example.com/i/new')).toBeVisible();
});
it('renders one row per invite in the table', async () => {
render(AdminInvitesPage, {
props: {
data: baseData({
invites: [
makeInvite({ id: 'a', displayCode: 'AAA-1111', label: 'Eltern' }),
makeInvite({ id: 'b', displayCode: 'BBB-2222', label: 'Geschwister' })
]
})
}
});
await expect.element(page.getByText('AAA-1111')).toBeVisible();
await expect.element(page.getByText('BBB-2222')).toBeVisible();
});
it('renders "Aktiv" status with the active visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'active' })] }) }
});
const statusBadge = document.querySelector('tbody [aria-label="Aktiv"]') as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-green-50')).toBe(true);
});
it('renders "Widerrufen" status with the revoked visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'revoked' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Widerrufen"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-red-50')).toBe(true);
});
it('renders "Erschöpft" status with the exhausted visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'exhausted' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Erschöpft"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-gray-100')).toBe(true);
});
it('renders "Abgelaufen" status with the expired visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'expired' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Abgelaufen"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-amber-50')).toBe(true);
});
it('renders the revoke button only for active invites', async () => {
render(AdminInvitesPage, {
props: {
data: baseData({
invites: [
makeInvite({ id: 'a', status: 'active' }),
makeInvite({ id: 'b', status: 'revoked' })
]
})
}
});
const revokeButtons = document.querySelectorAll('button[type="submit"]');
// The new-invite form is hidden by default, so all submit buttons are revoke buttons.
expect(revokeButtons.length).toBe(1);
});
it('renders the unlimited symbol when an invite has no maxUses', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ maxUses: null, useCount: 7 })] }) }
});
await expect.element(page.getByText(/7\s*\/\s*∞/)).toBeVisible();
});
it('renders "Kein Ablauf" when an invite has no expiresAt', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ expiresAt: null })] }) }
});
await expect.element(page.getByText('Kein Ablauf')).toBeVisible();
});
});