refactor(admin/invites): migrate to createApiClient; fix Sentry mock event

Replace manual fetch(${apiUrl}/api/...) calls in load, create, and revoke
with createApiClient(fetch) so auth injection is handled by handleFetch
and the typed API contract is enforced at compile time.

Also fix pre-existing load test failures caused by Sentry's load wrapper
reading event.request.method (add request to the mock event object).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 09:46:28 +02:00
parent 1247b51d9e
commit 08eec086a9
2 changed files with 136 additions and 47 deletions

View File

@@ -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 };

View File

@@ -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<AnyFetch>();
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' } });
});
});