Spaces:
Runtime error
Runtime error
| import { describe, test, expect, beforeEach, afterEach } from "vitest"; | |
| import Database from "better-sqlite3"; | |
| import { retrieveMemories, estimateTokens } from "../retrieval"; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Existing tests (pure-logic, no DB required) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Test that corrupt metadata in retrieval doesn't throw (returns {} instead). | |
| * This validates the fix for the 500 error caused by JSON.parse on corrupt metadata. | |
| */ | |
| describe("Memory Retrieval - corrupt metadata handling", () => { | |
| test("corrupt metadata JSON does not throw, returns empty object", () => { | |
| // Simulate what retrieval.ts line 74 does: JSON.parse(String(metadata)) | |
| // The fix should wrap this in try/catch and return {} on failure. | |
| const corruptValues = ["{invalid json", "not-json-at-all", "{{{}}}", "", "undefined"]; | |
| for (const corrupt of corruptValues) { | |
| // This simulates the fixed parsing logic | |
| const result = (() => { | |
| try { | |
| return JSON.parse(String(corrupt)); | |
| } catch { | |
| return {}; | |
| } | |
| })(); | |
| expect(result).toEqual({}); | |
| } | |
| }); | |
| }); | |
| /** | |
| * Test that GET /api/memory response includes stats object with total field. | |
| */ | |
| describe("Memory API - response shape", () => { | |
| test("GET /api/memory response should include stats with total field", async () => { | |
| // We test the response shape by importing the route handler | |
| // Since the route depends on DB, we test the stats computation logic directly | |
| const memories = [ | |
| { type: "factual", content: "test1" }, | |
| { type: "factual", content: "test2" }, | |
| { type: "procedural", content: "test3" }, | |
| ]; | |
| const stats = { | |
| total: memories.length, | |
| byType: memories.reduce( | |
| (acc, m) => { | |
| acc[m.type] = (acc[m.type] || 0) + 1; | |
| return acc; | |
| }, | |
| {} as Record<string, number> | |
| ), | |
| }; | |
| expect(stats).toHaveProperty("total"); | |
| expect(stats.total).toBe(3); | |
| expect(stats).toHaveProperty("byType"); | |
| expect(stats.byType).toEqual({ factual: 2, procedural: 1 }); | |
| }); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // FTS5-specific tests (real in-memory SQLite DB) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const API_KEY_ID = "test-api-key-fts5"; | |
| /** | |
| * Helper: create the `memories` table + `memory_fts` FTS5 virtual table | |
| * with the same DDL used in the real migrations (015 + 022). | |
| */ | |
| function setupSchema(db: InstanceType<typeof Database>) { | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS memories ( | |
| id INTEGER PRIMARY KEY, | |
| api_key_id TEXT NOT NULL, | |
| session_id TEXT, | |
| type TEXT NOT NULL CHECK(type IN ('factual','episodic','procedural','semantic')), | |
| key TEXT, | |
| content TEXT NOT NULL, | |
| metadata TEXT, | |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| expires_at TEXT | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id); | |
| CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); | |
| `); | |
| } | |
| function setupFts(db: InstanceType<typeof Database>) { | |
| db.exec(` | |
| CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( | |
| content, | |
| key, | |
| content='memories', | |
| content_rowid='id' | |
| ); | |
| CREATE TRIGGER IF NOT EXISTS memory_fts_ai AFTER INSERT ON memories BEGIN | |
| INSERT INTO memory_fts(rowid, content, key) VALUES (new.id, new.content, new.key); | |
| END; | |
| CREATE TRIGGER IF NOT EXISTS memory_fts_ad AFTER DELETE ON memories BEGIN | |
| INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.id, old.content, old.key); | |
| END; | |
| CREATE TRIGGER IF NOT EXISTS memory_fts_au AFTER UPDATE ON memories BEGIN | |
| INSERT INTO memory_fts(memory_fts, rowid, content, key) VALUES('delete', old.id, old.content, old.key); | |
| INSERT INTO memory_fts(rowid, content, key) VALUES (new.id, new.content, new.key); | |
| END; | |
| `); | |
| } | |
| /** Insert a memory row with an auto-incremented INTEGER id (FTS5-compatible). */ | |
| function insertMemory( | |
| db: InstanceType<typeof Database>, | |
| opts: { | |
| apiKeyId?: string; | |
| sessionId?: string; | |
| type?: string; | |
| key?: string; | |
| content: string; | |
| metadata?: string; | |
| createdAt?: string; | |
| } | |
| ) { | |
| const now = opts.createdAt ?? new Date().toISOString(); | |
| db.prepare( | |
| `INSERT INTO memories (api_key_id, session_id, type, key, content, metadata, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?)` | |
| ).run( | |
| opts.apiKeyId ?? API_KEY_ID, | |
| opts.sessionId ?? null, | |
| opts.type ?? "factual", | |
| opts.key ?? "", | |
| opts.content, | |
| opts.metadata ?? "{}", | |
| now, | |
| now | |
| ); | |
| } | |
| describe("Memory Retrieval β FTS5 integration", () => { | |
| let db: InstanceType<typeof Database>; | |
| let savedDb: unknown; | |
| beforeEach(() => { | |
| // Capture whatever DB singleton existed before the test | |
| savedDb = (globalThis as any).__omnirouteDb; | |
| // Stand up an in-memory SQLite DB and inject it as the singleton | |
| db = new Database(":memory:"); | |
| db.pragma("journal_mode = WAL"); | |
| setupSchema(db); | |
| setupFts(db); | |
| (globalThis as any).__omnirouteDb = db; | |
| }); | |
| afterEach(() => { | |
| // Restore the previous singleton (or remove it) | |
| if (savedDb) { | |
| (globalThis as any).__omnirouteDb = savedDb; | |
| } else { | |
| delete (globalThis as any).__omnirouteDb; | |
| } | |
| try { | |
| db.close(); | |
| } catch { | |
| // already closed | |
| } | |
| }); | |
| // ββ 1. Ranked results β FTS5 returns results ordered by relevance ββ | |
| test("semantic strategy returns FTS5-ranked results with most relevant first", async () => { | |
| // Insert memories with varying relevance to the query "TypeScript" | |
| insertMemory(db, { | |
| content: "Python is a popular programming language for data science.", | |
| key: "python-info", | |
| }); | |
| insertMemory(db, { | |
| content: "TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.", | |
| key: "typescript-overview", | |
| }); | |
| insertMemory(db, { | |
| content: | |
| "The TypeScript compiler (tsc) performs type checking and emits JavaScript. The TypeScript compiler is fast and TypeScript is great.", | |
| key: "typescript-compiler", | |
| }); | |
| // Use single-token query so FTS5 MATCH finds all memories containing "TypeScript" | |
| const results = await retrieveMemories(API_KEY_ID, { | |
| query: "TypeScript", | |
| retrievalStrategy: "semantic", | |
| maxTokens: 8000, | |
| }); | |
| // Should return at least the two TypeScript memories (Python one won't match) | |
| expect(results.length).toBeGreaterThanOrEqual(2); | |
| // The memory mentioning "TypeScript" more times should rank higher via BM25 | |
| const topContent = results[0].content; | |
| expect(topContent).toContain("TypeScript compiler"); | |
| }); | |
| // ββ 2. Hybrid strategy β combines FTS5 + recency/keyword signals ββ | |
| test("hybrid strategy merges FTS5 results with keyword results without duplicates", async () => { | |
| // Memory that matches FTS5 query | |
| insertMemory(db, { | |
| content: "Kubernetes orchestrates containerized applications in a cluster.", | |
| key: "kubernetes", | |
| }); | |
| // Memory that won't match FTS5 but contains the keyword in metadata/key | |
| insertMemory(db, { | |
| content: "Container deployment best practices for production systems.", | |
| key: "container-deploy", | |
| }); | |
| // Unrelated memory | |
| insertMemory(db, { | |
| content: "Baking a sourdough loaf requires patience and a good starter.", | |
| key: "baking", | |
| }); | |
| const results = await retrieveMemories(API_KEY_ID, { | |
| query: "Kubernetes", | |
| retrievalStrategy: "hybrid", | |
| maxTokens: 8000, | |
| }); | |
| // Should return the Kubernetes memory (FTS5 match) | |
| expect(results.some((m) => m.content.includes("Kubernetes"))).toBe(true); | |
| // Verify no duplicates (unique ids) | |
| const ids = results.map((m) => m.id); | |
| expect(new Set(ids).size).toBe(ids.length); | |
| }); | |
| // ββ 3. Graceful fallback β FTS5 table missing β falls back to LIKE / chronological ββ | |
| test("semantic strategy does not throw when memory_fts table is missing", async () => { | |
| // Seed data first (while FTS5 table still exists) | |
| insertMemory(db, { | |
| content: "React hooks simplify stateful logic in function components.", | |
| key: "react-hooks", | |
| }); | |
| insertMemory(db, { | |
| content: "Vue 3 composition API provides flexible component composition.", | |
| key: "vue-composition", | |
| }); | |
| // Now drop the FTS5 virtual table to simulate it being absent | |
| db.exec("DROP TABLE IF EXISTS memory_fts"); | |
| // Should NOT throw β falls back to chronological retrieval | |
| const results = await retrieveMemories(API_KEY_ID, { | |
| query: "React", | |
| retrievalStrategy: "semantic", | |
| maxTokens: 8000, | |
| }); | |
| // With FTS5 gone, the code falls back to chronological ORDER BY. | |
| // The query filter still applies via getRelevanceScore post-scoring, | |
| // so we should still get the React memory back. | |
| expect(results.some((m) => m.content.includes("React"))).toBe(true); | |
| }); | |
| // ββ 4. Special characters in FTS5 queries ββ | |
| test("queries with special characters do not throw and return results gracefully", async () => { | |
| insertMemory(db, { | |
| content: "C++ is a powerful systems programming language with operator overloading.", | |
| key: "cpp-info", | |
| }); | |
| insertMemory(db, { | |
| content: "Johnson & Johnson is a healthcare company.", | |
| key: "company-info", | |
| }); | |
| const specialQueries = [ | |
| '"quoted phrase"', | |
| "C++ language", | |
| "Johnson & Johnson", | |
| "dash-separated-query", | |
| "parens(test)", | |
| "asterisk*wildcard", | |
| "single'quote", | |
| ]; | |
| for (const q of specialQueries) { | |
| // Must not throw regardless of strategy | |
| const semanticResults = await retrieveMemories(API_KEY_ID, { | |
| query: q, | |
| retrievalStrategy: "semantic", | |
| maxTokens: 8000, | |
| }); | |
| expect(Array.isArray(semanticResults)).toBe(true); | |
| const hybridResults = await retrieveMemories(API_KEY_ID, { | |
| query: q, | |
| retrievalStrategy: "hybrid", | |
| maxTokens: 8000, | |
| }); | |
| expect(Array.isArray(hybridResults)).toBe(true); | |
| } | |
| }); | |
| // ββ 5. Token budget enforcement ββ | |
| test("results are trimmed when token budget is exceeded", async () => { | |
| // Each memory is ~100 chars = ~25 tokens | |
| const longContent = "A".repeat(400); // ~100 tokens per memory | |
| for (let i = 0; i < 10; i++) { | |
| insertMemory(db, { | |
| content: `Memory ${i}: ${longContent}`, | |
| key: `bulk-${i}`, | |
| }); | |
| } | |
| // With maxTokens=50, we should get at most 1 memory (~100+ tokens each, | |
| // but the first one is always included even if it exceeds the budget) | |
| const results = await retrieveMemories(API_KEY_ID, { | |
| retrievalStrategy: "exact", | |
| maxTokens: 50, | |
| }); | |
| // At least 1 memory (the "always include at least 1" rule) | |
| expect(results.length).toBeGreaterThanOrEqual(1); | |
| // But far fewer than all 10 | |
| expect(results.length).toBeLessThan(10); | |
| }); | |
| // ββ 6. estimateTokens utility ββ | |
| test("estimateTokens returns correct approximation", () => { | |
| expect(estimateTokens("")).toBe(0); | |
| expect(estimateTokens("abcd")).toBe(1); // 4 chars / 4 = 1 | |
| expect(estimateTokens("abcde")).toBe(2); // ceil(5/4) = 2 | |
| expect(estimateTokens("a".repeat(100))).toBe(25); | |
| // edge cases | |
| expect(estimateTokens(null as unknown as string)).toBe(0); | |
| expect(estimateTokens(undefined as unknown as string)).toBe(0); | |
| }); | |
| }); | |