For issue #380 (AC-2, AC-3, AC-4 + NFR debounce). The search input is now the single fetch trigger. The dropdown's searchQuery reactivity calls onSearch on every change — whether sourced from the editor mirror or the user's own input. PersonMentionEditor debounces these calls at 150 ms, short-circuits on empty queries (no fetch, items cleared), and tears down pending timers on destroy. The Tiptap suggestion plugin's items() now returns [] — per-keystroke fetches in the editor are gone. The same /api/persons?q= endpoint is used; the difference is in when and how often the request fires. Adds a cancel() method to the debounce utility so destroyed editors don't leave trailing fetches alive (which previously polluted the test ledger and would have wasted bandwidth in production tab-close races). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
/**
|
||
* 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-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('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'));
|
||
|
||
// Wait beyond the debounce window to confirm no fetch was scheduled.
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
|
||
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
|
||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── 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: '<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]');
|
||
});
|
||
});
|