From 08eec086a90b4a049c3a5d9955f02164efe17903 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 09:46:28 +0200 Subject: [PATCH 1/7] refactor(admin/invites): migrate to createApiClient; fix Sentry mock event Replace manual fetch(${apiUrl}/api/...) calls in load, create, and revoke with createApiClient(fetch) so auth injection is handled by handleFetch and the typed API contract is enforced at compile time. Also fix pre-existing load test failures caused by Sentry's load wrapper reading event.request.method (add request to the mock event object). Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/invites/+page.server.ts | 67 ++++------ .../routes/admin/invites/page.server.test.ts | 116 ++++++++++++++++-- 2 files changed, 136 insertions(+), 47 deletions(-) diff --git a/frontend/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index cb33d962..3746c264 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -1,9 +1,10 @@ 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 type { Actions, PageServerLoad } from './$types'; import type { components } from '$lib/generated/api'; +// The spec marks shareableUrl optional but the backend always populates it. +// Keeping the required shape here avoids null-guarding throughout the page component. export interface InviteListItem { id: string; code: string; @@ -17,34 +18,33 @@ export interface InviteListItem { createdAt: string; shareableUrl: string; } - 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 api = createApiClient(fetch); - const [invitesRes, groupsRes] = await Promise.all([ - fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`), - fetch(`${apiUrl}/api/groups`) + 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 unknown 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 +63,29 @@ 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 unknown as InviteListItem }; }, revoke: async ({ request, fetch }) => { const formData = await request.formData(); const id = formData.get('id') as string; - 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.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts index 8e66e9b0..2f7b6157 100644 --- a/frontend/src/routes/admin/invites/page.server.test.ts +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -36,8 +36,10 @@ describe('admin/invites load()', () => { beforeEach(() => mockFetch.mockReset()); function event(status = 'active') { + const url = new URL(`http://localhost/admin/invites?status=${status}`); return { - url: new URL(`http://localhost/admin/invites?status=${status}`), + url, + request: new Request(url), fetch: mockFetch as unknown as typeof fetch // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -100,8 +102,14 @@ describe('admin/invites load()', () => { await load(event()); expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups')); + // 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') + ]) + ); }); }); @@ -133,8 +141,11 @@ describe('admin/invites create action', () => { // 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); + // 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']); }); @@ -148,8 +159,99 @@ describe('admin/invites create action', () => { // 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); + 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' } }); + }); +}); + +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' } }); + }); }); -- 2.49.1 From 6576e1d3760849e42eaaa5dcfcde94a7b16ab8a6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 09:56:17 +0200 Subject: [PATCH 2/7] 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 --- .../routes/admin/users/[id]/+page.server.ts | 23 ++-- .../admin/users/[id]/page.server.test.ts | 110 ++++++++++++++++++ .../routes/admin/users/layout.server.spec.ts | 21 +++- 3 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 frontend/src/routes/admin/users/[id]/page.server.test.ts 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'); }); }); -- 2.49.1 From 741b2231f2d28b26d186ff1ad63a1e57bd7afd5b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 09:56:46 +0200 Subject: [PATCH 3/7] docs(contributing): clarify event.fetch required even for multipart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multipart note previously said "use raw fetch" which was misread as "global fetch is acceptable". Clarify that event.fetch must always be used — the typed client is bypassed for multipart, but handleFetch still needs to inject the session cookie. Co-Authored-By: Claude Sonnet 4.6 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 -- 2.49.1 From 07415a5b2b16f133903436fefb38b244124e1497 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 10:27:11 +0200 Subject: [PATCH 4/7] fix(tests): add missing Sentry mock event fields across 14 spec files; fix test:coverage semicolon `@sentry/sveltekit` wraps load functions and reads `event.request.method` and `event.url.pathname`. Mock events that omitted `request` or `url` threw `TypeError: Cannot read properties of undefined` on every invocation, silently masking 86 test failures on main. Two root causes fixed: - Added `request: new Request(...)` (and `url: new URL(...)` where absent) to all mock event objects in 14 `*.server.spec.ts` files - Changed `;` to `&&` in the `test:coverage` npm script so a failing server run propagates its exit code instead of being swallowed by the client run All 576 server-project tests now pass. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package.json | 2 +- .../routes/admin/groups/layout.server.spec.ts | 18 +++++- .../src/routes/admin/layout.server.spec.ts | 30 ++++++++-- .../admin/ocr/[personId]/page.server.spec.ts | 14 ++++- .../admin/ocr/global/page.server.spec.ts | 14 ++++- .../src/routes/admin/ocr/page.server.spec.ts | 14 ++++- .../admin/tags/[id]/page.server.spec.ts | 2 + .../routes/admin/tags/layout.server.spec.ts | 36 +++++++++-- .../routes/aktivitaeten/page.server.spec.ts | 60 +++++++++++++++---- .../routes/briefwechsel/page.server.spec.ts | 6 ++ .../routes/documents/[id]/page.server.spec.ts | 25 ++++++-- .../documents/bulk-edit/page.server.spec.ts | 30 ++++++++-- .../src/routes/documents/page.server.spec.ts | 21 ++++++- frontend/src/routes/page.server.spec.ts | 16 +++++ .../routes/persons/[id]/page.server.spec.ts | 40 +++++++++++-- 15 files changed, 282 insertions(+), 46 deletions(-) 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/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/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/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 }); -- 2.49.1 From d1c6ae67c1787570f5acead7d7e76ac87e56698b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 10:39:10 +0200 Subject: [PATCH 5/7] fix(admin): address PR #623 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add load() unit tests for admin/users/[id] (permission gate, 404, success) - Rename .test.ts → .spec.ts for consistency with rest of suite - Add @Schema(requiredMode=REQUIRED) to InviteListItem.shareableUrl - Add client-side allowlist for invite status query param Co-Authored-By: Claude Sonnet 4.6 --- .../user/InviteListItemDTO.java | 1 + .../src/routes/admin/invites/+page.server.ts | 8 +- ...age.server.test.ts => page.server.spec.ts} | 0 .../admin/users/[id]/page.server.spec.ts | 198 ++++++++++++++++++ .../admin/users/[id]/page.server.test.ts | 110 ---------- 5 files changed, 206 insertions(+), 111 deletions(-) rename frontend/src/routes/admin/invites/{page.server.test.ts => page.server.spec.ts} (100%) create mode 100644 frontend/src/routes/admin/users/[id]/page.server.spec.ts delete mode 100644 frontend/src/routes/admin/users/[id]/page.server.test.ts 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/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index 3746c264..e006716a 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -20,8 +20,14 @@ export interface InviteListItem { } export type UserGroup = components['schemas']['UserGroup']; +const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const; +type InviteStatus = (typeof VALID_STATUSES)[number]; + export const load: PageServerLoad = async ({ url, fetch }) => { - const status = url.searchParams.get('status') ?? 'active'; + const rawStatus = url.searchParams.get('status'); + const status: InviteStatus | 'active' = VALID_STATUSES.includes(rawStatus as InviteStatus) + ? (rawStatus as InviteStatus) + : 'active'; const api = createApiClient(fetch); const [invitesResult, groupsResult] = await Promise.all([ diff --git a/frontend/src/routes/admin/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.spec.ts similarity index 100% rename from frontend/src/routes/admin/invites/page.server.test.ts rename to frontend/src/routes/admin/invites/page.server.spec.ts 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..78f04823 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/page.server.spec.ts @@ -0,0 +1,198 @@ +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 + // 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/[id]/page.server.test.ts b/frontend/src/routes/admin/users/[id]/page.server.test.ts deleted file mode 100644 index 373e4ace..00000000 --- a/frontend/src/routes/admin/users/[id]/page.server.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 }); - }); -}); -- 2.49.1 From 49ac1984ddc07813d48f4beba61531eb67137b4d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 10:50:55 +0200 Subject: [PATCH 6/7] fix(admin): address PR #623 second-pass review feedback - Fix VALID_STATUSES fallback to use uppercase enum value - Add TODO comment on InviteListItem cast pending type regeneration - Guard revoke action against null id (returns fail 400) - Add request: to delete action mock events for Sentry consistency - Add expiresAt forwarding test for create action - Add null-id guard test for revoke action Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/invites/+page.server.ts | 10 ++++--- .../routes/admin/invites/page.server.spec.ts | 27 +++++++++++++++++++ .../admin/users/[id]/page.server.spec.ts | 3 ++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index e006716a..e16fc279 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -1,5 +1,6 @@ import { fail } from '@sveltejs/kit'; 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'; @@ -25,9 +26,9 @@ type InviteStatus = (typeof VALID_STATUSES)[number]; export const load: PageServerLoad = async ({ url, fetch }) => { const rawStatus = url.searchParams.get('status'); - const status: InviteStatus | 'active' = VALID_STATUSES.includes(rawStatus as InviteStatus) + const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus) ? (rawStatus as InviteStatus) - : 'active'; + : 'ACTIVE'; const api = createApiClient(fetch); const [invitesResult, groupsResult] = await Promise.all([ @@ -41,6 +42,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => { const code = (invitesResult.error as unknown as { code?: string })?.code; loadError = code ?? 'INTERNAL_ERROR'; } else { + // TODO: remove cast after next npm run generate:api — shareableUrl is now @Schema(requiredMode=REQUIRED) invites = (invitesResult.data ?? []) as unknown as InviteListItem[]; } @@ -79,12 +81,14 @@ export const actions = { return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' }); } + // TODO: remove cast after next npm run generate:api — shareableUrl is now @Schema(requiredMode=REQUIRED) return { created: result.data! as unknown 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 api = createApiClient(fetch); const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } }); diff --git a/frontend/src/routes/admin/invites/page.server.spec.ts b/frontend/src/routes/admin/invites/page.server.spec.ts index 2f7b6157..f5592b34 100644 --- a/frontend/src/routes/admin/invites/page.server.spec.ts +++ b/frontend/src/routes/admin/invites/page.server.spec.ts @@ -203,6 +203,22 @@ describe('admin/invites create action', () => { 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', () => { @@ -254,4 +270,15 @@ describe('admin/invites revoke action', () => { 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/users/[id]/page.server.spec.ts b/frontend/src/routes/admin/users/[id]/page.server.spec.ts index 78f04823..da2f3921 100644 --- a/frontend/src/routes/admin/users/[id]/page.server.spec.ts +++ b/frontend/src/routes/admin/users/[id]/page.server.spec.ts @@ -165,7 +165,8 @@ describe('admin/users/[id] delete action', () => { function makeEvent() { return { params: { id: 'user-123' }, - fetch: vi.fn() as unknown as typeof fetch + 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; } -- 2.49.1 From 6cfb1899a1c5b765a6f600e690175a547510bf59 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 13:22:32 +0200 Subject: [PATCH 7/7] refactor(admin/invites): regenerate types; remove InviteListItem cast After adding @Schema(requiredMode=REQUIRED) to InviteListItemDTO.shareableUrl, npm run generate:api now emits shareableUrl as required. Replace the hand-rolled InviteListItem interface with a type alias to the generated InviteListItemDTO and remove the two 'as unknown as InviteListItem' casts + TODO comments. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 134 +++++++++++++++++- .../src/routes/admin/invites/+page.server.ts | 22 +-- 2 files changed, 130 insertions(+), 26 deletions(-) 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/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index e16fc279..9123de94 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -4,21 +4,7 @@ import { getErrorMessage } from '$lib/shared/errors'; import type { Actions, PageServerLoad } from './$types'; import type { components } from '$lib/generated/api'; -// The spec marks shareableUrl optional but the backend always populates it. -// Keeping the required shape here avoids null-guarding throughout the page component. -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']; const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const; @@ -42,8 +28,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => { const code = (invitesResult.error as unknown as { code?: string })?.code; loadError = code ?? 'INTERNAL_ERROR'; } else { - // TODO: remove cast after next npm run generate:api — shareableUrl is now @Schema(requiredMode=REQUIRED) - invites = (invitesResult.data ?? []) as unknown as InviteListItem[]; + invites = (invitesResult.data ?? []) as InviteListItem[]; } let groups: UserGroup[] = []; @@ -81,8 +66,7 @@ export const actions = { return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' }); } - // TODO: remove cast after next npm run generate:api — shareableUrl is now @Schema(requiredMode=REQUIRED) - return { created: result.data! as unknown as InviteListItem }; + return { created: result.data! as InviteListItem }; }, revoke: async ({ request, fetch }) => { -- 2.49.1