/** * 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']; // Mirror of the debounce in PersonMentionEditor.svelte. Naming the magic and // using a generous slack (SEARCH_DEBOUNCE_MS + 350 = 500 ms) kills CI-jitter // flakiness Sara raised on PR #629. const SEARCH_DEBOUNCE_MS = 150; const POST_DEBOUNCE_SLACK_MS = 350; const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', lastName: 'Raddatz', displayName: 'Auguste Raddatz', personType: 'PERSON', familyMember: false, birthYear: 1882, deathYear: 1944 }; const ANNA: Person = { id: 'p-anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt', personType: 'PERSON', familyMember: false, birthYear: 1860 }; 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('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', 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('limit=5')); }); }); 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-2/3: search input drives the person fetch (debounced) ─────────────── describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { it('editing the search input fires a debounced fetch with the new query', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown so the search input is reachable. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); const fetchesBeforeSearch = fetchMock.mock.calls.length; // `fill` simulates a single input event with the final value — sidesteps // per-keystroke timing of userEvent.type so the test can deterministically // assert that one input event collapses into one debounced fetch. await page.getByRole('searchbox').fill('Walter'); await vi.waitFor( () => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter')); }, { timeout: 1000 } ); const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch; expect(fetchesAfterSearch).toBe(1); }); it('fires exactly one /api/persons fetch when the user searches for Walter (debounced)', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown first so the search input is reachable. `fill` then // drives the searchbox in one input event — sidesteps per-keystroke // debounce timing on CI that Sara flagged on PR #629 round 2. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); const fetchesBeforeSearch = fetchMock.mock.calls.length; await page.getByRole('searchbox').fill('Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const personsFetches = fetchMock.mock.calls .slice(fetchesBeforeSearch) .filter(([url]) => typeof url === 'string' && url.startsWith('/api/persons')); expect(personsFetches.length).toBe(1); }); it('clearing the search input clears the list without firing a fetch', 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(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); const fetchesBeforeClear = fetchMock.mock.calls.length; await userEvent.clear(page.getByRole('searchbox')); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); // ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ─────────────── describe('PersonMentionEditor — whitespace-only query', () => { it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@ '); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); await expect.element(page.getByText(m.person_mention_search_prompt())).toBeInTheDocument(); expect(fetchMock).not.toHaveBeenCalled(); }); }); // ─── Stale-response race (Sara on PR #629) ─────────────────────────────────── describe('PersonMentionEditor — stale-response race', () => { it('discards a stale response that resolves after the search has been cleared', async () => { let resolveFetch!: (v: { ok: boolean; json: () => Promise }) => void; const pendingResponse = new Promise<{ ok: boolean; json: () => Promise }>((r) => { resolveFetch = r; }); const fetchMock = vi.fn().mockReturnValue(pendingResponse); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown and let the debounce fire so a fetch is in flight. await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); }); // Clear the search input *before* the fetch resolves. await userEvent.clear(page.getByRole('searchbox')); await expect.element(page.getByRole('searchbox')).toHaveValue(''); // The stale fetch now resolves with persons. The dropdown must stay empty. resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) }); await new Promise((r) => setTimeout(r, 50)); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); // ─── onExit cancels pending debounce (Felix #1 on PR #629) ─────────────────── describe('PersonMentionEditor — onExit cancels pending debounce', () => { it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown by typing @ + a query in the editor. await userEvent.type(page.getByRole('textbox'), '@A'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); // Wait for any in-flight fetch from opening the dropdown to settle. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const fetchesBeforeEscape = fetchMock.mock.calls.length; // Trigger a new debounced search (queues runSearch after 150 ms), then // immediately Escape *while focus is back in the editor* so Tiptap's // suggestion-plugin Escape handler fires onExit before the debounce. // Without onExit cancelling the pending debounce, runSearch executes // against the now-unmounted dropdown's state. await page.getByRole('searchbox').fill('Walter'); // Focus the editor so the Escape lands on Tiptap's suggestion handler. (page.getByRole('textbox').element() as HTMLElement).focus(); await userEvent.keyboard('{Escape}'); // Wait past the debounce window. If onExit did not cancel the pending // debounce, a fetch with q=Walter would still fire here. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter( ([url]) => typeof url === 'string' && url.includes('q=Walter') ); expect(walterFetches.length).toBe(0); }); }); // ─── AC-1: search input prefilled with text typed after @ ─────────────────── describe('PersonMentionEditor — AC-1: search input prefill', () => { it('prefills the dropdown search input with the text typed after @', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@WdG'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); }); }); }); // ─── 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: '' }; renderHost({ value: '@', 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 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(''); }); }); }); // ─── 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]'); }); });