import { describe, it, expect, vi, 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', code: 'XYZ1234567', displayCode: 'XYZ-1234', label: 'Familie', useCount: 0, maxUses: 5, expiresAt: '2027-01-01T00:00:00Z', revoked: false, status: 'active' as string, createdAt: '2025-01-01T00:00:00Z', shareableUrl: 'http://example.com/i/i-1', ...overrides }); const baseData = ( overrides: Partial<{ invites: ReturnType[]; status: string; loadError: string | null; groups: { id: string; name: string; permissions: string[] }[]; groupsLoadError: string | null; }> = {} ) => ({ invites: [], status: 'active', loadError: null, groups: [], groupsLoadError: 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(); }); it('renders the exhausted status with the correct color class', async () => { render(AdminInvitesPage, { props: { data: baseData({ invites: [makeInvite({ status: 'exhausted' })] }) } }); // gray color for exhausted const pill = Array.from(document.querySelectorAll('.bg-gray-100')); expect(pill.length).toBeGreaterThan(0); }); it('renders the expired status with the correct color class', async () => { render(AdminInvitesPage, { props: { data: baseData({ invites: [makeInvite({ status: 'expired' })] }) } }); // amber color for expired const pill = Array.from(document.querySelectorAll('.bg-amber-50')); expect(pill.length).toBeGreaterThan(0); }); it('renders the revoked status with the correct color class', async () => { render(AdminInvitesPage, { props: { data: baseData({ invites: [makeInvite({ status: 'revoked' })] }) } }); const pill = Array.from(document.querySelectorAll('.bg-red-50')); // May have other red elements (like loadError) — at least one expect(pill.length).toBeGreaterThan(0); }); it('toggles the new-invite form when the button is clicked', async () => { render(AdminInvitesPage, { props: { data: baseData(), form: undefined } }); const formBefore = document.querySelector('form[action="?/create"]'); expect(formBefore).toBeNull(); const newBtn = Array.from(document.querySelectorAll('button')).find((b) => /neue|invite|einladung/i.test(b.textContent ?? '') ) as HTMLButtonElement | undefined; newBtn?.click(); await vi.waitFor(() => { expect(document.querySelector('form[action="?/create"]')).not.toBeNull(); }); }); it('shows the load error banner when data.loadError is set', async () => { render(AdminInvitesPage, { props: { data: baseData({ loadError: 'INTERNAL_ERROR' }), form: undefined } }); const banner = document.querySelector('.bg-red-50'); expect(banner).not.toBeNull(); }); // ─── groups section ─────────────────────────────────────────────────────── it('shows a groups-load warning banner when data.groupsLoadError is set', async () => { render(AdminInvitesPage, { props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } } }); await page .getByRole('button', { name: /neue einladung/i }) .first() .click(); const banner = document.querySelector('.bg-amber-50'); expect(banner).not.toBeNull(); }); it('renders group checkboxes inside the new-invite form when groups are provided', async () => { render(AdminInvitesPage, { props: { data: { ...baseData(), groups: [ { id: 'g-1', name: 'Administratoren', permissions: ['ADMIN'] }, { id: 'g-2', name: 'Familie', permissions: ['READ_ALL'] } ], groupsLoadError: null } } }); await page .getByRole('button', { name: /neue einladung/i }) .first() .click(); await expect.element(page.getByRole('checkbox', { name: 'Administratoren' })).toBeVisible(); await expect.element(page.getByRole('checkbox', { name: 'Familie' })).toBeVisible(); }); it('group checkbox stays checked after being clicked', async () => { render(AdminInvitesPage, { props: { data: { ...baseData(), groups: [{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }], groupsLoadError: null } } }); await page .getByRole('button', { name: /neue einladung/i }) .first() .click(); const checkbox = page.getByRole('checkbox', { name: 'Familie' }); await checkbox.click(); await expect.element(checkbox).toBeChecked(); }); it('amber warning banner has role="alert"', async () => { render(AdminInvitesPage, { props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } } }); await page .getByRole('button', { name: /neue einladung/i }) .first() .click(); const alert = document.querySelector('[role="alert"]'); expect(alert).not.toBeNull(); }); });