diff --git a/frontend/src/routes/admin/users/[id]/+page.server.ts b/frontend/src/routes/admin/users/[id]/+page.server.ts index e4c0f81c..38f4e27f 100644 --- a/frontend/src/routes/admin/users/[id]/+page.server.ts +++ b/frontend/src/routes/admin/users/[id]/+page.server.ts @@ -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 }; diff --git a/frontend/src/routes/admin/users/[id]/page.server.test.ts b/frontend/src/routes/admin/users/[id]/page.server.test.ts new file mode 100644 index 00000000..373e4ace --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/page.server.test.ts @@ -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 = {}) { + const fd = new FormData(); + const defaults: Record = { + 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(); + + 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 }); + }); +}); diff --git a/frontend/src/routes/admin/users/layout.server.spec.ts b/frontend/src/routes/admin/users/layout.server.spec.ts index c6211147..bd4ab7ff 100644 --- a/frontend/src/routes/admin/users/layout.server.spec.ts +++ b/frontend/src/routes/admin/users/layout.server.spec.ts @@ -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'); }); });