import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import { m } from '$lib/paraglide/messages.js'; import StoryDocumentPanel from './StoryDocumentPanel.svelte'; const docSummary = (id: string, title: string) => ({ id, title, datePrecision: 'DAY' as const, receiverCount: 0 }); const makeItem = ( id: string, position: number, document?: ReturnType, note?: string ) => ({ id, position, document, note }); /** DocumentListItem fixture as returned by the picker search endpoint. */ const makeSearchResultItem = (id: string, title: string) => ({ id, title, documentDate: '1880-01-01', metaDatePrecision: 'DAY', originalFilename: 'brief.pdf', receivers: [], tags: [], completionPercentage: 0, contributors: [], matchData: { titleOffsets: [], senderMatched: false, matchedReceiverIds: [], matchedTagIds: [], snippetOffsets: [], summaryOffsets: [] }, status: 'UPLOADED', metadataComplete: false, scriptType: 'UNKNOWN', createdAt: '2024-01-01T00:00:00', updatedAt: '2024-01-01T00:00:00' }); type MutationResponse = { ok: boolean; status?: number; body?: object }; /** * Routes the picker's GET search to `searchItems` and every mutation * (POST/DELETE) to `mutation` — the panel talks to both endpoints. */ function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const method = (init?.method ?? 'GET').toUpperCase(); if (method === 'GET') { return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: searchItems }) }); } return Promise.resolve({ ok: mutation.ok, status: mutation.status ?? (mutation.ok ? 200 : 500), json: () => Promise.resolve(mutation.body ?? {}) }); }); vi.stubGlobal('fetch', fetchMock); return fetchMock; } const defaultProps = (overrides: Record = {}) => ({ geschichteId: 'g1', items: [], ...overrides }); async function addViaPicker(title: RegExp) { await userEvent.fill(page.getByRole('combobox'), 'Brief'); await expect.element(page.getByText(title)).toBeInTheDocument(); await userEvent.click(page.getByText(title)); } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe('StoryDocumentPanel — rendering', () => { it('renders linked documents sorted by position', async () => { render( StoryDocumentPanel, defaultProps({ items: [ makeItem('i3', 30, docSummary('d3', 'Dritter Brief')), makeItem('i1', 10, docSummary('d1', 'Erster Brief')) ] }) ); const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); expect(rows[0]).toContain('Erster Brief'); expect(rows[1]).toContain('Dritter Brief'); }); it('shows the empty state when no items are linked', async () => { render(StoryDocumentPanel, defaultProps()); await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); }); it('renders a deleted-document item as placeholder row that is still removable', async () => { render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] })); await expect .element(page.getByText(m.geschichte_documents_deleted_placeholder())) .toBeInTheDocument(); await expect .element( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: m.geschichte_documents_deleted_placeholder() }) }) ) .toBeInTheDocument(); }); it('wires a visible label to the picker input', async () => { render(StoryDocumentPanel, defaultProps()); const input = page.getByRole('combobox').element() as HTMLInputElement; const label = document.querySelector(`label[for="${input.id}"]`); expect(label?.textContent).toContain(m.geschichte_documents_picker_label()); }); }); describe('StoryDocumentPanel — add', () => { it('POSTs to the items endpoint and appends the created item', async () => { const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: true, body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); expect(post?.[0]).toBe('/api/geschichten/g1/items'); expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' }); const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); }); it('marks an already-linked document as not selectable in the dropdown', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await expect.element(page.getByRole('option')).toBeInTheDocument(); const option = document.querySelector('[role="listbox"] [role="option"]'); expect(option?.getAttribute('aria-disabled')).toBe('true'); }); it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: false, status: 409, body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' } }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); await expect .element(page.getByRole('alert')) .toHaveTextContent(m.geschichte_documents_duplicate()); }); it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: false, status: 409, body: { code: 'JOURNEY_AT_CAPACITY' } }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); await expect .element(page.getByRole('alert')) .toHaveTextContent(m.geschichte_documents_capacity()); }); it('announces a successful add via the polite live region', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: true, body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); const liveRegion = document.querySelector('[aria-live="polite"]'); expect(liveRegion?.textContent).toBe( m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' }) ); }); it('routes a 403 response through getErrorMessage on POST', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: false, status: 403, body: { code: 'FORBIDDEN' } }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); await expect.element(page.getByRole('alert')).toBeInTheDocument(); const alertText = page.getByRole('alert').element().textContent ?? ''; expect(alertText).not.toBe(''); expect(alertText).not.toContain('FORBIDDEN'); }); it('shows the generic reload message when POST throws a network error', async () => { stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]); vi.stubGlobal( 'fetch', vi.fn((input: RequestInfo | URL, init?: RequestInit) => { if ((init?.method ?? 'GET').toUpperCase() === 'GET') { return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] }) }); } return Promise.reject(new Error('Network error')); }) ); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); await expect .element(page.getByRole('alert')) .toHaveTextContent(m.journey_mutation_error_reload()); }); it('attaches X-XSRF-TOKEN header from cookie on POST', async () => { document.cookie = 'XSRF-TOKEN=test-csrf-token'; const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { ok: true, body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) }); render(StoryDocumentPanel, defaultProps()); await addViaPicker(/Brief von Eugenie/i); const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); const headers = post?.[1]?.headers as Headers; expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token'); document.cookie = 'XSRF-TOKEN=; Max-Age=0'; }); }); describe('StoryDocumentPanel — remove', () => { it('DELETEs the item endpoint and removes the row', async () => { const fetchMock = stubFetch([], { ok: true, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); expect(del?.[0]).toBe('/api/geschichten/g1/items/i1'); expect(document.querySelectorAll('li').length).toBe(0); await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); }); it('restores the row and shows an error when the DELETE fails', async () => { stubFetch([], { ok: false, status: 500, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); await expect.element(page.getByRole('alert')).toBeInTheDocument(); const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); }); it('moves focus to the previous row remove button instead of dropping to body', async () => { stubFetch([], { ok: true, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [ makeItem('i1', 10, docSummary('d1', 'Erster Brief')), makeItem('i2', 20, docSummary('d2', 'Zweiter Brief')) ] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' }) }) ); expect(document.activeElement).not.toBe(document.body); expect(document.activeElement?.getAttribute('aria-label')).toBe( m.geschichte_documents_remove_label({ title: 'Erster Brief' }) ); }); it('moves focus to the picker input when the last item is removed', async () => { stubFetch([], { ok: true, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); const input = page.getByRole('combobox').element(); expect(document.activeElement).toBe(input); }); it('announces a successful remove via the polite live region', async () => { stubFetch([], { ok: true, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); const liveRegion = document.querySelector('[aria-live="polite"]'); expect(liveRegion?.textContent).toBe( m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' }) ); }); it('returns focus to the item remove button when DELETE fails with !res.ok', async () => { stubFetch([], { ok: false, status: 500, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); expect(document.activeElement?.getAttribute('aria-label')).toBe( m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) ); }); it('returns focus to the item remove button when DELETE throws a network error', async () => { vi.stubGlobal( 'fetch', vi.fn(() => Promise.reject(new Error('Network error'))) ); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); expect(document.activeElement?.getAttribute('aria-label')).toBe( m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) ); }); it('shows the generic reload message when DELETE throws a network error', async () => { vi.stubGlobal( 'fetch', vi.fn(() => Promise.reject(new Error('Network error'))) ); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); await expect .element(page.getByRole('alert')) .toHaveTextContent(m.journey_mutation_error_reload()); }); it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => { document.cookie = 'XSRF-TOKEN=test-csrf-token'; const fetchMock = stubFetch([], { ok: true, body: {} }); render( StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) ); await userEvent.click( page.getByRole('button', { name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) }) ); const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); const headers = del?.[1]?.headers as Headers; expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token'); document.cookie = 'XSRF-TOKEN=; Max-Age=0'; }); });