Files
familienarchiv/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Marcel bd5e6e6fbe
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
fix(test): narrow getByText selector to dropdown-only item in aria-disabled test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:38:22 +02:00

397 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import JourneyEditor from './JourneyEditor.svelte';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const
});
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Briefe der Familie Raddatz',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichte: makeGeschichte(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitting: false,
...overrides
});
function mockCsrfFetch(responseFactory: () => object) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(responseFactory())
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyEditor — empty state', () => {
it('renders title input and intro textarea', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
});
it('publish button disabled when no items', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
});
it('shows empty state message when items list is empty', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeInTheDocument();
});
});
describe('JourneyEditor — items in position order', () => {
it('renders items sorted by position', async () => {
const items = [
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
];
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe('JourneyEditor — publish disabled when title empty', () => {
it('publish stays disabled until title is non-empty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
title: '',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
const titleInput = page.getByPlaceholder(/Titel/);
await userEvent.fill(titleInput, 'Meine Reise');
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
mockCsrfFetch(() => newItem);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{
id: 'd1',
title: 'Brief von Karl',
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'
}
]
})
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350)); // wait debounce
await userEvent.click(page.getByText(/Brief von Karl/));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
describe('JourneyEditor — remove with rollback', () => {
it('restores item on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
await new Promise((r) => setTimeout(r, 50));
// Item should be restored after rollback
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
// Publish should be disabled before adding item
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
await new Promise((r) => setTimeout(r, 50));
// After item add, publish becomes enabled — item was added and state is correct
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
});
describe('JourneyEditor — reorder via move buttons', () => {
it('move-up calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
it('move-down calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
describe('JourneyEditor — live announce region', () => {
it('clears the live announce region 500ms after a move operation', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch
const liveRegion = document.querySelector('[aria-live="polite"]');
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer
expect((liveRegion?.textContent ?? '').trim()).toBe('');
});
});
describe('JourneyEditor — note patch body', () => {
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi
.fn()
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await new Promise((r) => setTimeout(r, 50));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
});
});
describe('JourneyEditor — duplicate document aria-disabled', () => {
it('already-added document appears as aria-disabled in picker', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{
id: 'd1',
title: 'Brief von Karl',
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'
}
]
})
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350));
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
const option = page
.getByText(/Brief von Karl ·/)
.element()
.closest('li')!;
expect(option.getAttribute('aria-disabled')).toBe('true');
});
});