| import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
| import Database from "better-sqlite3";
|
| import { retrieveMemories, estimateTokens } from "../retrieval";
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
|
|
| describe("Memory Retrieval - corrupt metadata handling", () => {
|
| test("corrupt metadata JSON does not throw, returns empty object", () => {
|
|
|
|
|
| const corruptValues = ["{invalid json", "not-json-at-all", "{{{}}}", "", "undefined"];
|
|
|
| for (const corrupt of corruptValues) {
|
|
|
| const result = (() => {
|
| try {
|
| return JSON.parse(String(corrupt));
|
| } catch {
|
| return {};
|
| }
|
| })();
|
| expect(result).toEqual({});
|
| }
|
| });
|
| });
|
|
|
| |
| |
|
|
| describe("Memory API - response shape", () => {
|
| test("GET /api/memory response should include stats with total field", async () => {
|
|
|
|
|
| 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 });
|
| });
|
| });
|
|
|
|
|
|
|
|
|
|
|
| const API_KEY_ID = "test-api-key-fts5";
|
|
|
| |
| |
| |
|
|
| 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;
|
| `);
|
| }
|
|
|
|
|
| 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(() => {
|
|
|
| savedDb = (globalThis as any).__omnirouteDb;
|
|
|
|
|
| db = new Database(":memory:");
|
| db.pragma("journal_mode = WAL");
|
| setupSchema(db);
|
| setupFts(db);
|
| (globalThis as any).__omnirouteDb = db;
|
| });
|
|
|
| afterEach(() => {
|
|
|
| if (savedDb) {
|
| (globalThis as any).__omnirouteDb = savedDb;
|
| } else {
|
| delete (globalThis as any).__omnirouteDb;
|
| }
|
| try {
|
| db.close();
|
| } catch {
|
|
|
| }
|
| });
|
|
|
|
|
|
|
| test("semantic strategy returns FTS5-ranked results with most relevant first", async () => {
|
|
|
| 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",
|
| });
|
|
|
|
|
| const results = await retrieveMemories(API_KEY_ID, {
|
| query: "TypeScript",
|
| retrievalStrategy: "semantic",
|
| maxTokens: 8000,
|
| });
|
|
|
|
|
| expect(results.length).toBeGreaterThanOrEqual(2);
|
|
|
|
|
| const topContent = results[0].content;
|
| expect(topContent).toContain("TypeScript compiler");
|
| });
|
|
|
|
|
|
|
| test("hybrid strategy merges FTS5 results with keyword results without duplicates", async () => {
|
|
|
| insertMemory(db, {
|
| content: "Kubernetes orchestrates containerized applications in a cluster.",
|
| key: "kubernetes",
|
| });
|
|
|
| insertMemory(db, {
|
| content: "Container deployment best practices for production systems.",
|
| key: "container-deploy",
|
| });
|
|
|
| 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,
|
| });
|
|
|
|
|
| expect(results.some((m) => m.content.includes("Kubernetes"))).toBe(true);
|
|
|
|
|
| const ids = results.map((m) => m.id);
|
| expect(new Set(ids).size).toBe(ids.length);
|
| });
|
|
|
|
|
|
|
| test("semantic strategy does not throw when memory_fts table is missing", async () => {
|
|
|
| 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",
|
| });
|
|
|
|
|
| db.exec("DROP TABLE IF EXISTS memory_fts");
|
|
|
|
|
| const results = await retrieveMemories(API_KEY_ID, {
|
| query: "React",
|
| retrievalStrategy: "semantic",
|
| maxTokens: 8000,
|
| });
|
|
|
|
|
|
|
|
|
| expect(results.some((m) => m.content.includes("React"))).toBe(true);
|
| });
|
|
|
|
|
|
|
| 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) {
|
|
|
| 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);
|
| }
|
| });
|
|
|
|
|
|
|
| test("results are trimmed when token budget is exceeded", async () => {
|
|
|
| const longContent = "A".repeat(400);
|
| for (let i = 0; i < 10; i++) {
|
| insertMemory(db, {
|
| content: `Memory ${i}: ${longContent}`,
|
| key: `bulk-${i}`,
|
| });
|
| }
|
|
|
|
|
|
|
| const results = await retrieveMemories(API_KEY_ID, {
|
| retrievalStrategy: "exact",
|
| maxTokens: 50,
|
| });
|
|
|
|
|
| expect(results.length).toBeGreaterThanOrEqual(1);
|
|
|
| expect(results.length).toBeLessThan(10);
|
| });
|
|
|
|
|
|
|
| test("estimateTokens returns correct approximation", () => {
|
| expect(estimateTokens("")).toBe(0);
|
| expect(estimateTokens("abcd")).toBe(1);
|
| expect(estimateTokens("abcde")).toBe(2);
|
| expect(estimateTokens("a".repeat(100))).toBe(25);
|
|
|
| expect(estimateTokens(null as unknown as string)).toBe(0);
|
| expect(estimateTokens(undefined as unknown as string)).toBe(0);
|
| });
|
| });
|
|
|