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:
@@ -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 };
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user