Compare commits
4 Commits
main
...
b2b3eb0b1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2b3eb0b1c | ||
|
|
4d8b8f15ad | ||
|
|
ba2481aef5 | ||
|
|
5a87e4d655 |
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -13,6 +13,9 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Leftover directory from branch work
|
||||||
|
/src.main/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ bun.lockb
|
|||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
/src.main/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
|
|||||||
@@ -1033,6 +1033,9 @@
|
|||||||
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
|
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
|
||||||
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
|
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
|
||||||
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
||||||
|
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
|
||||||
|
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
|
||||||
|
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
|
||||||
"geschichten_back_to_index": "Zurück zu Geschichten",
|
"geschichten_back_to_index": "Zurück zu Geschichten",
|
||||||
"geschichten_published_on": "veröffentlicht am {date}",
|
"geschichten_published_on": "veröffentlicht am {date}",
|
||||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||||
|
|||||||
@@ -1033,6 +1033,9 @@
|
|||||||
"geschichten_empty_for_person": "No stories found for {name}.",
|
"geschichten_empty_for_person": "No stories found for {name}.",
|
||||||
"geschichten_empty_for_persons": "No stories found for {names}.",
|
"geschichten_empty_for_persons": "No stories found for {names}.",
|
||||||
"geschichten_empty_no_filter": "There are no published stories yet.",
|
"geschichten_empty_no_filter": "There are no published stories yet.",
|
||||||
|
"geschichten_filter_document_chip": "Filtered by letter:",
|
||||||
|
"geschichten_filter_remove_document_chip": "Remove letter {title} from filter",
|
||||||
|
"geschichten_empty_for_document": "No stories reference this letter yet",
|
||||||
"geschichten_back_to_index": "Back to stories",
|
"geschichten_back_to_index": "Back to stories",
|
||||||
"geschichten_published_on": "published on {date}",
|
"geschichten_published_on": "published on {date}",
|
||||||
"geschichten_persons_section": "People in this story",
|
"geschichten_persons_section": "People in this story",
|
||||||
|
|||||||
@@ -1033,6 +1033,9 @@
|
|||||||
"geschichten_empty_for_person": "No hay historias para {name}.",
|
"geschichten_empty_for_person": "No hay historias para {name}.",
|
||||||
"geschichten_empty_for_persons": "No hay historias para {names}.",
|
"geschichten_empty_for_persons": "No hay historias para {names}.",
|
||||||
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
||||||
|
"geschichten_filter_document_chip": "Filtrado por carta:",
|
||||||
|
"geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro",
|
||||||
|
"geschichten_empty_for_document": "Aún no hay historias sobre esta carta",
|
||||||
"geschichten_back_to_index": "Volver a Historias",
|
"geschichten_back_to_index": "Volver a Historias",
|
||||||
"geschichten_published_on": "publicada el {date}",
|
"geschichten_published_on": "publicada el {date}",
|
||||||
"geschichten_persons_section": "Personas en esta historia",
|
"geschichten_persons_section": "Personas en esta historia",
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||||
|
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -11,7 +12,19 @@ let { data }: { data: PageData } = $props();
|
|||||||
let showPersonPicker = $state(false);
|
let showPersonPicker = $state(false);
|
||||||
|
|
||||||
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
||||||
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
|
const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null);
|
||||||
|
|
||||||
|
const emptyMessage = $derived.by(() => {
|
||||||
|
if (data.personFilters.length > 0) {
|
||||||
|
return m.geschichten_empty_for_persons({
|
||||||
|
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.documentFilter) {
|
||||||
|
return m.geschichten_empty_for_document();
|
||||||
|
}
|
||||||
|
return m.geschichten_empty_no_filter();
|
||||||
|
});
|
||||||
|
|
||||||
function rebuildUrl(personIds: string[]) {
|
function rebuildUrl(personIds: string[]) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -38,6 +51,10 @@ function removePerson(personId: string) {
|
|||||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeDocument() {
|
||||||
|
goto(rebuildUrl(selectedPersonIds));
|
||||||
|
}
|
||||||
|
|
||||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||||
const a = g.author;
|
const a = g.author;
|
||||||
if (!a) return '';
|
if (!a) return '';
|
||||||
@@ -88,6 +105,14 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if data.documentFilter}
|
||||||
|
<DocumentFilterChip
|
||||||
|
id={data.documentFilter.id}
|
||||||
|
title={data.documentFilter.title}
|
||||||
|
onremove={removeDocument}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={showPersonPicker}
|
aria-expanded={showPersonPicker}
|
||||||
@@ -118,13 +143,7 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
|||||||
<!-- Card list -->
|
<!-- Card list -->
|
||||||
{#if data.geschichten.length === 0}
|
{#if data.geschichten.length === 0}
|
||||||
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
||||||
{#if data.personFilters.length > 0}
|
{emptyMessage}
|
||||||
{m.geschichten_empty_for_persons({
|
|
||||||
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
|
||||||
})}
|
|
||||||
{:else}
|
|
||||||
{m.geschichten_empty_no_filter()}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-4">
|
<ul class="flex flex-col gap-4">
|
||||||
|
|||||||
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
onremove
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
onremove: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const chipLabel = $derived(title ?? id.slice(0, 8));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 text-primary-fg"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs tracking-wider whitespace-nowrap uppercase">
|
||||||
|
{m.geschichten_filter_document_chip()}
|
||||||
|
</span>
|
||||||
|
<span class="line-clamp-2 font-serif italic sm:max-w-[16rem] sm:truncate" title={chipLabel}>
|
||||||
|
{chipLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onremove}
|
||||||
|
aria-label={m.geschichten_filter_remove_document_chip({ title: chipLabel })}
|
||||||
|
class="ml-0.5 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
|
||||||
|
describe('DocumentFilterChip', () => {
|
||||||
|
it('renders the resolved document title inside the chip', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: {
|
||||||
|
id: VALID_UUID,
|
||||||
|
title: 'Brief an Oma',
|
||||||
|
onremove: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the prefix label', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to short UUID when title is null', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: null, onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/11111111/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onremove when the remove button is clicked', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove button aria-label references the resolved title', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ });
|
||||||
|
await expect.element(btn).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title= attribute equals the validated id, not a raw query string', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const chip = document.querySelector('[title]');
|
||||||
|
expect(chip?.getAttribute('title')).toBe('Brief an Oma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove button has a minimum 44px touch target', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/);
|
||||||
|
});
|
||||||
|
});
|
||||||
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] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): PageData {
|
|||||||
} as unknown as PageData;
|
} as unknown as PageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||||
|
title: 'Brief an Oma',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('geschichten page — multi-person filter chips', () => {
|
describe('geschichten page — multi-person filter chips', () => {
|
||||||
it('renders one chip per person in personFilters', async () => {
|
it('renders one chip per person in personFilters', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
@@ -81,9 +92,12 @@ describe('geschichten page — multi-person filter chips', () => {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click();
|
const chipBtn = (await page
|
||||||
|
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
chipBtn.click();
|
||||||
|
|
||||||
expect(goto).toHaveBeenCalledOnce();
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||||
expect(url).toContain('personId=b');
|
expect(url).toContain('personId=b');
|
||||||
expect(url).not.toContain('personId=a');
|
expect(url).not.toContain('personId=a');
|
||||||
@@ -100,6 +114,149 @@ describe('geschichten page — multi-person filter chips', () => {
|
|||||||
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
|
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('document filter chip', () => {
|
||||||
|
it('renders the document chip when documentFilter is set', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the document chip when documentFilter is null', async () => {
|
||||||
|
render(Page, { data: makeData() });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the document chip remove button navigates without documentId', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
'',
|
||||||
|
'/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
|
);
|
||||||
|
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
removeBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||||
|
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||||
|
expect(url).not.toContain('documentId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('document chip removal preserves active person filters', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
'',
|
||||||
|
'/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
|
);
|
||||||
|
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('p1', 'Anna A')] as PageData['personFilters'],
|
||||||
|
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
removeBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||||
|
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('personId=p1');
|
||||||
|
expect(url).not.toContain('documentId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the "All" pill as unpressed when document filter is active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Alle' }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty state precedence', () => {
|
||||||
|
it('shows geschichten_empty_for_document when only document filter is active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
geschichten: [],
|
||||||
|
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows geschichten_empty_for_persons when only person filter is active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
geschichten: [],
|
||||||
|
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows geschichten_empty_no_filter when no filter is active', async () => {
|
||||||
|
render(Page, { data: makeData({ geschichten: [] }) });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.'))
|
||||||
|
.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('person-wins: shows persons message when both person and document filters are active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
geschichten: [],
|
||||||
|
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||||||
|
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chip renders alongside results (empty state not shown when results exist)', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
geschichten: [
|
||||||
|
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
|
||||||
|
] as PageData['geschichten'],
|
||||||
|
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders all filter pills with a 44px touch target (h-11)', async () => {
|
it('renders all filter pills with a 44px touch target (h-11)', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: makeData({
|
data: makeData({
|
||||||
|
|||||||
Reference in New Issue
Block a user