Files
familienarchiv/frontend/src/lib/shared/discussion/mention.spec.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

351 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
detectMention,
escapeHtml,
extractContent,
renderBody,
renderTranscriptionBody
} from './mention';
import type { MentionDTO, PersonMention } from '$lib/shared/types';
// ─── escapeHtml ───────────────────────────────────────────────────────────────
describe('escapeHtml', () => {
it('escapes ampersand', () => {
expect(escapeHtml('AT&T')).toBe('AT&amp;T');
});
it('escapes less-than and greater-than', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('escapes double quote', () => {
expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;');
});
it('returns empty string unchanged', () => {
expect(escapeHtml('')).toBe('');
});
it('escapes ampersand before other entities to avoid double-encoding', () => {
expect(escapeHtml('a&<b')).toBe('a&amp;&lt;b');
});
it('escapes apostrophe to &#39;', () => {
expect(escapeHtml("d'Artagnan")).toBe('d&#39;Artagnan');
});
it('does not collapse already-encoded entities (re-escapes the &)', () => {
// escapeHtml is idempotent by composition: the second pass re-escapes
// the & that was added by the first. Pin the property so the helper
// can't be "cleverly" optimised to skip it.
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
});
});
// ─── detectMention ────────────────────────────────────────────────────────────
describe('detectMention', () => {
it('returns null when text has no @', () => {
expect(detectMention('hello world', 11)).toBeNull();
});
it('returns null when @ is not the most recent trigger word', () => {
// cursor is past a completed mention (next word started)
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
});
it('returns empty string immediately after @', () => {
expect(detectMention('hello @', 7)).toBe('');
});
it('returns query text after @', () => {
expect(detectMention('hello @Han', 10)).toBe('Han');
});
it('returns null when @ is preceded by a letter (email address pattern)', () => {
expect(detectMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectMention('@Hans', 5)).toBe('Hans');
});
it('returns null when cursor is before the @', () => {
expect(detectMention('@Hans', 0)).toBeNull();
});
});
// ─── extractContent ───────────────────────────────────────────────────────────
describe('extractContent', () => {
it('returns empty arrays for empty string', () => {
const result = extractContent('', []);
expect(result.content).toBe('');
expect(result.mentionedUserIds).toEqual([]);
});
it('returns plain content unchanged when no candidates', () => {
const result = extractContent('Hello world', []);
expect(result.content).toBe('Hello world');
expect(result.mentionedUserIds).toEqual([]);
});
it('extracts user id when @FirstName LastName is in content', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('Hey @Hans Müller how are you?', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('deduplicates user ids when same user mentioned twice', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
expect(result.mentionedUserIds).toHaveLength(1);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('collects multiple distinct users', () => {
const candidates: MentionDTO[] = [
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
];
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
expect(result.mentionedUserIds).toContain('uuid-2');
});
});
// ─── renderBody ───────────────────────────────────────────────────────────────
describe('renderBody', () => {
it('returns escaped plain text when no mentions', () => {
expect(renderBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in content', () => {
const result = renderBody('<script>alert(1)</script>', []);
expect(result).toContain('&lt;script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in content', () => {
const result = renderBody('AT&T', []);
expect(result).toContain('AT&amp;T');
});
it('wraps @mention in a mention span', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Hey @Hans Müller!', mentions);
expect(result).toContain('<span');
expect(result).toContain('class="mention"');
expect(result).toContain('Hans Müller');
});
it('does not double-encode already escaped text', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Check @Hans Müller', mentions);
expect(result).not.toContain('&amp;');
});
it('replaces all occurrences of the same mention', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
const spanCount = (result.match(/<span /g) ?? []).length;
expect(spanCount).toBe(2);
});
it('escapes HTML special chars in mention display names', () => {
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
const result = renderBody('@<script> alert(1)', mentions);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('converts newlines to <br>', () => {
const result = renderBody('line1\nline2', []);
expect(result).toContain('<br>');
expect(result).not.toContain('\n');
});
});
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
describe('renderTranscriptionBody', () => {
const auguste: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440000',
displayName: 'Auguste Raddatz'
};
const hans: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440001',
displayName: 'Hans'
};
it('returns empty string for empty input', () => {
expect(renderTranscriptionBody('', [])).toBe('');
});
it('returns escaped plain text when no mentions', () => {
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in plain block text', () => {
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
expect(result).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in plain block text', () => {
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&amp;T');
});
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
expect(result).toContain('class="person-mention"');
expect(result).toContain(`data-person-id="${auguste.personId}"`);
expect(result).toContain('>Auguste Raddatz</a>');
});
it('strips the @ prefix from rendered link text (read mode)', () => {
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
// The anchor body is the bare display name — no leading @
expect(result).not.toMatch(/>@Auguste Raddatz</);
expect(result).toMatch(/>Auguste Raddatz</);
});
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
// No bare @ remains where the mention was
expect(result).not.toMatch(/@<a/);
});
it('replaces all occurrences of the same mention', () => {
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('does not replace plain-text occurrences without the @ trigger', () => {
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
expect(result).not.toContain('<a ');
expect(result).toBe('Auguste Raddatz war hier');
});
it('processes longer displayNames first to avoid prefix shadowing', () => {
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
const LONG_ID = '22222222-2222-4222-8222-222222222222';
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
const augusteLong: PersonMention = {
personId: LONG_ID,
displayName: 'Auguste Raddatz'
};
// Sidecar order is short-first; longer match must still win for the long text
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
augusteShort,
augusteLong
]);
expect(result).toContain(`href="/persons/${LONG_ID}"`);
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
// The "Raddatz" suffix must not leak inside the short-name anchor
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
});
it('does not match @ followed by extra word characters (word boundary)', () => {
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
expect(result).not.toContain('<a ');
expect(result).toContain('@HansM');
});
it('first-sidecar-wins when two entries share the same displayName', () => {
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('escapes HTML in displayName to prevent stored XSS', () => {
const xss: PersonMention = {
personId: '55555555-5555-4555-8555-555555555555',
displayName: '<script>alert(1)</script>'
};
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('escapes <img onerror=...> payloads in surrounding block text', () => {
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not double-encode HTML-entity-already-encoded payloads', () => {
// `&amp;lt;script&amp;gt;` is already-escaped HTML in the source text.
// renderTranscriptionBody must escape the literal & once → `&amp;amp;lt;...`
// — never silently decode pre-escaped entities.
const result = renderTranscriptionBody('text &amp;lt;script&amp;gt;', []);
expect(result).toBe('text &amp;amp;lt;script&amp;amp;gt;');
});
it('escapes quotes in displayName so they cannot break the href attribute', () => {
const tricky: PersonMention = {
personId: '66666666-6666-4666-8666-666666666666',
displayName: 'O"Brien'
};
const result = renderTranscriptionBody('@O"Brien', [tricky]);
// The raw `"` from the displayName must never appear inside the rendered link
// — it would terminate the attribute value early and let an attacker craft
// arbitrary attributes on the anchor. It must arrive at the browser as &quot;.
expect(result).toMatch(/>O&quot;Brien<\/a>/);
expect(result).not.toMatch(/>O"Brien<\/a>/);
});
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
const result = renderTranscriptionBody('Plain old transcription text.', []);
expect(result).toBe('Plain old transcription text.');
});
it('skips substitution when personId is not a UUID (defense in depth)', () => {
// Nora #5551: if personId ever flowed in from a less-sanitised source
// (a future "external person" or a bad sidecar), the renderer must not
// emit a clickable link. The escaped text remains as plain content.
const evil: PersonMention = {
personId: 'javascript:alert(1)',
displayName: 'Evil Link'
};
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
expect(result).not.toContain('<a ');
expect(result).not.toContain('javascript:');
// The @-trigger and displayName are preserved as plain text
expect(result).toContain('@Evil Link');
});
it('skips substitution when personId is an absolute URL', () => {
const evil: PersonMention = {
personId: 'https://evil.example/persons/abc',
displayName: 'Phisher'
};
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
expect(result).not.toContain('<a ');
expect(result).not.toContain('https://evil.example');
});
it('still substitutes when personId is a well-formed UUID', () => {
// Sanity check that the validation does not over-reject valid IDs.
const valid: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440000',
displayName: 'Auguste Raddatz'
};
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
expect(result).toContain('<a ');
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
});
});