feat(geschichten): resolve document title in loader, return documentFilter object

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 09:05:53 +02:00
parent 5a87e4d655
commit ba2481aef5
2 changed files with 209 additions and 4 deletions

View File

@@ -6,21 +6,28 @@ import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person']; 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 }) => { export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch); const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId'); 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', { api.GET('/api/geschichten', {
params: { params: {
query: { query: {
status: 'PUBLISHED', status: 'PUBLISHED',
personId: personIds.length ? personIds : undefined, 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 } } })) ...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) .filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[]; .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 { return {
geschichten: listResult.data ?? [], geschichten: listResult.data ?? [],
personFilters, personFilters,
documentFilter: documentId ?? null documentFilter
}; };
}; };

View File

@@ -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<string, string | string[]> = {}) {
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<string, unknown> | 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] })
})
})
);
});
});