refactor(admin/users): migrate update action to createApiClient

Replace fetch('/api/users/${id}', { method: 'PUT', ... }) + inline JSON
error parsing with createApiClient(fetch).PUT('/api/users/{id}', ...) and
the standard result.error cast pattern.

Also fix pre-existing Sentry mock event failures in layout.server.spec.ts
by adding request and url to the test event object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 09:56:17 +02:00
parent 08eec086a9
commit 6576e1d376
3 changed files with 138 additions and 16 deletions

View File

@@ -2,6 +2,7 @@ import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
const user = locals.user;
@@ -45,21 +46,17 @@ export const actions: Actions = {
groupIds: data.getAll('groupIds') as string[]
};
const res = await fetch(`/api/users/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
const api = createApiClient(fetch);
const result = await api.PUT('/api/users/{id}', {
params: { path: { id: params.id } },
// Body may contain null for fields the user cleared; the backend treats
// null as "clear this field". Cast to satisfy the optional-only spec type.
body: body as components['schemas']['AdminUpdateUserRequest']
});
if (!res.ok) {
let code: string | undefined;
try {
const json = await res.json();
code = json?.code;
} catch {
// ignore
}
return fail(res.status, { error: getErrorMessage(code) });
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
import { actions } from './+page.server';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFetch = (...args: any[]) => any;
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;
}
function makeUpdateRequest(fields: Record<string, string | string[]> = {}) {
const fd = new FormData();
const defaults: Record<string, string> = {
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com'
};
for (const [k, v] of Object.entries({ ...defaults, ...fields })) {
if (Array.isArray(v)) {
v.forEach((item) => fd.append(k, item));
} else {
fd.append(k, v);
}
}
return new Request('http://localhost', { method: 'POST', body: fd });
}
describe('admin/users/[id] update action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
it('calls PUT /api/users/{id} via createApiClient (Request object)', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, {}, 200));
await actions.update({
params: { id: 'user-123' },
request: makeUpdateRequest(),
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/users/user-123');
expect(req.method).toBe('PUT');
});
it('returns success: true when PUT responds with 200', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, {}, 200));
const result = await actions.update({
params: { id: 'user-123' },
request: makeUpdateRequest(),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toEqual({ success: true });
});
it('returns fail with backend error code when PUT returns non-OK', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = await actions.update({
params: { id: 'user-123' },
request: makeUpdateRequest(),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 403 });
});
it('returns fail with generic message when error body has no code field', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(false, { message: 'internal server error' }, 500));
const result = await actions.update({
params: { id: 'user-123' },
request: makeUpdateRequest(),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 500 });
});
it('returns fail without calling backend when passwords do not match', async () => {
const result = await actions.update({
params: { id: 'user-123' },
request: makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 400 });
});
});

View File

@@ -19,14 +19,24 @@ describe('admin/users layout load', () => {
{ id: 'u1', email: 'alice@example.com' },
{ id: 'u2', email: 'bob@example.com' }
]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result.users).toHaveLength(2);
expect(result.users[0].email).toBe('alice@example.com');
});
it('returns an empty array when the API returns nothing', async () => {
mockApi([]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result.users).toEqual([]);
});
@@ -35,7 +45,12 @@ describe('admin/users layout load', () => {
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ fetch: vi.fn() as unknown as typeof fetch });
await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(mockGet).toHaveBeenCalledWith('/api/users');
});
});