diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b27388c5..bace28d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -263,7 +263,7 @@ if (!result.response.ok) { return { person: result.data! }; // non-null assertion is safe after the ok check ``` -For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it. +For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie. ### Date handling diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java index 4e0cd8a6..1355b0fe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java @@ -31,5 +31,6 @@ public class InviteListItemDTO { private String status; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createdAt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String shareableUrl; } diff --git a/frontend/package.json b/frontend/package.json index 7ef2ce7d..bc0c2da5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "lint:boundary-demo": "eslint src/lib/tag/__fixtures__/", "test:unit": "vitest", "test": "npm run test:unit -- --run", - "test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage", + "test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 3bf2833d..764adce9 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -180,6 +180,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/{id}/force-logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["forceLogout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/password": { parameters: { query?: never; @@ -580,6 +596,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/forgot-password": { parameters: { query?: never; @@ -1849,7 +1897,7 @@ export interface components { status: string; /** Format: date-time */ createdAt: string; - shareableUrl?: string; + shareableUrl: string; }; GroupDTO: { name?: string; @@ -2011,13 +2059,17 @@ export interface components { lastName?: string; notifyOnMention?: boolean; }; + LoginRequest: { + email?: string; + password?: string; + }; ForgotPasswordRequest: { email?: string; }; ImportStatus: { /** @enum {string} */ state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; - message?: string; + statusCode?: string; /** Format: int32 */ processed?: number; /** Format: date-time */ @@ -2255,14 +2307,14 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ numberOfElements?: number; empty?: boolean; @@ -2410,7 +2462,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -2954,6 +3006,30 @@ export interface operations { }; }; }; + forceLogout: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: unknown; + }; + }; + }; + }; + }; changePassword: { parameters: { query?: never; @@ -3547,6 +3623,7 @@ export interface operations { query?: never; header?: never; path: { + documentId: string; blockId: string; }; cookie?: never; @@ -3597,6 +3674,7 @@ export interface operations { header?: never; path: { documentId: string; + blockId: string; commentId: string; }; cookie?: never; @@ -3791,6 +3869,48 @@ export interface operations { }; }; }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AppUser"]; + }; + }; + }; + }; forgotPassword: { parameters: { query?: never; @@ -4985,7 +5105,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["DocumentDensityResult"]; + "application/json": components["schemas"]["DocumentDensityResult"]; }; }; }; @@ -5061,7 +5181,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[]; }; header?: never; path?: never; diff --git a/frontend/src/routes/admin/groups/layout.server.spec.ts b/frontend/src/routes/admin/groups/layout.server.spec.ts index 8a7df9a3..08cff99c 100644 --- a/frontend/src/routes/admin/groups/layout.server.spec.ts +++ b/frontend/src/routes/admin/groups/layout.server.spec.ts @@ -19,14 +19,22 @@ describe('admin/groups layout load', () => { { id: 'g1', name: 'Admins', permissions: ['ADMIN'] }, { id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] } ]); - 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/groups'), + url: new URL('http://localhost/admin/groups') + }); expect(result.groups).toHaveLength(2); expect(result.groups[0].name).toBe('Admins'); }); 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/groups'), + url: new URL('http://localhost/admin/groups') + }); expect(result.groups).toEqual([]); }); @@ -35,7 +43,11 @@ describe('admin/groups 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/groups'), + url: new URL('http://localhost/admin/groups') + }); expect(mockGet).toHaveBeenCalledWith('/api/groups'); }); }); diff --git a/frontend/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index cb33d962..9123de94 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -1,50 +1,43 @@ 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 { getErrorMessage } from '$lib/shared/errors'; import type { Actions, PageServerLoad } from './$types'; import type { components } from '$lib/generated/api'; -export interface InviteListItem { - id: string; - code: string; - displayCode: string; - label?: string; - useCount: number; - maxUses?: number; - expiresAt?: string; - revoked: boolean; - status: string; - createdAt: string; - shareableUrl: string; -} - +export type InviteListItem = components['schemas']['InviteListItemDTO']; 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 VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const; +type InviteStatus = (typeof VALID_STATUSES)[number]; - const [invitesRes, groupsRes] = await Promise.all([ - fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`), - fetch(`${apiUrl}/api/groups`) +export const load: PageServerLoad = async ({ url, fetch }) => { + const rawStatus = url.searchParams.get('status'); + const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus) + ? (rawStatus as InviteStatus) + : 'ACTIVE'; + const api = createApiClient(fetch); + + 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 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 +56,30 @@ 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 InviteListItem }; }, revoke: async ({ request, fetch }) => { const formData = await request.formData(); - const id = formData.get('id') as string; + const id = formData.get('id') as string | null; + if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') }); - 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 }; diff --git a/frontend/src/routes/admin/invites/page.server.spec.ts b/frontend/src/routes/admin/invites/page.server.spec.ts new file mode 100644 index 00000000..f5592b34 --- /dev/null +++ b/frontend/src/routes/admin/invites/page.server.spec.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://localhost:8080' } +})); + +import { load, actions } from './+page.server'; +import type { UserGroup } from './+page.server'; + +// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids +// the void and the Record from the generic constraint. +type LoadData = { + invites: unknown[]; + status: string; + loadError: string | null; + groups: UserGroup[]; + groupsLoadError: string | null; +}; + +// 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; +} + +describe('admin/invites load()', () => { + const mockFetch = vi.fn(); + + beforeEach(() => mockFetch.mockReset()); + + function event(status = 'active') { + const url = new URL(`http://localhost/admin/invites?status=${status}`); + return { + url, + request: new Request(url), + fetch: mockFetch as unknown as typeof fetch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('returns groups array alongside invites when both succeed', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( + mockResponse(true, [ + { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, + { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } + ]) + ); + + const result = (await load(event())) as LoadData; + + expect(result.groups).toHaveLength(2); + expect(result.groupsLoadError).toBeNull(); + }); + + it('returns groups sorted alphabetically by name', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( + mockResponse(true, [ + { id: 'g-1', name: 'Zebra', permissions: [] }, + { id: 'g-2', name: 'Alfa', permissions: [] }, + { id: 'g-3', name: 'Mitte', permissions: [] } + ]) + ); + + const result = (await load(event())) as LoadData; + + expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']); + }); + + it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403)); + + const result = (await load(event())) as LoadData; + + expect(result.groups).toEqual([]); + expect(result.groupsLoadError).toBe('FORBIDDEN'); + }); + + it('falls back to INTERNAL_ERROR when groups error body has no code', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(false, null, 500)); + + const result = (await load(event())) as LoadData; + + expect(result.groupsLoadError).toBe('INTERNAL_ERROR'); + }); + + it('fetches invites and groups in parallel (both URLs called)', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(true, [])) + .mockResolvedValueOnce(mockResponse(true, [])); + + await load(event()); + + expect(mockFetch).toHaveBeenCalledTimes(2); + // 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') + ]) + ); + }); +}); + +describe('admin/invites create action', () => { + const mockFetch = vi.fn(); + + beforeEach(() => mockFetch.mockReset()); + + const successBody = { + id: 'inv-1', + code: 'ABCDE12345', + displayCode: 'ABCDE-12345', + status: 'active', + revoked: false, + useCount: 0, + createdAt: '2026-01-01T00:00:00Z', + shareableUrl: 'http://localhost/register?code=ABCDE12345' + }; + + it('includes groupIds array in POST body when checkboxes are checked', async () => { + const fd = new FormData(); + fd.append('groupIds', 'g-1'); + fd.append('groupIds', 'g-2'); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); + + 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); + + // 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']); + }); + + it('sends groupIds: [] when no checkboxes are checked', async () => { + const fd = new FormData(); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); + + 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); + + 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' } }); + }); + + it('includes expiresAt in POST body when provided', async () => { + const fd = new FormData(); + fd.append('expiresAt', '2026-12-31'); + mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); + + 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); + + const [req] = mockFetch.mock.calls[0] as [Request, unknown]; + const sent = await req.json(); + expect(sent.expiresAt).toBe('2026-12-31'); + }); +}); + +describe('admin/invites revoke action', () => { + const mockFetch = vi.fn(); + + 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' } }); + }); + + it('returns fail(400) when revoke id is missing', async () => { + const result = await actions.revoke({ + request: new Request('http://localhost', { method: 'POST', body: new FormData() }), + 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/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts deleted file mode 100644 index 8e66e9b0..00000000 --- a/frontend/src/routes/admin/invites/page.server.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('$env/dynamic/private', () => ({ - env: { API_INTERNAL_URL: 'http://localhost:8080' } -})); - -import { load, actions } from './+page.server'; -import type { UserGroup } from './+page.server'; - -// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids -// the void and the Record from the generic constraint. -type LoadData = { - invites: unknown[]; - status: string; - loadError: string | null; - groups: UserGroup[]; - groupsLoadError: string | null; -}; - -// 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; -} - -describe('admin/invites load()', () => { - const mockFetch = vi.fn(); - - beforeEach(() => mockFetch.mockReset()); - - function event(status = 'active') { - return { - url: new URL(`http://localhost/admin/invites?status=${status}`), - fetch: mockFetch as unknown as typeof fetch - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - } - - it('returns groups array alongside invites when both succeed', async () => { - mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( - mockResponse(true, [ - { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, - { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } - ]) - ); - - const result = (await load(event())) as LoadData; - - expect(result.groups).toHaveLength(2); - expect(result.groupsLoadError).toBeNull(); - }); - - it('returns groups sorted alphabetically by name', async () => { - mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce( - mockResponse(true, [ - { id: 'g-1', name: 'Zebra', permissions: [] }, - { id: 'g-2', name: 'Alfa', permissions: [] }, - { id: 'g-3', name: 'Mitte', permissions: [] } - ]) - ); - - const result = (await load(event())) as LoadData; - - expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']); - }); - - it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => { - mockFetch - .mockResolvedValueOnce(mockResponse(true, [])) - .mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403)); - - const result = (await load(event())) as LoadData; - - expect(result.groups).toEqual([]); - expect(result.groupsLoadError).toBe('FORBIDDEN'); - }); - - it('falls back to INTERNAL_ERROR when groups error body has no code', async () => { - mockFetch - .mockResolvedValueOnce(mockResponse(true, [])) - .mockResolvedValueOnce(mockResponse(false, null, 500)); - - const result = (await load(event())) as LoadData; - - expect(result.groupsLoadError).toBe('INTERNAL_ERROR'); - }); - - it('fetches invites and groups in parallel (both URLs called)', async () => { - mockFetch - .mockResolvedValueOnce(mockResponse(true, [])) - .mockResolvedValueOnce(mockResponse(true, [])); - - await load(event()); - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups')); - }); -}); - -describe('admin/invites create action', () => { - const mockFetch = vi.fn(); - - beforeEach(() => mockFetch.mockReset()); - - const successBody = { - id: 'inv-1', - code: 'ABCDE12345', - displayCode: 'ABCDE-12345', - status: 'active', - revoked: false, - useCount: 0, - createdAt: '2026-01-01T00:00:00Z', - shareableUrl: 'http://localhost/register?code=ABCDE12345' - }; - - it('includes groupIds array in POST body when checkboxes are checked', async () => { - const fd = new FormData(); - fd.append('groupIds', 'g-1'); - fd.append('groupIds', 'g-2'); - mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); - - 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); - - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - const sent = JSON.parse(init.body as string); - expect(sent.groupIds).toEqual(['g-1', 'g-2']); - }); - - it('sends groupIds: [] when no checkboxes are checked', async () => { - const fd = new FormData(); - mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201)); - - 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); - - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - const sent = JSON.parse(init.body as string); - expect(sent.groupIds).toEqual([]); - }); -}); diff --git a/frontend/src/routes/admin/layout.server.spec.ts b/frontend/src/routes/admin/layout.server.spec.ts index 1d744b6c..0fb89809 100644 --- a/frontend/src/routes/admin/layout.server.spec.ts +++ b/frontend/src/routes/admin/layout.server.spec.ts @@ -26,26 +26,46 @@ beforeEach(() => vi.clearAllMocks()); describe('admin layout load — permission check', () => { it('throws 403 when user has no admin permission', async () => { await expect( - load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } }) + load({ + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/admin'), + url: new URL('http://localhost/admin'), + locals: { user: noPermUser } + }) ).rejects.toMatchObject({ status: 403 }); }); it('throws 403 when user is undefined', async () => { await expect( - load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } }) + load({ + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/admin'), + url: new URL('http://localhost/admin'), + locals: { user: undefined } + }) ).rejects.toMatchObject({ status: 403 }); }); it('throws 403 when user has no groups', async () => { await expect( - load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } }) + load({ + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/admin'), + url: new URL('http://localhost/admin'), + locals: { user: { groups: [] } } + }) ).rejects.toMatchObject({ status: 403 }); }); it('allows access for a user with ADMIN_TAG only', async () => { mockApi([], [], []); await expect( - load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } }) + load({ + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/admin'), + url: new URL('http://localhost/admin'), + locals: { user: tagAdminUser } + }) ).resolves.toBeDefined(); }); @@ -63,6 +83,8 @@ describe('admin layout load — permission check', () => { const result = await load({ fetch: mockFetch as unknown as typeof fetch, + request: new Request('http://localhost/admin'), + url: new URL('http://localhost/admin'), locals: { user: adminUser } }); diff --git a/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts b/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts index 2e93d323..4d2f6ef7 100644 --- a/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts +++ b/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts @@ -15,7 +15,12 @@ describe('admin/ocr/[personId] — load', () => { data: { runs: [], personNames: { [personId]: 'Anna Müller' } } }); - const result = (await load({ params: { personId }, fetch } as never))!; + const result = (await load({ + params: { personId }, + fetch, + request: new Request('http://localhost/admin/ocr/123'), + url: new URL('http://localhost/admin/ocr/123') + } as never))!; expect(result.history.personNames?.[personId]).toBe('Anna Müller'); }); @@ -27,7 +32,12 @@ describe('admin/ocr/[personId] — load', () => { }); await expect( - load({ params: { personId: 'unknown-id' }, fetch } as never) + load({ + params: { personId: 'unknown-id' }, + fetch, + request: new Request('http://localhost/admin/ocr/unknown-id'), + url: new URL('http://localhost/admin/ocr/unknown-id') + } as never) ).rejects.toMatchObject({ status: 404 }); }); }); diff --git a/frontend/src/routes/admin/ocr/global/page.server.spec.ts b/frontend/src/routes/admin/ocr/global/page.server.spec.ts index 9aa48f64..b96438de 100644 --- a/frontend/src/routes/admin/ocr/global/page.server.spec.ts +++ b/frontend/src/routes/admin/ocr/global/page.server.spec.ts @@ -14,7 +14,11 @@ describe('admin/ocr/global — load', () => { data: { runs: [{ id: 'run1' }], personNames: {} } }); - const result = (await load({ fetch } as never))!; + const result = (await load({ + fetch, + request: new Request('http://localhost/admin/ocr/global'), + url: new URL('http://localhost/admin/ocr/global') + } as never))!; expect(result.history.runs).toHaveLength(1); }); @@ -22,6 +26,12 @@ describe('admin/ocr/global — load', () => { it('throws error when API call fails', async () => { mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} }); - await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 }); + await expect( + load({ + fetch, + request: new Request('http://localhost/admin/ocr/global'), + url: new URL('http://localhost/admin/ocr/global') + } as never) + ).rejects.toMatchObject({ status: 500 }); }); }); diff --git a/frontend/src/routes/admin/ocr/page.server.spec.ts b/frontend/src/routes/admin/ocr/page.server.spec.ts index b2abe869..a7c4f019 100644 --- a/frontend/src/routes/admin/ocr/page.server.spec.ts +++ b/frontend/src/routes/admin/ocr/page.server.spec.ts @@ -14,7 +14,11 @@ describe('admin/ocr — load', () => { data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] } }); - const result = (await load({ fetch } as never))!; + const result = (await load({ + fetch, + request: new Request('http://localhost/admin/ocr'), + url: new URL('http://localhost/admin/ocr') + } as never))!; expect(result.trainingInfo.availableBlocks).toBe(10); expect(result.trainingInfo.ocrServiceAvailable).toBe(true); @@ -23,6 +27,12 @@ describe('admin/ocr — load', () => { it('throws 503 when OCR API call fails', async () => { mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} }); - await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 }); + await expect( + load({ + fetch, + request: new Request('http://localhost/admin/ocr'), + url: new URL('http://localhost/admin/ocr') + } as never) + ).rejects.toMatchObject({ status: 503 }); }); }); diff --git a/frontend/src/routes/admin/tags/[id]/page.server.spec.ts b/frontend/src/routes/admin/tags/[id]/page.server.spec.ts index a4614eae..c8b724c0 100644 --- a/frontend/src/routes/admin/tags/[id]/page.server.spec.ts +++ b/frontend/src/routes/admin/tags/[id]/page.server.spec.ts @@ -21,6 +21,7 @@ describe('tags/[id] — load function', () => { const result = await load({ params: { id: 't1' }, parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }), + request: new Request('http://localhost/admin/tags/t1'), url } as never); expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true); @@ -31,6 +32,7 @@ describe('tags/[id] — load function', () => { const result = await load({ params: { id: 't1' }, parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }), + request: new Request('http://localhost/admin/tags/t1'), url } as never); expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false); diff --git a/frontend/src/routes/admin/tags/layout.server.spec.ts b/frontend/src/routes/admin/tags/layout.server.spec.ts index 80879593..d50440f5 100644 --- a/frontend/src/routes/admin/tags/layout.server.spec.ts +++ b/frontend/src/routes/admin/tags/layout.server.spec.ts @@ -44,14 +44,22 @@ const sampleTree = [ describe('admin/tags layout load', () => { it('returns the tree list', async () => { mockTreeApi(sampleTree); - 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/tags'), + url: new URL('http://localhost/admin/tags') + }); expect(result.tree).toHaveLength(2); expect(result.tree[0].name).toBe('Familie'); }); it('returns an empty tree when the API returns nothing', async () => { mockTreeApi([]); - 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/tags'), + url: new URL('http://localhost/admin/tags') + }); expect(result.tree).toEqual([]); }); @@ -60,13 +68,21 @@ describe('admin/tags 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/tags'), + url: new URL('http://localhost/admin/tags') + }); expect(mockGet).toHaveBeenCalledWith('/api/tags/tree'); }); it('flattens the tree into a flat tags array', async () => { mockTreeApi(sampleTree); - 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/tags'), + url: new URL('http://localhost/admin/tags') + }); // Both parent and child should be in the flat array expect(result.tags).toHaveLength(3); expect(result.tags.map((t) => t.name)).toContain('Eltern'); @@ -74,14 +90,22 @@ describe('admin/tags layout load', () => { it('preserves parentId on child tags in the flat array', async () => { mockTreeApi(sampleTree); - 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/tags'), + url: new URL('http://localhost/admin/tags') + }); const child = result.tags.find((t) => t.name === 'Eltern'); expect(child?.parentId).toBe('parent1'); }); it('sets parentId to undefined on root tags in the flat array', async () => { mockTreeApi(sampleTree); - 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/tags'), + url: new URL('http://localhost/admin/tags') + }); const root = result.tags.find((t) => t.name === 'Familie'); expect(root?.parentId).toBeUndefined(); }); 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.spec.ts b/frontend/src/routes/admin/users/[id]/page.server.spec.ts new file mode 100644 index 00000000..da2f3921 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/page.server.spec.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://localhost:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() })); + +import { load, actions } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; + +function mockApi(methods: Partial>>) { + vi.mocked(createApiClient).mockReturnValue(methods as ReturnType); +} + +// ─── load() ────────────────────────────────────────────────────────────────── + +describe('admin/users/[id] load()', () => { + beforeEach(() => vi.clearAllMocks()); + + function makeEvent(permissions: string[] = ['ADMIN']) { + return { + params: { id: 'user-123' }, + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: { groups: [{ permissions }] } }, + request: new Request('http://localhost/admin/users/user-123'), + url: new URL('http://localhost/admin/users/user-123') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('throws 403 when the user lacks the ADMIN permission', async () => { + let thrown: unknown; + try { + await load(makeEvent(['READ_ALL'])); + } catch (e) { + thrown = e; + } + + expect((thrown as { status: number }).status).toBe(403); + }); + + it('throws 404 when the backend returns non-ok for the user lookup', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: false, status: 404 }, data: undefined }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); + mockApi({ GET: mockGet }); + + let thrown: unknown; + try { + await load(makeEvent()); + } catch (e) { + thrown = e; + } + + expect((thrown as { status: number }).status).toBe(404); + }); + + it('returns editUser and groups on success', async () => { + const editUser = { id: 'user-123', email: 'max@example.com', firstName: 'Max' }; + const groups = [ + { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, + { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } + ]; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true }, data: editUser }) + .mockResolvedValueOnce({ response: { ok: true }, data: groups }); + mockApi({ GET: mockGet }); + + const result = await load(makeEvent()); + + expect(result).toMatchObject({ + editUser: expect.objectContaining({ id: 'user-123' }), + groups: expect.arrayContaining([expect.objectContaining({ id: 'g-1' })]) + }); + }); +}); + +// ─── update action ──────────────────────────────────────────────────────────── + +describe('admin/users/[id] update action', () => { + beforeEach(() => vi.clearAllMocks()); + + 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 }); + } + + function makeEvent(request: Request) { + return { + params: { id: 'user-123' }, + request, + fetch: vi.fn() as unknown as typeof fetch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('calls PUT /api/users/{id} and returns success: true on 200', async () => { + const mockPut = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} }); + mockApi({ PUT: mockPut }); + + const result = await actions.update(makeEvent(makeUpdateRequest())); + + expect(mockPut).toHaveBeenCalledWith( + '/api/users/{id}', + expect.objectContaining({ params: { path: { id: 'user-123' } } }) + ); + expect(result).toEqual({ success: true }); + }); + + it('returns fail with backend error code when PUT returns non-OK', async () => { + const mockPut = vi + .fn() + .mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } }); + mockApi({ PUT: mockPut }); + + const result = await actions.update(makeEvent(makeUpdateRequest())); + + expect(result).toMatchObject({ status: 403 }); + }); + + it('returns fail with generic message when error body has no code field', async () => { + const mockPut = vi + .fn() + .mockResolvedValue({ response: { ok: false, status: 500 }, error: null }); + mockApi({ PUT: mockPut }); + + const result = await actions.update(makeEvent(makeUpdateRequest())); + + expect(result).toMatchObject({ status: 500 }); + }); + + it('returns fail without calling backend when passwords do not match', async () => { + const mockPut = vi.fn(); + mockApi({ PUT: mockPut }); + + const result = await actions.update( + makeEvent(makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' })) + ); + + expect(mockPut).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 400 }); + }); +}); + +// ─── delete action ──────────────────────────────────────────────────────────── + +describe('admin/users/[id] delete action', () => { + beforeEach(() => vi.clearAllMocks()); + + function makeEvent() { + return { + params: { id: 'user-123' }, + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/admin/users/user-123') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('redirects to /admin/users on successful delete', async () => { + const mockDelete = vi.fn().mockResolvedValue({ response: { ok: true } }); + mockApi({ DELETE: mockDelete }); + + let redirectLocation: string | null = null; + try { + await actions.delete(makeEvent()); + } catch (e: unknown) { + const r = e as { location?: string }; + redirectLocation = r.location ?? null; + } + + expect(redirectLocation).toBe('/admin/users'); + }); + + it('returns fail when delete returns non-OK', async () => { + const mockDelete = vi + .fn() + .mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } }); + mockApi({ DELETE: mockDelete }); + + const result = await actions.delete(makeEvent()); + + expect(result).toMatchObject({ status: 403 }); + }); +}); 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'); }); }); diff --git a/frontend/src/routes/aktivitaeten/page.server.spec.ts b/frontend/src/routes/aktivitaeten/page.server.spec.ts index 91f0d31f..31a1619f 100644 --- a/frontend/src/routes/aktivitaeten/page.server.spec.ts +++ b/frontend/src/routes/aktivitaeten/page.server.spec.ts @@ -29,7 +29,11 @@ beforeEach(() => { describe('aktivitaeten/load — core', () => { it('requests only unread notifications for Für-dich', async () => { mockSuccess(); - await load({ fetch, url: buildUrl() } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl() + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', { params: { query: { read: false, page: 0, size: 20 } } }); @@ -45,7 +49,11 @@ describe('aktivitaeten/load — core', () => { return Promise.resolve({ response: { ok: true }, data: { content: unread } }); }); - const result = await load({ fetch, url: buildUrl() } as never); + const result = await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl() + } as never); expect(result.activityFeed).toEqual(feed); expect(result.unreadNotifications).toEqual(unread); @@ -61,7 +69,11 @@ describe('aktivitaeten/load — core', () => { return Promise.resolve({ response: { ok: true }, data: { content: [] } }); }); - const result = await load({ fetch, url: buildUrl() } as never); + const result = await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl() + } as never); expect(result.loadError).toBe('activity'); expect(result.activityFeed).toEqual([]); @@ -69,11 +81,19 @@ describe('aktivitaeten/load — core', () => { it('parses the filter query param, falling back to "alle" for invalid values', async () => { mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] }); - const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never); + const validResult = await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=fuer-dich') + } as never); expect(validResult.filter).toBe('fuer-dich'); mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] }); - const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never); + const invalidResult = await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=bogus') + } as never); expect(invalidResult.filter).toBe('alle'); }); }); @@ -81,7 +101,11 @@ describe('aktivitaeten/load — core', () => { describe('aktivitaeten/load — kinds param per filter', () => { it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => { mockSuccess(); - await load({ fetch, url: buildUrl() } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl() + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { params: { query: { limit: 40 } } }); @@ -89,7 +113,11 @@ describe('aktivitaeten/load — kinds param per filter', () => { it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => { mockSuccess(); - await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=fuer-dich') + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { params: { query: { limit: 40 } } }); @@ -97,7 +125,11 @@ describe('aktivitaeten/load — kinds param per filter', () => { it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => { mockSuccess(); - await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=hochgeladen') + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } } }); @@ -105,7 +137,11 @@ describe('aktivitaeten/load — kinds param per filter', () => { it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => { mockSuccess(); - await load({ fetch, url: buildUrl('?filter=transkription') } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=transkription') + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { params: { query: { @@ -120,7 +156,11 @@ describe('aktivitaeten/load — kinds param per filter', () => { it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => { mockSuccess(); - await load({ fetch, url: buildUrl('?filter=kommentare') } as never); + await load({ + fetch, + request: new Request('http://localhost/aktivitaeten'), + url: buildUrl('?filter=kommentare') + } as never); expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', { params: { query: { diff --git a/frontend/src/routes/briefwechsel/page.server.spec.ts b/frontend/src/routes/briefwechsel/page.server.spec.ts index 16ad7637..1c8232ef 100644 --- a/frontend/src/routes/briefwechsel/page.server.spec.ts +++ b/frontend/src/routes/briefwechsel/page.server.spec.ts @@ -40,6 +40,7 @@ describe('korrespondenz load — no senderId', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: readUser } }); @@ -69,6 +70,7 @@ describe('korrespondenz load — senderId set, no receiverId', () => { const result = await load({ url: makeUrl({ senderId: 'p1' }), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: readUser } }); @@ -108,6 +110,7 @@ describe('korrespondenz load — senderId and receiverId set', () => { const result = await load({ url: makeUrl({ senderId: 'p1', receiverId: 'p2' }), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: readUser } }); @@ -137,6 +140,7 @@ describe('korrespondenz load — canWrite', () => { const result = await load({ url: makeUrl({ senderId: 'p1' }), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: writeUser } }); @@ -160,6 +164,7 @@ describe('korrespondenz load — canWrite', () => { const result = await load({ url: makeUrl({ senderId: 'p1' }), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: readUser } }); @@ -188,6 +193,7 @@ describe('korrespondenz load — backend error', () => { await expect( load({ url: makeUrl({ senderId: 'p1' }), + request: new Request('http://localhost/briefwechsel'), fetch: vi.fn() as unknown as typeof fetch, locals: { user: readUser } }) diff --git a/frontend/src/routes/documents/[id]/page.server.spec.ts b/frontend/src/routes/documents/[id]/page.server.spec.ts index 591a229d..f2735a9e 100644 --- a/frontend/src/routes/documents/[id]/page.server.spec.ts +++ b/frontend/src/routes/documents/[id]/page.server.spec.ts @@ -23,7 +23,9 @@ describe('document detail load — happy path', () => { const result = await load({ params: { id: '123' }, - fetch: mockFetch as unknown as typeof fetch + fetch: mockFetch as unknown as typeof fetch, + request: new Request('http://localhost/documents/123'), + url: new URL('http://localhost/documents/123') }); expect(result.document.title).toBe('Testbrief'); @@ -44,7 +46,12 @@ describe('document detail load — error paths', () => { const mockFetch = vi.fn(); await expect( - load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch }) + load({ + params: { id: 'missing' }, + fetch: mockFetch as unknown as typeof fetch, + request: new Request('http://localhost/documents/123'), + url: new URL('http://localhost/documents/123') + }) ).rejects.toMatchObject({ status: 404 }); }); @@ -59,7 +66,12 @@ describe('document detail load — error paths', () => { const mockFetch = vi.fn(); await expect( - load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch }) + load({ + params: { id: 'secret' }, + fetch: mockFetch as unknown as typeof fetch, + request: new Request('http://localhost/documents/123'), + url: new URL('http://localhost/documents/123') + }) ).rejects.toMatchObject({ status: 403 }); }); @@ -74,7 +86,12 @@ describe('document detail load — error paths', () => { const mockFetch = vi.fn(); await expect( - load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch }) + load({ + params: { id: 'any' }, + fetch: mockFetch as unknown as typeof fetch, + request: new Request('http://localhost/documents/123'), + url: new URL('http://localhost/documents/123') + }) ).rejects.toMatchObject({ location: '/login' }); }); }); diff --git a/frontend/src/routes/documents/bulk-edit/page.server.spec.ts b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts index d43d2248..2bca1032 100644 --- a/frontend/src/routes/documents/bulk-edit/page.server.spec.ts +++ b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts @@ -6,7 +6,11 @@ describe('/documents/bulk-edit +page.server.ts', () => { const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; try { // @ts-expect-error — partial event shape sufficient for this guard - await load({ locals }); + await load({ + locals, + request: new Request('http://localhost/documents/bulk-edit'), + url: new URL('http://localhost/documents/bulk-edit') + }); throw new Error('expected redirect to be thrown'); } catch (e) { const err = e as { status?: number; location?: string }; @@ -19,7 +23,11 @@ describe('/documents/bulk-edit +page.server.ts', () => { const locals = { user: { groups: [] } }; try { // @ts-expect-error — partial event shape sufficient for this guard - await load({ locals }); + await load({ + locals, + request: new Request('http://localhost/documents/bulk-edit'), + url: new URL('http://localhost/documents/bulk-edit') + }); throw new Error('expected redirect'); } catch (e) { expect((e as { status?: number }).status).toBe(303); @@ -30,7 +38,11 @@ describe('/documents/bulk-edit +page.server.ts', () => { const locals = {}; try { // @ts-expect-error — partial event shape sufficient for this guard - await load({ locals }); + await load({ + locals, + request: new Request('http://localhost/documents/bulk-edit'), + url: new URL('http://localhost/documents/bulk-edit') + }); throw new Error('expected redirect'); } catch (e) { expect((e as { status?: number }).status).toBe(303); @@ -40,7 +52,11 @@ describe('/documents/bulk-edit +page.server.ts', () => { it('returns canWrite=true for a WRITE_ALL user', async () => { const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } }; // @ts-expect-error — partial event shape sufficient for this guard - const result = await load({ locals }); + const result = await load({ + locals, + request: new Request('http://localhost/documents/bulk-edit'), + url: new URL('http://localhost/documents/bulk-edit') + }); expect(result).toEqual({ canWrite: true }); }); @@ -52,7 +68,11 @@ describe('/documents/bulk-edit +page.server.ts', () => { }; try { // @ts-expect-error — partial event shape sufficient for this guard - await load({ locals }); + await load({ + locals, + request: new Request('http://localhost/documents/bulk-edit'), + url: new URL('http://localhost/documents/bulk-edit') + }); throw new Error('expected redirect'); } catch (e) { expect((e as { status?: number }).status).toBe(303); diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index a77d2c3e..ac71972f 100644 --- a/frontend/src/routes/documents/page.server.spec.ts +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -33,6 +33,7 @@ describe('documents page load — search params', () => { await load({ url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -57,6 +58,7 @@ describe('documents page load — search params', () => { await load({ url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -81,6 +83,7 @@ describe('documents page load — search params', () => { await load({ url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -111,6 +114,7 @@ describe('documents page load — search params', () => { const result = await load({ url: makeUrl({ q: 'test' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -129,6 +133,7 @@ describe('documents page load — search params', () => { const result = await load({ url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -148,7 +153,11 @@ describe('documents page load — auth redirect', () => { } as ReturnType); await expect( - load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + load({ + url: makeUrl(), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }) ).rejects.toMatchObject({ location: '/login' }); }); }); @@ -161,7 +170,11 @@ describe('documents page load — network error fallback', () => { GET: vi.fn().mockRejectedValue(new Error('Network failure')) } as ReturnType); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/documents'), + fetch: vi.fn() as unknown as typeof fetch + }); expect(result.error).toBeTruthy(); expect(result.items).toEqual([]); @@ -199,6 +212,7 @@ describe('documents page load — person name resolution', () => { const result = await load({ url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -210,6 +224,7 @@ describe('documents page load — person name resolution', () => { const result = await load({ url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -221,6 +236,7 @@ describe('documents page load — person name resolution', () => { const result = await load({ url: makeUrl({ senderId: 'not-a-uuid' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); @@ -234,6 +250,7 @@ describe('documents page load — person name resolution', () => { const result = await load({ url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }), + request: new Request('http://localhost/documents'), fetch: vi.fn() as unknown as typeof fetch }); diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b51540c3..9ffae2b9 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -33,6 +33,7 @@ it('never calls /api/documents/search regardless of URL params', async () => { await load({ url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -49,6 +50,7 @@ it('always fetches dashboard data regardless of URL params', async () => { await load({ url: makeUrl({ q: 'Urlaub' }), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -110,6 +112,7 @@ describe('home page load — dashboard', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -147,6 +150,7 @@ describe('home page load — dashboard', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -168,6 +172,7 @@ describe('home page load — dashboard', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -189,6 +194,7 @@ describe('home page load — dashboard', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -213,6 +219,7 @@ describe('home page load — dashboard', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -232,6 +239,7 @@ describe('home page load — auth redirect', () => { await expect( load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]) @@ -249,6 +257,7 @@ describe('home page load — network error fallback', () => { const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: contributorParent() } as Parameters[0]); @@ -268,6 +277,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi .fn() @@ -289,6 +299,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi .fn() @@ -310,6 +321,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi .fn() @@ -332,6 +344,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true }) } as Parameters[0]); @@ -352,6 +365,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi .fn() @@ -369,6 +383,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }) } as Parameters[0]); @@ -398,6 +413,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate const result = await load({ url: makeUrl(), + request: new Request('http://localhost/'), fetch: vi.fn() as unknown as typeof fetch, parent: vi .fn() diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index 45d60ce6..c2fc6dea 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -32,7 +32,13 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); + const result = await load({ + params: { id: 'p1' }, + fetch: mockFetch, + request: new Request('http://localhost/persons/p1'), + url: new URL('http://localhost/persons/p1'), + locals: mockLocals + }); expect(result.person.firstName).toBe('Hans'); expect(result.sentDocuments).toHaveLength(1); @@ -55,7 +61,13 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter }); + const result = await load({ + params: { id: 'p1' }, + fetch: mockFetch, + request: new Request('http://localhost/persons/p1'), + url: new URL('http://localhost/persons/p1'), + locals: mockLocalsWriter + }); expect(result.canWrite).toBe(true); }); @@ -76,7 +88,13 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); + const result = await load({ + params: { id: 'p1' }, + fetch: mockFetch, + request: new Request('http://localhost/persons/p1'), + url: new URL('http://localhost/persons/p1'), + locals: mockLocals + }); expect(result.sentDocuments).toEqual([]); expect(result.receivedDocuments).toEqual([]); @@ -100,7 +118,13 @@ describe('person detail load — error paths', () => { } as ReturnType); await expect( - load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals }) + load({ + params: { id: 'missing' }, + fetch: mockFetch, + request: new Request('http://localhost/persons/p1'), + url: new URL('http://localhost/persons/p1'), + locals: mockLocals + }) ).rejects.toMatchObject({ status: 404 }); @@ -120,7 +144,13 @@ describe('person detail load — error paths', () => { } as ReturnType); await expect( - load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals }) + load({ + params: { id: 'forbidden' }, + fetch: mockFetch, + request: new Request('http://localhost/persons/p1'), + url: new URL('http://localhost/persons/p1'), + locals: mockLocals + }) ).rejects.toMatchObject({ status: 403 });