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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|