Files
familienarchiv/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts
Marcel f2f9006548
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m32s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m39s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
test(person): add now-required precision fields to Person test fixtures
birthDatePrecision/deathDatePrecision are @Schema REQUIRED, so the
generated Person type makes them non-optional — fixtures that were
type-clean before the regen get UNKNOWN defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:28:09 +02:00

210 lines
8.0 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 GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
id,
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const,
birthDatePrecision: 'UNKNOWN' as const,
deathDatePrecision: 'UNKNOWN' as const,
familyMember: false,
provisional: false
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
afterEach(() => cleanup());
describe('GeschichteEditor — title-required guard', () => {
it('disables both DRAFT save buttons when the title is empty', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
expect(draft).toHaveProperty('disabled', true);
expect(publish).toHaveProperty('disabled', true);
});
it('shows the inline error after the title field is blurred while empty', async () => {
const onSubmit = vi.fn();
render(GeschichteEditor, { onSubmit });
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
// userEvent.tab() / keyboard('{Tab}') do not reliably fire the blur event on
// inputs inside Playwright's test iframe. .blur() is a no-op when the element
// has lost focus to TipTap's onMount initialisation. Dispatching the FocusEvent
// directly fires Svelte's onblur listener regardless of the current focus owner.
const input = await page.getByPlaceholder('Titel der Geschichte').element();
input.dispatchEvent(new FocusEvent('blur'));
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
});
});
describe('GeschichteEditor — onSubmit rejects on failure', () => {
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
// Contract: onSubmit rejects on failure. Without the catch in save(), this
// click would surface as an unhandled promise rejection and fail the run.
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Editor still functional — a second save attempt goes through
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
});
});
describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
await expect
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
});
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
onSubmit: vi.fn()
});
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
.toBeInTheDocument();
});
});
describe('GeschichteEditor — pre-fill', () => {
it('renders initial persons as chips', async () => {
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
onSubmit: vi.fn()
});
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
onSubmit: vi.fn()
});
const input = await page.getByPlaceholder('Titel der Geschichte').element();
expect((input as HTMLInputElement).value).toBe('My existing story');
});
});
describe('GeschichteEditor — onSubmit payload', () => {
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
// userEvent.fill() with trailing whitespace does not fire the input event chain
// that Svelte's bind:value requires (CDP limitation). Setting .value directly
// and dispatching an input event works around this while preserving the trailing
// space needed to verify the trim() contract.
const input = (await page
.getByPlaceholder('Titel der Geschichte')
.element()) as HTMLInputElement;
input.value = 'My title ';
input.dispatchEvent(new Event('input', { bubbles: true }));
// userEvent.click() via Playwright CDP does not reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component. Dispatching
// the MouseEvent directly from the browser JS context bypasses this issue.
const btn = (await page
.getByRole('button', { name: 'Entwurf speichern' })
.element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.title).toBe('My title');
expect(payload.status).toBe('DRAFT');
});
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
const btn = (await page
.getByRole('button', { name: 'Veröffentlichen' })
.element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes personIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
onSubmit
});
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
const btn = (await page
.getByRole('button', { name: 'Entwurf speichern' })
.element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
});
});
describe('GeschichteEditor — story document panel (#795)', () => {
it('shows the document panel with the story items when editing an existing story', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({
items: [
{
id: 'i1',
position: 10,
document: {
id: 'd1',
title: 'Brief von Eugenie',
datePrecision: 'DAY' as const,
receiverCount: 0
}
}
]
}),
onSubmit: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
.toBeInTheDocument();
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
});
it('hides the document panel when no geschichte is set (creation flow)', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});