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
committed by marcel
parent 18e675a5b2
commit d739f58bb5
2 changed files with 136 additions and 47 deletions

View File

@@ -1,9 +1,10 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private'; import { createApiClient } from '$lib/shared/api.server';
import { parseBackendError } from '$lib/shared/errors';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { components } from '$lib/generated/api'; 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 { export interface InviteListItem {
id: string; id: string;
code: string; code: string;
@@ -17,34 +18,33 @@ export interface InviteListItem {
createdAt: string; createdAt: string;
shareableUrl: string; shareableUrl: string;
} }
export type UserGroup = components['schemas']['UserGroup']; export type UserGroup = components['schemas']['UserGroup'];
export const load: PageServerLoad = async ({ url, fetch }) => { export const load: PageServerLoad = async ({ url, fetch }) => {
const status = url.searchParams.get('status') ?? 'active'; 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([ const [invitesResult, groupsResult] = await Promise.all([
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`), api.GET('/api/invites', { params: { query: { status } } }),
fetch(`${apiUrl}/api/groups`) api.GET('/api/groups')
]); ]);
let invites: InviteListItem[] = []; let invites: InviteListItem[] = [];
let loadError: string | null = null; let loadError: string | null = null;
if (!invitesRes.ok) { if (!invitesResult.response.ok) {
const backendError = await parseBackendError(invitesRes); const code = (invitesResult.error as unknown as { code?: string })?.code;
loadError = backendError?.code ?? 'INTERNAL_ERROR'; loadError = code ?? 'INTERNAL_ERROR';
} else { } else {
invites = await invitesRes.json(); invites = (invitesResult.data ?? []) as unknown as InviteListItem[];
} }
let groups: UserGroup[] = []; let groups: UserGroup[] = [];
let groupsLoadError: string | null = null; let groupsLoadError: string | null = null;
if (!groupsRes.ok) { if (!groupsResult.response.ok) {
const backendError = await parseBackendError(groupsRes); const code = (groupsResult.error as unknown as { code?: string })?.code;
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR'; groupsLoadError = code ?? 'INTERNAL_ERROR';
} else { } else {
const raw: UserGroup[] = await groupsRes.json(); const raw = groupsResult.data ?? [];
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name)); 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 expiresAt = (formData.get('expiresAt') as string) || undefined;
const groupIds = formData.getAll('groupIds') as string[]; const groupIds = formData.getAll('groupIds') as string[];
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const api = createApiClient(fetch);
const res = await fetch(`${apiUrl}/api/invites`, { const result = await api.POST('/api/invites', {
method: 'POST', body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds }
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label,
maxUses,
prefillFirstName,
prefillLastName,
prefillEmail,
expiresAt,
groupIds
})
}); });
if (!res.ok) { if (!result.response.ok) {
const backendError = await parseBackendError(res); const code = (result.error as unknown as { code?: string })?.code;
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' }); return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
} }
const created: InviteListItem = await res.json(); return { created: result.data! as unknown as InviteListItem };
return { created };
}, },
revoke: async ({ request, fetch }) => { revoke: async ({ request, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const id = formData.get('id') as string; const id = formData.get('id') as string;
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const api = createApiClient(fetch);
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, { const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
method: 'DELETE'
});
if (!res.ok) { if (!result.response.ok) {
const backendError = await parseBackendError(res); const code = (result.error as unknown as { code?: string })?.code;
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' }); return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
} }
return { revoked: id }; return { revoked: id };

View File

@@ -36,8 +36,10 @@ describe('admin/invites load()', () => {
beforeEach(() => mockFetch.mockReset()); beforeEach(() => mockFetch.mockReset());
function event(status = 'active') { function event(status = 'active') {
const url = new URL(`http://localhost/admin/invites?status=${status}`);
return { return {
url: new URL(`http://localhost/admin/invites?status=${status}`), url,
request: new Request(url),
fetch: mockFetch as unknown as typeof fetch fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -100,8 +102,14 @@ describe('admin/invites load()', () => {
await load(event()); await load(event());
expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); // createApiClient calls fetch(Request, {}), not fetch(string, init)
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups')); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any); } as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; // createApiClient calls fetch(Request, {}), not fetch(string, init)
const sent = JSON.parse(init.body as string); 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']); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any); } as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; const [req] = mockFetch.mock.calls[0] as [Request, unknown];
const sent = JSON.parse(init.body as string); expect(req).toBeInstanceOf(Request);
const sent = await req.json();
expect(sent.groupIds).toEqual([]); 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' } });
});
}); });