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:
@@ -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 };
|
||||
|
||||
110
frontend/src/routes/admin/users/[id]/page.server.test.ts
Normal file
110
frontend/src/routes/admin/users/[id]/page.server.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user