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 type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -45,21 +46,17 @@ export const actions: Actions = {
|
|||||||
groupIds: data.getAll('groupIds') as string[]
|
groupIds: data.getAll('groupIds') as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`/api/users/${params.id}`, {
|
const api = createApiClient(fetch);
|
||||||
method: 'PUT',
|
const result = await api.PUT('/api/users/{id}', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
params: { path: { id: params.id } },
|
||||||
body: JSON.stringify(body)
|
// 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) {
|
if (!result.response.ok) {
|
||||||
let code: string | undefined;
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
try {
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
const json = await res.json();
|
|
||||||
code = json?.code;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return fail(res.status, { error: getErrorMessage(code) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
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: 'u1', email: 'alice@example.com' },
|
||||||
{ id: 'u2', email: 'bob@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).toHaveLength(2);
|
||||||
expect(result.users[0].email).toBe('alice@example.com');
|
expect(result.users[0].email).toBe('alice@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty array when the API returns nothing', async () => {
|
it('returns an empty array when the API returns nothing', async () => {
|
||||||
mockApi([]);
|
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([]);
|
expect(result.users).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +45,12 @@ describe('admin/users layout load', () => {
|
|||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
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');
|
expect(mockGet).toHaveBeenCalledWith('/api/users');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user