From ba2481aef52eb44bfc59fceeded96fc2099f291b Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 09:05:53 +0200 Subject: [PATCH] feat(geschichten): resolve document title in loader, return documentFilter object Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/geschichten/+page.server.ts | 28 ++- .../routes/geschichten/page.server.test.ts | 185 ++++++++++++++++++ 2 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 frontend/src/routes/geschichten/page.server.test.ts diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 6d802e8e..88be909a 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -6,21 +6,28 @@ import type { PageServerLoad } from './$types'; type Person = components['schemas']['Person']; +const isUuid = (s: string) => + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(s); + export const load: PageServerLoad = async ({ url, fetch }) => { const api = createApiClient(fetch); const personIds = url.searchParams.getAll('personId'); - const documentId = url.searchParams.get('documentId') ?? undefined; + const rawDocumentId = url.searchParams.get('documentId'); + const documentId = rawDocumentId && isUuid(rawDocumentId) ? rawDocumentId : null; - const [listResult, ...personResults] = await Promise.all([ + const [listResult, docResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', personId: personIds.length ? personIds : undefined, - documentId + documentId: rawDocumentId ?? undefined } } }), + documentId + ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) + : Promise.resolve(null), ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); @@ -32,9 +39,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => { .filter((r) => r && r.response.ok && r.data) .map((r) => r!.data!) as Person[]; + let documentFilter: { id: string; title: string | null } | null = null; + if (documentId) { + if (docResult && docResult.response.ok && docResult.data) { + const doc = docResult.data; + documentFilter = { + id: documentId, + title: doc.title || doc.originalFilename || null + }; + } else { + documentFilter = { id: documentId, title: null }; + } + } + return { geschichten: listResult.data ?? [], personFilters, - documentFilter: documentId ?? null + documentFilter }; }; diff --git a/frontend/src/routes/geschichten/page.server.test.ts b/frontend/src/routes/geschichten/page.server.test.ts new file mode 100644 index 00000000..6149c08f --- /dev/null +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +const VALID_UUID = '11111111-2222-3333-4444-555555555555'; + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/geschichten'); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + value.forEach((v) => url.searchParams.append(key, v)); + } else { + url.searchParams.set(key, value); + } + } + return url; +} + +function callLoad(url: URL) { + return load({ + url, + request: new Request('http://localhost/geschichten'), + fetch: vi.fn() as unknown as typeof fetch + }); +} + +function mockApi( + opts: { + listData?: unknown[]; + docOk?: boolean; + docData?: Record | null; + } = {} +) { + const { + listData = [], + docOk = true, + docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } + } = opts; + + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ + response: { ok: docOk, status: docOk ? 200 : 404 }, + data: docOk ? docData : undefined + }); + } + return Promise.resolve({ + response: { ok: true, status: 200 }, + data: listData + }); + }); + + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + return mockGet; +} + +describe('geschichten page load — documentFilter title resolution', () => { + it('resolves document title when documentId is a valid UUID and document exists', async () => { + mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' }); + }); + + it('falls back to originalFilename when document title is null', async () => { + mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' }); + }); + + it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => { + // Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject + mockApi({ docOk: false }); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('treats 403 identically to 404 — no oracle, loader still resolves', async () => { + // Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch. + // If it did, this assertion would fail with a rejection instead of a resolution. + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined }); + } + return Promise.resolve({ response: { ok: true, status: 200 }, data: [] }); + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('list still populates when title fetch returns 404 (independent results)', async () => { + mockApi({ + listData: [{ id: 'g1', title: 'Some Story' }], + docOk: false + }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.geschichten).toHaveLength(1); + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null }); + }); + + it('returns null documentFilter when documentId is syntactically invalid', async () => { + mockApi(); + + const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(result.documentFilter).toBeNull(); + }); + + it('does not fetch document title when documentId is invalid', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything()); + }); + + it('returns null documentFilter when documentId is absent', async () => { + mockApi(); + + const result = await callLoad(makeUrl()); + + expect(result.documentFilter).toBeNull(); + }); + + it('passes valid documentId to the geschichten API', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(mockGet).toHaveBeenCalledWith( + '/api/geschichten', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ documentId: VALID_UUID }) + }) + }) + ); + }); + + it('passes invalid documentId to the list API without stripping (option B)', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten'); + expect(listCall?.[1]?.params?.query?.documentId).toBe('not-a-uuid'); + }); + + it('keeps forwarding personId filters alongside documentId', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] })); + + expect(mockGet).toHaveBeenCalledWith( + '/api/geschichten', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] }) + }) + }) + ); + }); +});