diff --git a/frontend/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index cb33d962..3746c264 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -1,9 +1,10 @@ import { fail } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; -import { parseBackendError } from '$lib/shared/errors'; +import { createApiClient } from '$lib/shared/api.server'; import type { Actions, PageServerLoad } from './$types'; import type { components } from '$lib/generated/api'; +// The spec marks shareableUrl optional but the backend always populates it. +// Keeping the required shape here avoids null-guarding throughout the page component. export interface InviteListItem { id: string; code: string; @@ -17,34 +18,33 @@ export interface InviteListItem { createdAt: string; shareableUrl: string; } - export type UserGroup = components['schemas']['UserGroup']; export const load: PageServerLoad = async ({ url, fetch }) => { const status = url.searchParams.get('status') ?? 'active'; - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const api = createApiClient(fetch); - const [invitesRes, groupsRes] = await Promise.all([ - fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`), - fetch(`${apiUrl}/api/groups`) + const [invitesResult, groupsResult] = await Promise.all([ + api.GET('/api/invites', { params: { query: { status } } }), + api.GET('/api/groups') ]); let invites: InviteListItem[] = []; let loadError: string | null = null; - if (!invitesRes.ok) { - const backendError = await parseBackendError(invitesRes); - loadError = backendError?.code ?? 'INTERNAL_ERROR'; + if (!invitesResult.response.ok) { + const code = (invitesResult.error as unknown as { code?: string })?.code; + loadError = code ?? 'INTERNAL_ERROR'; } else { - invites = await invitesRes.json(); + invites = (invitesResult.data ?? []) as unknown as InviteListItem[]; } let groups: UserGroup[] = []; let groupsLoadError: string | null = null; - if (!groupsRes.ok) { - const backendError = await parseBackendError(groupsRes); - groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR'; + if (!groupsResult.response.ok) { + const code = (groupsResult.error as unknown as { code?: string })?.code; + groupsLoadError = code ?? 'INTERNAL_ERROR'; } else { - const raw: UserGroup[] = await groupsRes.json(); + const raw = groupsResult.data ?? []; groups = [...raw].sort((a, b) => a.name.localeCompare(b.name)); } @@ -63,42 +63,29 @@ export const actions = { const expiresAt = (formData.get('expiresAt') as string) || undefined; const groupIds = formData.getAll('groupIds') as string[]; - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const res = await fetch(`${apiUrl}/api/invites`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - label, - maxUses, - prefillFirstName, - prefillLastName, - prefillEmail, - expiresAt, - groupIds - }) + const api = createApiClient(fetch); + const result = await api.POST('/api/invites', { + body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds } }); - if (!res.ok) { - const backendError = await parseBackendError(res); - return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' }); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' }); } - const created: InviteListItem = await res.json(); - return { created }; + return { created: result.data! as unknown as InviteListItem }; }, revoke: async ({ request, fetch }) => { const formData = await request.formData(); const id = formData.get('id') as string; - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, { - method: 'DELETE' - }); + const api = createApiClient(fetch); + const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } }); - if (!res.ok) { - const backendError = await parseBackendError(res); - return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' }); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' }); } return { revoked: id }; diff --git a/frontend/src/routes/admin/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts index 8e66e9b0..2f7b6157 100644 --- a/frontend/src/routes/admin/invites/page.server.test.ts +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -36,8 +36,10 @@ describe('admin/invites load()', () => { beforeEach(() => mockFetch.mockReset()); function event(status = 'active') { + const url = new URL(`http://localhost/admin/invites?status=${status}`); return { - url: new URL(`http://localhost/admin/invites?status=${status}`), + url, + request: new Request(url), fetch: mockFetch as unknown as typeof fetch // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -100,8 +102,14 @@ describe('admin/invites load()', () => { await load(event()); expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups')); + // createApiClient calls fetch(Request, {}), not fetch(string, init) + const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url); + expect(urls).toEqual( + expect.arrayContaining([ + expect.stringContaining('/api/invites'), + expect.stringContaining('/api/groups') + ]) + ); }); }); @@ -133,8 +141,11 @@ describe('admin/invites create action', () => { // 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); + // createApiClient calls fetch(Request, {}), not fetch(string, init) + const [req] = mockFetch.mock.calls[0] as [Request, unknown]; + expect(req).toBeInstanceOf(Request); + expect(req.url).toContain('/api/invites'); + const sent = await req.json(); expect(sent.groupIds).toEqual(['g-1', 'g-2']); }); @@ -148,8 +159,99 @@ describe('admin/invites create action', () => { // 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); + const [req] = mockFetch.mock.calls[0] as [Request, unknown]; + expect(req).toBeInstanceOf(Request); + const sent = await req.json(); expect(sent.groupIds).toEqual([]); }); + + it('returns created invite on success', async () => { + const fd = new FormData(); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); + + const result = 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); + + expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) }); + }); + + it('returns fail with backend error code when create returns non-OK', async () => { + const fd = new FormData(); + mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403)); + + const result = 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); + + expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } }); + }); + + it('falls back to INTERNAL_ERROR when create error body has no code', async () => { + const fd = new FormData(); + mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500)); + + const result = 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); + + expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } }); + }); +}); + +describe('admin/invites revoke action', () => { + const mockFetch = vi.fn(); + + beforeEach(() => mockFetch.mockReset()); + + it('calls DELETE /api/invites/{id} via createApiClient', async () => { + const fd = new FormData(); + fd.append('id', 'inv-abc'); + mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200)); + + await actions.revoke({ + 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 [req] = mockFetch.mock.calls[0] as [Request, unknown]; + expect(req).toBeInstanceOf(Request); + expect(req.url).toContain('/api/invites/inv-abc'); + expect(req.method).toBe('DELETE'); + }); + + it('returns revoked id on success', async () => { + const fd = new FormData(); + fd.append('id', 'inv-abc'); + mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200)); + + const result = await actions.revoke({ + 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); + + expect(result).toEqual({ revoked: 'inv-abc' }); + }); + + it('returns fail with backend error code when revoke returns non-OK', async () => { + const fd = new FormData(); + fd.append('id', 'inv-abc'); + mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404)); + + const result = await actions.revoke({ + 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); + + expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } }); + }); });