/** * Tests for components/semantic_search.js * * Focus on the pure data-shaping helpers exposed via window.SemanticSearch: * - buildTieredResults: 3-tier merge with dedup + sort * - isSafeExternalUrl: URL scheme validation (security-critical) * * createSemanticResultCard and renderSnippet do DOM/markdown work and depend * on optional libraries (marked, DOMPurify) — exercised via integration in * library-search tests already. */ import '@js/components/semantic_search.js'; const SS = window.SemanticSearch; describe('SemanticSearch.buildTieredResults', () => { it('returns empty tiers for empty inputs', () => { const r = SS.buildTieredResults([], []); expect(r.tier1).toEqual([]); expect(r.tier2).toEqual([]); expect(r.tier3).toEqual([]); }); it('puts text-only matches in tier2 (preserving original order)', () => { const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; const r = SS.buildTieredResults(text, []); expect(r.tier1).toEqual([]); expect(r.tier3).toEqual([]); expect(r.tier2.map(x => x.historyItem.id)).toEqual(['a', 'b', 'c']); }); it('puts semantic-only matches in tier3', () => { const sem = [ { research_id: 'x', similarity: 0.5 }, { research_id: 'y', similarity: 0.8 }, ]; const r = SS.buildTieredResults([], sem); expect(r.tier1).toEqual([]); expect(r.tier2).toEqual([]); expect(r.tier3).toHaveLength(2); }); it('places items appearing in both tiers in tier1 with semanticMatch populated', () => { const text = [{ id: 'a' }, { id: 'b' }]; const sem = [ { research_id: 'a', similarity: 0.7, snippet: 'hi' }, { research_id: 'c', similarity: 0.9 }, ]; const r = SS.buildTieredResults(text, sem); expect(r.tier1).toHaveLength(1); expect(r.tier1[0].historyItem.id).toBe('a'); expect(r.tier1[0].semanticMatch.similarity).toBe(0.7); expect(r.tier1[0].semanticMatch.snippet).toBe('hi'); // 'b' was text-only → tier2 expect(r.tier2.map(x => x.historyItem.id)).toEqual(['b']); // 'c' was semantic-only → tier3 expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['c']); }); it('sorts tier1 by similarity DESC', () => { const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; const sem = [ { research_id: 'a', similarity: 0.3 }, { research_id: 'b', similarity: 0.9 }, { research_id: 'c', similarity: 0.6 }, ]; const r = SS.buildTieredResults(text, sem); expect(r.tier1.map(x => x.historyItem.id)).toEqual(['b', 'c', 'a']); }); it('sorts tier3 by similarity DESC', () => { const sem = [ { research_id: 'a', similarity: 0.2 }, { research_id: 'b', similarity: 0.95 }, { research_id: 'c', similarity: 0.5 }, ]; const r = SS.buildTieredResults([], sem); expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['b', 'c', 'a']); }); it('dedups semantic results by ID, keeping the highest similarity', () => { const sem = [ { research_id: 'dup', similarity: 0.3 }, { research_id: 'dup', similarity: 0.8 }, { research_id: 'dup', similarity: 0.5 }, ]; const r = SS.buildTieredResults([], sem); expect(r.tier3).toHaveLength(1); expect(r.tier3[0].semanticResult.similarity).toBe(0.8); }); it('skips semantic results missing the configured ID key', () => { const sem = [ { research_id: 'a', similarity: 0.5 }, { similarity: 0.9 }, // missing research_id { research_id: '', similarity: 0.7 }, // empty falsy ]; const r = SS.buildTieredResults([], sem); expect(r.tier3).toHaveLength(1); expect(r.tier3[0].semanticResult.research_id).toBe('a'); }); it('honors custom textIdKey and semanticIdKey', () => { const text = [{ doc_id: 'x' }]; const sem = [{ id: 'x', similarity: 0.5 }]; const r = SS.buildTieredResults(text, sem, { textIdKey: 'doc_id', semanticIdKey: 'id', }); expect(r.tier1).toHaveLength(1); expect(r.tier1[0].historyItem.doc_id).toBe('x'); }); it('uses default keys when options is undefined', () => { const text = [{ id: 'k' }]; const sem = [{ research_id: 'k', similarity: 0.5 }]; const r = SS.buildTieredResults(text, sem); expect(r.tier1).toHaveLength(1); }); it('coerces IDs to strings for matching (number vs string)', () => { const text = [{ id: 42 }]; const sem = [{ research_id: '42', similarity: 0.5 }]; const r = SS.buildTieredResults(text, sem); expect(r.tier1).toHaveLength(1); }); it('defaults snippet to empty string when missing', () => { const text = [{ id: 'a' }]; const sem = [{ research_id: 'a', similarity: 0.5 }]; const r = SS.buildTieredResults(text, sem); expect(r.tier1[0].semanticMatch.snippet).toBe(''); }); }); describe('SemanticSearch.isSafeExternalUrl', () => { let savedValidator; beforeEach(() => { // Force the fallback path so we test the inline scheme list, // not URLValidator's behavior (covered by url-validator tests). savedValidator = window.URLValidator; delete window.URLValidator; }); afterEach(() => { if (savedValidator !== undefined) window.URLValidator = savedValidator; }); it('accepts http and https URLs', () => { expect(SS.isSafeExternalUrl('http://example.com')).toBe(true); expect(SS.isSafeExternalUrl('https://example.com/path?q=1')).toBe(true); }); it('rejects javascript: URLs', () => { expect(SS.isSafeExternalUrl('javascript:alert(1)')).toBe(false); }); it('rejects data: URLs', () => { expect(SS.isSafeExternalUrl('data:text/html,')).toBe(false); }); it('rejects vbscript: URLs', () => { expect(SS.isSafeExternalUrl('vbscript:msgbox(1)')).toBe(false); }); it('rejects about:, blob:, file: schemes', () => { expect(SS.isSafeExternalUrl('about:blank')).toBe(false); expect(SS.isSafeExternalUrl('blob:https://example.com/abc')).toBe(false); expect(SS.isSafeExternalUrl('file:///etc/passwd')).toBe(false); }); it('rejects non-string input', () => { expect(SS.isSafeExternalUrl(null)).toBe(false); expect(SS.isSafeExternalUrl(undefined)).toBe(false); expect(SS.isSafeExternalUrl(42)).toBe(false); expect(SS.isSafeExternalUrl({})).toBe(false); }); it('rejects empty string', () => { expect(SS.isSafeExternalUrl('')).toBe(false); }); it('is case-insensitive against scheme obfuscation', () => { expect(SS.isSafeExternalUrl('JaVaScRiPt:alert(1)')).toBe(false); }); it('rejects relative URLs (no scheme)', () => { expect(SS.isSafeExternalUrl('/relative/path')).toBe(false); expect(SS.isSafeExternalUrl('example.com')).toBe(false); }); it('delegates to URLValidator when present', () => { window.URLValidator = { isSafeUrl: vi.fn().mockReturnValue(true), }; expect(SS.isSafeExternalUrl('https://example.com')).toBe(true); expect(window.URLValidator.isSafeUrl).toHaveBeenCalledWith( 'https://example.com', { allowMailto: false } ); }); });