From e4303baa401b339ccd59d67afc82e7980c6c2b2e Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 16:06:08 +0200 Subject: [PATCH] test(invites): import real +page.server module via vi.mock env Replace hand-copied load/action replicas with direct imports of the real module. Mock $env/dynamic/private so the tests cover the actual production code paths, not a duplicate that can drift. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin/invites/page.server.test.ts | 229 ++++++------------ 1 file changed, 71 insertions(+), 158 deletions(-) diff --git a/frontend/src/routes/admin/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts index 1ba43a93..060389e7 100644 --- a/frontend/src/routes/admin/invites/page.server.test.ts +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -1,159 +1,71 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Test the load and create action logic in isolation without importing the actual module -// (which depends on SvelteKit env and $types at runtime). -// We replicate the exact logic here to test the branching behaviour. +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://localhost:8080' } +})); -const API_URL = 'http://localhost:8080'; +import { load, actions } from './+page.server'; -interface InviteListItem { - id: string; - code: string; - displayCode: string; - label?: string; - useCount: number; - maxUses?: number; - expiresAt?: string; - revoked: boolean; - status: string; - createdAt: string; - shareableUrl: string; -} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFetch = (...args: any[]) => any; -interface UserGroup { - id: string; - name: string; - permissions: string[]; -} - -interface MockResponse { - ok: boolean; - status?: number; - json: () => Promise; -} - -async function loadFn( - status: string, - fetchImpl: (url: string) => Promise -): Promise<{ - invites: InviteListItem[]; - status: string; - loadError: string | null; - groups: UserGroup[]; - groupsLoadError: string | null; -}> { - const [invitesRes, groupsRes] = await Promise.all([ - fetchImpl(`${API_URL}/api/invites?status=${encodeURIComponent(status)}`), - fetchImpl(`${API_URL}/api/groups`) - ]); - - let invites: InviteListItem[] = []; - let loadError: string | null = null; - if (!invitesRes.ok) { - const body = (await invitesRes.json()) as { code?: string } | null; - loadError = body?.code ?? 'INTERNAL_ERROR'; - } else { - invites = (await invitesRes.json()) as InviteListItem[]; - } - - let groups: UserGroup[] = []; - let groupsLoadError: string | null = null; - if (!groupsRes.ok) { - const body = (await groupsRes.json()) as { code?: string } | null; - groupsLoadError = body?.code ?? 'INTERNAL_ERROR'; - } else { - const raw = (await groupsRes.json()) as UserGroup[]; - groups = [...raw].sort((a, b) => a.name.localeCompare(b.name)); - } - - return { invites, status, loadError, groups, groupsLoadError }; -} - -async function createActionFn( - formData: FormData, - fetchImpl: (url: string, init: RequestInit) => Promise -): Promise<{ ok: boolean; body: unknown }> { - const label = (formData.get('label') as string) || undefined; - const maxUsesRaw = formData.get('maxUses') as string; - const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined; - const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined; - const prefillLastName = (formData.get('prefillLastName') as string) || undefined; - const prefillEmail = (formData.get('prefillEmail') as string) || undefined; - const expiresAt = (formData.get('expiresAt') as string) || undefined; - const groupIds = formData.getAll('groupIds') as string[]; - - const res = await fetchImpl(`${API_URL}/api/invites`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - label, - maxUses, - prefillFirstName, - prefillLastName, - prefillEmail, - expiresAt, - groupIds - }) - }); - - const body = await res.json(); - return { ok: res.ok, body }; +function mockResponse(ok: boolean, body: unknown, status = 200) { + return { + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Headers({ 'content-type': 'application/json' }) + } as unknown as Response; } describe('admin/invites load()', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockFetch = vi.fn(); + const mockFetch = vi.fn(); beforeEach(() => mockFetch.mockReset()); + function event(status = 'active') { + return { + url: new URL(`http://localhost/admin/invites?status=${status}`), + fetch: mockFetch as unknown as typeof fetch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + it('returns groups array alongside invites when both succeed', async () => { - const invites: InviteListItem[] = []; - const groups: UserGroup[] = [ - { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, - { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } - ]; - - mockFetch - .mockResolvedValueOnce({ ok: true, json: async () => invites }) - .mockResolvedValueOnce({ ok: true, json: async () => groups }); - - const result = await loadFn( - 'active', - mockFetch as unknown as (url: string) => Promise + mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( + mockResponse(true, [ + { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, + { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } + ]) ); + const result = await load(event()); + expect(result.groups).toHaveLength(2); expect(result.groupsLoadError).toBeNull(); }); it('returns groups sorted alphabetically by name', async () => { - const groups: UserGroup[] = [ - { id: 'g-1', name: 'Zebra', permissions: [] }, - { id: 'g-2', name: 'Alfa', permissions: [] }, - { id: 'g-3', name: 'Mitte', permissions: [] } - ]; - - mockFetch - .mockResolvedValueOnce({ ok: true, json: async () => [] }) - .mockResolvedValueOnce({ ok: true, json: async () => groups }); - - const result = await loadFn( - 'active', - mockFetch as unknown as (url: string) => Promise + mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( + mockResponse(true, [ + { id: 'g-1', name: 'Zebra', permissions: [] }, + { id: 'g-2', name: 'Alfa', permissions: [] }, + { id: 'g-3', name: 'Mitte', permissions: [] } + ]) ); + const result = await load(event()); + expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']); }); it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => { mockFetch - .mockResolvedValueOnce({ ok: true, json: async () => [] }) - .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'FORBIDDEN' }) }); + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403)); - const result = await loadFn( - 'active', - mockFetch as unknown as (url: string) => Promise - ); + const result = await load(event()); expect(result.groups).toEqual([]); expect(result.groupsLoadError).toBe('FORBIDDEN'); @@ -161,23 +73,20 @@ describe('admin/invites load()', () => { it('falls back to INTERNAL_ERROR when groups error body has no code', async () => { mockFetch - .mockResolvedValueOnce({ ok: true, json: async () => [] }) - .mockResolvedValueOnce({ ok: false, json: async () => null }); + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(false, null, 500)); - const result = await loadFn( - 'active', - mockFetch as unknown as (url: string) => Promise - ); + const result = await load(event()); expect(result.groupsLoadError).toBe('INTERNAL_ERROR'); }); it('fetches invites and groups in parallel (both URLs called)', async () => { mockFetch - .mockResolvedValueOnce({ ok: true, json: async () => [] }) - .mockResolvedValueOnce({ ok: true, json: async () => [] }); + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(true, [])); - await loadFn('active', mockFetch as unknown as (url: string) => Promise); + await load(event()); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); @@ -186,25 +95,32 @@ describe('admin/invites load()', () => { }); describe('admin/invites create action', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockFetch = vi.fn(); + const mockFetch = vi.fn(); beforeEach(() => mockFetch.mockReset()); + const successBody = { + id: 'inv-1', + code: 'ABCDE12345', + displayCode: 'ABCDE-12345', + status: 'active', + revoked: false, + useCount: 0, + createdAt: '2026-01-01T00:00:00Z', + shareableUrl: 'http://localhost/register?code=ABCDE12345' + }; + it('includes groupIds array in POST body when checkboxes are checked', async () => { const fd = new FormData(); fd.append('groupIds', 'g-1'); fd.append('groupIds', 'g-2'); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'inv-1', code: 'ABCDE12345' }) - }); - - await createActionFn( - fd, - mockFetch as unknown as (url: string, init: RequestInit) => Promise - ); + await actions.create({ + request: new Request('http://localhost', { method: 'POST', body: fd }), + fetch: mockFetch as unknown as typeof fetch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; const sent = JSON.parse(init.body as string); @@ -213,16 +129,13 @@ describe('admin/invites create action', () => { it('sends groupIds: [] when no checkboxes are checked', async () => { const fd = new FormData(); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'inv-1', code: 'ABCDE12345' }) - }); - - await createActionFn( - fd, - mockFetch as unknown as (url: string, init: RequestInit) => Promise - ); + await actions.create({ + request: new Request('http://localhost', { method: 'POST', body: fd }), + fetch: mockFetch as unknown as typeof fetch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; const sent = JSON.parse(init.body as string);