447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
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<typeof docSummary>,
|
|
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<string, unknown> = {}) => ({
|
|
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';
|
|
});
|
|
});
|