feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s

This commit was merged in pull request #787.
This commit is contained in:
2026-06-12 14:04:02 +02:00
parent 4bcf568ed4
commit b33d0eb850
142 changed files with 11643 additions and 917 deletions

View File

@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): 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', () => {
it('renders one chip per person in personFilters', async () => {
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;
expect(url).toContain('personId=b');
expect(url).not.toContain('personId=a');
@@ -91,6 +105,19 @@ describe('geschichten page — multi-person filter chips', () => {
window.history.replaceState({}, '', originalHref);
});
it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => {
render(Page, {
data: makeData({
geschichten: [
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
] as PageData['geschichten']
})
});
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge).not.toBeNull();
});
it('shows the "+ Person wählen" button even when filters are already active', async () => {
render(Page, {
data: makeData({
@@ -100,6 +127,207 @@ describe('geschichten page — multi-person filter chips', () => {
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');
});
});
it('removing a person chip preserves an active document filter in the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const chipBtn = (await page
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
.element()) as HTMLElement;
chipBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('personId=a');
expect(url).toContain('documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
window.history.replaceState({}, '', '/');
});
it('clearAll removes both person and document filters from the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const allBtn = (await page.getByRole('button', { name: 'Alle' }).element()) as HTMLElement;
allBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('personId');
expect(url).not.toContain('documentId');
window.history.replaceState({}, '', '/');
});
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 () => {
render(Page, {
data: makeData({