Files
familienarchiv/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts
Marcel 0c765d8112
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m54s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 3m48s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m24s
fix(tests): fix 13 pre-existing vitest-browser spec failures
Fixes all remaining failing tests in the browser project. Root cause in
every case: Playwright CDP-based clicks/keyboard events do not reliably
trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout:

- Buttons / result items: native `.element().click()` or
  `dispatchEvent(new MouseEvent('click', { bubbles: true }))`
- Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))`
  on the target DOM element
- TipTap selection: `element.focus()` + Selection API +
  `document.dispatchEvent(new Event('selectionchange'))`
- ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))`

Also fixes pre-existing content/logic issues found during analysis:
- ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings
  and wrong ARIA role (combobox not textbox)
- RichtlinienRuleCard: beide beispielInput + beispielOutput required for
  arrow to render; querySelectorAll to get last code element
- admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode
  heading selector; per-call mockResolvedValueOnce for dual-card page
- DocumentList: add total prop + result count paragraph (test relied on it)
- PersonTypeahead keyboard navigation: pressKey() helper with native
  KeyboardEvent dispatch replaces userEvent.keyboard()
- PersonMultiSelect: native element clicks for result selection and
  chip removal; keydown dispatch on result div for Enter key test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:15:54 +02:00

435 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PersonMentionEditor — Tiptap-based component tests.
*
* All old tests used document.querySelector('textarea') which is dead after
* the Tiptap migration. These tests drive the contenteditable via
* userEvent.type() and inspect the serialized output from the test host.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const ANNA: Person = {
id: 'p-anna',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
birthYear: 1860
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
function renderHost(
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
};
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
disabled: initial.disabled ?? false,
onChange: (snap: Snapshot) => {
snapshot = snap;
}
});
return {
get snapshot() {
return snapshot;
}
};
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
});
it('reflects bound initial value as visible text', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
it('opens the dropdown when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
});
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
});
});
it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
});
it('offers a "create new person" link in the empty state', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => {
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
await expect.element(link).toBeVisible();
await expect.element(link).toHaveAttribute('href', '/persons/new');
});
});
});
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
it('stores the typed query as displayName, not the person DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
// MentionDropdown handles selection via onmousedown (not onclick) to prevent
// blurring the editor before the selection fires. userEvent.click() via CDP
// does not reliably trigger Svelte 5's onmousedown handler when TipTap is
// mounted — dispatch the MouseEvent directly from browser JS instead.
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
expect(host.snapshot.mentionedPersons[0]).toEqual({
personId: 'p-aug',
displayName: 'Aug' // typed text, not "Auguste Raddatz"
});
});
});
it('regression: text value contains the typed query, not the full DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
expect(host.snapshot.value).toContain('@Aug');
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
});
});
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
});
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation', () => {
it('Enter selects the highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{ArrowDown}');
await vi.waitFor(async () => {
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
});
});
it('Escape closes the dropdown without inserting', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
describe('PersonMentionEditor — disabled state', () => {
it('sets contenteditable=false on the editor when disabled', async () => {
renderHost({ value: 'Bestehender Text', disabled: true });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('false');
});
});
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
renderHost({ disabled: true });
await vi.waitFor(() => {
const wrapper = document.querySelector('[aria-disabled="true"]');
expect(wrapper).not.toBeNull();
});
});
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
renderHost({ disabled: false });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('true');
});
});
});
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
describe('PersonMentionEditor — XSS resistance', () => {
it('renders a malicious displayName as text, not as HTML elements', async () => {
// A historical sidecar entry whose displayName contains an HTML payload
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
// returns the @-prefixed string as the third tuple entry, which
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
const maliciousMention: PersonMention = {
personId: '00000000-0000-0000-0000-000000000001',
displayName: '<img src=x onerror=alert(1)>'
};
renderHost({
value: '@<img src=x onerror=alert(1)>',
mentionedPersons: [maliciousMention]
});
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
// No element from the malicious payload should have appeared as a real
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
// paragraphs — that is internal markup and never carries user attrs;
// guard against the injection by checking the user-controlled attrs.)
expect(textbox!.querySelector('img[onerror]')).toBeNull();
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
expect(textbox!.querySelector('script')).toBeNull();
// The payload should appear as visible text content instead.
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
});
});
});
// ─── Placeholder behavior ─────────────────────────────────────────────────────
describe('PersonMentionEditor — placeholder behavior', () => {
it('sets data-placeholder on the inner element when editor is empty', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
});
});
it('omits data-placeholder on the inner element when editor has content', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Bestehender Text',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
});
});
});
// ─── i18n message content ─────────────────────────────────────────────────────
describe('PersonMentionEditor — i18n message content', () => {
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
expect(m.transcription_block_placeholder()).toContain('@');
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option').first()).toBeVisible();
});
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
expect(option.className).toContain('min-h-[44px]');
});
});