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:
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
185
frontend/src/routes/geschichten/page.server.test.ts
Normal file
185
frontend/src/routes/geschichten/page.server.test.ts
Normal 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] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user