From 74747524a4a7db80368e1596c78a1ab433ed4182 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:29:52 +0200 Subject: [PATCH] test(admin/invites): cover the four invite-status branches and form toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../routes/admin/invites/page.svelte.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 frontend/src/routes/admin/invites/page.svelte.test.ts diff --git a/frontend/src/routes/admin/invites/page.svelte.test.ts b/frontend/src/routes/admin/invites/page.svelte.test.ts new file mode 100644 index 00000000..9e43e7aa --- /dev/null +++ b/frontend/src/routes/admin/invites/page.svelte.test.ts @@ -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 = {}) => ({ + 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[]; + 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(); + }); +});