Files
gbrain/test/utils.test.ts
3381dd7658 fix(search): preserve email citation metadata across result paths (takeover of #2875)
Salvage of PR #2875 (which subsumes the base projection from #2873),
rebased onto current master with the release bookkeeping (VERSION /
package.json / CHANGELOG) dropped, plus one hot-path hardening fix.

Salvaged (verified on this head):
- project trusted message_id / thread_id / Message-ID-gated source_subject
  through keyword, chunk-keyword, CJK, and vector paths in BOTH engines
- preserve the citation DTO through alias injection, relational
  recall/fanout, two-pass hydration, vector fusion/reranking, and
  semantic-cache hits
- raw source_subject is never trusted; only allowlisted `subject` may
  supply it, and only when a nonblank Message-ID proves the page is an
  email; malformed/non-object frontmatter fails closed (no double-decode)
- source visibility / quarantine / deletion rechecked across indirect
  retrieval paths (alias hop, relational hydrate, two-pass expansion,
  graph walk, cache-hit gate)
- typed JSON cache scope keys (scalar/set/all) — injective encoding, no
  forged-key collisions; store-side write gate skips writeback when the
  page-generation clock advanced during the producing search
- KNOBS_HASH_VERSION 12 -> 13 so pre-projection cached DTOs miss instead
  of replaying the old shape

Fixed on top of the original head (the flagged hot-path defect):
- cacheScopeKey's forged-id rejection was evaluated inline at the cache
  lookup/store call sites inside hybridSearchCached, outside any catch —
  an invalid scope id broke the whole search instead of skipping the
  cache. The key is now computed once, fail-open: invalid scope =>
  cache skipped, search unaffected. Pinned by
  test/search/hybrid-cache-scope-failopen.serial.test.ts (fails on the
  original head, passes here).

Verified on this exact head: typecheck clean; 379 touched unit tests,
3 serial files (one process each), pglite cache-gate/source-isolation
e2e, and real-PostgreSQL engine-parity (26 pass) + source-routing —
all green. jsonb-pattern/params, key-files-current-state,
test-isolation, progress-to-stdout guards clean.

Closes #2962

Co-authored-by: amtagrwl <amtagrwl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:07:56 -07:00

235 lines
8.6 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { validateSlug, contentHash, parseEmbedding, tryParseEmbedding, rowToPage, rowToChunk, rowToSearchResult } from '../src/core/utils.ts';
describe('validateSlug', () => {
test('accepts valid slugs', () => {
expect(validateSlug('people/sarah-chen')).toBe('people/sarah-chen');
expect(validateSlug('concepts/rag')).toBe('concepts/rag');
expect(validateSlug('simple')).toBe('simple');
});
test('normalizes to lowercase', () => {
expect(validateSlug('People/Sarah-Chen')).toBe('people/sarah-chen');
expect(validateSlug('UPPER')).toBe('upper');
});
test('rejects empty slug', () => {
expect(() => validateSlug('')).toThrow('Invalid slug');
});
test('rejects path traversal', () => {
expect(() => validateSlug('../etc/passwd')).toThrow('path traversal');
expect(() => validateSlug('test/../hack')).toThrow('path traversal');
});
test('rejects leading slash', () => {
expect(() => validateSlug('/absolute/path')).toThrow('start with /');
});
});
describe('contentHash', () => {
test('returns deterministic hash', () => {
const page = { title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'world' };
const h1 = contentHash(page);
const h2 = contentHash(page);
expect(h1).toBe(h2);
});
test('changes when content changes', () => {
const h1 = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'world' });
const h2 = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'changed' });
expect(h1).not.toBe(h2);
});
test('returns hex string', () => {
const h = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'test', timeline: '' });
expect(h).toMatch(/^[a-f0-9]{64}$/);
});
});
describe('rowToPage', () => {
test('rejects encoded-string frontmatter instead of double-decoding it', () => {
const page = rowToPage({
id: 1, slug: 'test', type: 'concept', title: 'Test',
compiled_truth: 'body', timeline: '',
frontmatter: '{"key":"val"}',
content_hash: 'abc', created_at: '2024-01-01', updated_at: '2024-01-01',
});
expect(page.frontmatter).toEqual({});
});
test('preserves object-shaped JSONB frontmatter', () => {
const page = rowToPage({
id: 1, slug: 'test', type: 'concept', title: 'Test',
compiled_truth: 'body', timeline: '',
frontmatter: { key: 'val' },
content_hash: 'abc', created_at: '2024-01-01', updated_at: '2024-01-01',
});
expect(page.frontmatter.key).toBe('val');
});
test('handles object frontmatter', () => {
const page = rowToPage({
id: 1, slug: 'test', type: 'concept', title: 'Test',
compiled_truth: 'body', timeline: '',
frontmatter: { key: 'val' },
content_hash: 'abc', created_at: '2024-01-01', updated_at: '2024-01-01',
});
expect(page.frontmatter.key).toBe('val');
});
test('creates Date objects', () => {
const page = rowToPage({
id: 1, slug: 'test', type: 'concept', title: 'Test',
compiled_truth: '', timeline: '', frontmatter: '{}',
content_hash: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
});
expect(page.created_at).toBeInstanceOf(Date);
expect(page.updated_at).toBeInstanceOf(Date);
});
});
describe('rowToChunk', () => {
test('nulls embedding by default', () => {
const chunk = rowToChunk({
id: 1, page_id: 1, chunk_index: 0, chunk_text: 'text',
chunk_source: 'compiled_truth', embedding: new Float32Array(10),
model: 'test', token_count: 5, embedded_at: '2024-01-01',
});
expect(chunk.embedding).toBeNull();
});
test('includes embedding when requested', () => {
const emb = new Float32Array(10).fill(0.5);
const chunk = rowToChunk({
id: 1, page_id: 1, chunk_index: 0, chunk_text: 'text',
chunk_source: 'compiled_truth', embedding: emb,
model: 'test', token_count: 5, embedded_at: '2024-01-01',
}, true);
expect(chunk.embedding).not.toBeNull();
});
test('parses pgvector string embeddings when requested', () => {
const chunk = rowToChunk({
id: 1, page_id: 1, chunk_index: 0, chunk_text: 'text',
chunk_source: 'compiled_truth', embedding: '[0.1, 0.2, 0.3]',
model: 'test', token_count: 5, embedded_at: '2024-01-01',
}, true);
expect(chunk.embedding).toBeInstanceOf(Float32Array);
expect(Array.from(chunk.embedding || [])).toHaveLength(3);
expect(chunk.embedding?.[0]).toBeCloseTo(0.1, 6);
expect(chunk.embedding?.[1]).toBeCloseTo(0.2, 6);
expect(chunk.embedding?.[2]).toBeCloseTo(0.3, 6);
});
});
describe('parseEmbedding', () => {
test('returns Float32Array unchanged', () => {
const emb = new Float32Array([0.1, 0.2]);
expect(parseEmbedding(emb)).toBe(emb);
});
test('parses pgvector text into Float32Array', () => {
const parsed = parseEmbedding('[0.1, 0.2, 0.3]');
expect(parsed).toBeInstanceOf(Float32Array);
expect(Array.from(parsed || [])).toHaveLength(3);
expect(parsed?.[0]).toBeCloseTo(0.1, 6);
expect(parsed?.[1]).toBeCloseTo(0.2, 6);
expect(parsed?.[2]).toBeCloseTo(0.3, 6);
});
test('returns null for unsupported embedding values', () => {
expect(parseEmbedding(null)).toBeNull();
expect(parseEmbedding(undefined)).toBeNull();
expect(parseEmbedding('not-a-vector')).toBeNull();
});
test('parses numeric array into Float32Array', () => {
const parsed = parseEmbedding([0.5, 0.25, 0.125]);
expect(parsed).toBeInstanceOf(Float32Array);
expect(parsed?.[0]).toBeCloseTo(0.5, 6);
});
test('throws on vector-like string with non-numeric content (no silent NaN)', () => {
expect(() => parseEmbedding('[abc, def]')).toThrow();
expect(() => parseEmbedding('[1, NaN, 3]')).toThrow();
});
});
describe('tryParseEmbedding', () => {
test('returns null on corrupt embedding instead of throwing', () => {
expect(tryParseEmbedding('[0.1,NaN,0.3]')).toBeNull();
expect(tryParseEmbedding(['bad' as unknown as number, 1])).toBeNull();
});
test('delegates happy path to parseEmbedding', () => {
const out = tryParseEmbedding('[0.1, 0.2]');
expect(out).toBeInstanceOf(Float32Array);
expect(out?.length).toBe(2);
});
test('warns once per session on corrupt rows', () => {
const orig = console.warn;
let warnCount = 0;
console.warn = () => { warnCount++; };
try {
tryParseEmbedding('[NaN]');
tryParseEmbedding('[NaN]');
tryParseEmbedding('[NaN]');
} finally {
console.warn = orig;
}
expect(warnCount).toBeLessThanOrEqual(1);
});
});
describe('rowToSearchResult', () => {
test('coerces score to number', () => {
const r = rowToSearchResult({
slug: 'test', page_id: 1, title: 'Test', type: 'concept',
chunk_text: 'text', chunk_source: 'compiled_truth',
score: '0.95', stale: false,
});
expect(typeof r.score).toBe('number');
expect(r.score).toBe(0.95);
});
test('projects allowlisted email identifiers when present', () => {
const r = rowToSearchResult({
slug: 'mail/example', page_id: 2, title: 'Example email', type: 'note',
chunk_text: 'text', chunk_source: 'compiled_truth', chunk_id: 3, chunk_index: 0,
score: 0.9, stale: false,
message_id: '<message@example.com>', thread_id: 'abc123',
source_subject: 'Example launch subject',
});
expect(r.message_id).toBe('<message@example.com>');
expect(r.thread_id).toBe('abc123');
expect(r.source_subject).toBe('Example launch subject');
});
test('does not invent email identifiers when projections are absent', () => {
const r = rowToSearchResult({
slug: 'concept/example', page_id: 3, title: 'Example', type: 'concept',
chunk_text: 'text', chunk_source: 'compiled_truth', chunk_id: 4, chunk_index: 0,
score: 0.8, stale: false,
source_subject: 'Generated title must not become an email subject',
});
expect(r.message_id).toBeUndefined();
expect(r.thread_id).toBeUndefined();
expect(r.source_subject).toBeUndefined();
});
test('whitespace-only message_id does not project or authorize source_subject', () => {
const r = rowToSearchResult({
slug: 'mail/whitespace-id', page_id: 4, title: 'Whitespace ID', type: 'note',
chunk_text: 'text', chunk_source: 'compiled_truth', chunk_id: 5, chunk_index: 0,
score: 0.7, stale: false,
message_id: ' \t\n ', thread_id: 'thread-whitespace',
source_subject: 'Must remain gated',
});
expect(r.message_id).toBeUndefined();
expect(r.thread_id).toBe('thread-whitespace');
expect(r.source_subject).toBeUndefined();
});
});