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
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:
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
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 JourneyEditor from './JourneyEditor.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
});
|
||||
|
||||
/** 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'
|
||||
});
|
||||
|
||||
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('labels the title input and intro textarea for screen readers', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state message when items list is empty', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByText(m.journey_empty_state())).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 surface', () => {
|
||||
it('publish button disabled when no items', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a visible hint while publishing is disabled', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('adding an item enables the publish button (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(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
// After item add, publish becomes enabled — item was added and state is correct
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
title: ' Meine Reise ',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
status: 'PUBLISHED',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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') };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
// picker search results
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// POST /items
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(newItem)
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
// dropdown option appears after the typeahead debounce
|
||||
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||
await userEvent.click(page.getByText(/Brief von Karl ·/));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
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(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: 'Reise nach Wien' })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves keyboard focus into the new row after the interlude is added', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — mutation error code routing', () => {
|
||||
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(page.getByText(m.error_journey_document_already_added()))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — remove with pending state', () => {
|
||||
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
let resolveFetch!: (value: unknown) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
// Row still present, marked as pending (text appears in the row AND the live region,
|
||||
// so scope the query to the row instead of using a page-wide locator)
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
const row = document.querySelector('[data-block-id="i1"]');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.textContent).toContain(m.journey_item_pending_remove());
|
||||
expect(row!.className).toContain('opacity-60');
|
||||
});
|
||||
|
||||
resolveFetch({ ok: true });
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the row and shows an error alert 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: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the row on successful DELETE', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses a sensible target after a successful remove (not body)', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 vi.waitFor(() => {
|
||||
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 vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the server-confirmed order after a successful reorder', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
expect(
|
||||
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', 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: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('restores the original DOM order and shows an alert when the reorder request rejects', 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().mockRejectedValue(new TypeError('network down')));
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — live announce region', () => {
|
||||
it('announces the move only after the reorder resolved, then clears', 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/ }));
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
await vi.waitFor(() => {
|
||||
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe('');
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('announces the error text instead of a success message when the move fails', 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: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
await vi.waitFor(() => {
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 vi.waitFor(() => {
|
||||
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: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
|
||||
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
|
||||
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||
const option = page
|
||||
.getByText(/Brief von Karl ·/)
|
||||
.element()
|
||||
.closest('li')!;
|
||||
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — unsaved warning banner', () => {
|
||||
function triggerNavigationAttempt() {
|
||||
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||
if (calls.length === 0) return;
|
||||
const [callback] = calls[calls.length - 1];
|
||||
const cancel = vi.fn();
|
||||
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||
cancel,
|
||||
to: { url: new URL('http://localhost/geschichten') }
|
||||
});
|
||||
return cancel;
|
||||
}
|
||||
|
||||
it('banner is absent before any edit or navigation attempt', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||
});
|
||||
|
||||
it('banner appears when dirty and a navigation is attempted', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
// Mark dirty by editing the title
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Simulate the user trying to navigate away
|
||||
const cancel = triggerNavigationAttempt();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
title: 'Titel',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Mark dirty
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Trigger navigation → banner appears
|
||||
triggerNavigationAttempt();
|
||||
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||
|
||||
// Attempt save — onSubmit throws
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||
|
||||
// Banner must still be visible (isDirty was not cleared)
|
||||
await vi.waitFor(() => {
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
|
||||
// Regression guard for clearOnSuccess(): without it, a curator who edits the
|
||||
// title and saves successfully stays trapped — the page goto() gets cancelled
|
||||
// by the still-armed guard and the banner appears after a *successful* save.
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyEditor, defaultProps({ onSubmit }));
|
||||
|
||||
// Mark dirty
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Dirty state blocks navigation
|
||||
expect(triggerNavigationAttempt()).toHaveBeenCalled();
|
||||
|
||||
// Save succeeds
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
|
||||
// Guard is disarmed again — navigation passes and no banner shows
|
||||
await vi.waitFor(() => {
|
||||
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||
});
|
||||
|
||||
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
|
||||
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
// The new interlude row renders its note textarea once the POST resolved
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
// The item was persisted by its own POST — navigating away loses nothing
|
||||
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — selectedPersons marks dirty', () => {
|
||||
function getNavCallback() {
|
||||
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||
const [callback] = calls[calls.length - 1];
|
||||
return (cancel = vi.fn()) => {
|
||||
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||
cancel,
|
||||
to: { url: new URL('http://localhost/geschichten') }
|
||||
});
|
||||
return cancel;
|
||||
};
|
||||
}
|
||||
|
||||
it('removing a person chip marks the editor dirty', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Confirm navigation is NOT blocked initially (clean state)
|
||||
const triggerNav = getNavCallback();
|
||||
expect(triggerNav()).not.toHaveBeenCalled();
|
||||
|
||||
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
|
||||
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
|
||||
|
||||
// After person removal, navigation should be blocked
|
||||
await vi.waitFor(() => {
|
||||
const cancel = triggerNav();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — person chips from GeschichteView', () => {
|
||||
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user