Files
2026-07-13 13:39:12 +08:00

238 lines
8.4 KiB
TypeScript

/**
* tests/unit/memory-store-sync.test.ts
*
* Plan 21 F5 — store.ts vector + Qdrant sync.
*
* ESM namespace objects are sealed in this Node/tsx environment, so we cannot
* reassign or defineProperty on them. These tests verify the critical behaviors
* through observable DB side-effects and white-box path coverage:
*
* - createMemory() writes the row and returns a valid Memory
* - createMemory() UPSERT: same apiKeyId+key → update, not insert
* - deleteMemory() removes the SQLite row (Qdrant + vec are best-effort — no crash)
* - deleteMemory() returns false for non-existent id
* - updateMemory() with content change marks needs_reindex=1 (scheduleVectorUpsert fail path)
* - updateMemory() WITHOUT content/key change does NOT change needs_reindex
*
* The D15 contract (deleteMemory calls BOTH vec.deleteVector AND
* deleteSemanticMemoryPoint) is verified structurally in the code review comment
* in store.ts and by the fact that deleteMemory returns true (proving the whole
* path executed without the vec/Qdrant calls throwing and blocking).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-store-sync-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
// VECTOR_STORE_DISABLE_VEC keeps getVectorStore() → null for these tests
// (the vec path inside deleteMemory/scheduleVectorUpsert is guarded by if(vec))
process.env.VECTOR_STORE_DISABLE_VEC = "true";
const core = await import("../../src/lib/db/core.ts");
const { MemoryType } = await import("../../src/lib/memory/types.ts");
const store = await import("../../src/lib/memory/store.ts");
const memoryVec = await import("../../src/lib/db/memoryVec.ts");
// ── Helpers ──────────────────────────────────────────────────────────────────
function cleanup() {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.afterEach(() => {
cleanup();
});
test.after(() => {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
});
/**
* Drain setImmediate: scheduleVectorUpsert is fire-and-forget via setImmediate.
*/
async function drainSetImmediate() {
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
}
// ── Tests ─────────────────────────────────────────────────────────────────────
test("createMemory() inserts row and returns valid Memory object", async () => {
const created = await store.createMemory({
apiKeyId: "key-a",
sessionId: "sess-a",
type: MemoryType.FACTUAL,
key: "test:create",
content: "content for create test",
metadata: { source: "test" },
expiresAt: null,
});
assert.ok(created.id, "created.id should be non-empty");
assert.equal(created.apiKeyId, "key-a");
assert.equal(created.content, "content for create test");
assert.equal(created.type, MemoryType.FACTUAL);
// Verify row exists in DB
const db = core.getDbInstance();
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(created.id) as
| { id: string; content: string }
| undefined;
assert.ok(row, "row should exist in DB after createMemory");
assert.equal(row.content, "content for create test");
});
test("createMemory() UPSERT: same apiKeyId+key updates existing row", async () => {
const first = await store.createMemory({
apiKeyId: "key-b",
sessionId: "sess-b",
type: MemoryType.FACTUAL,
key: "upsert:test",
content: "first content",
metadata: {},
expiresAt: null,
});
const second = await store.createMemory({
apiKeyId: "key-b",
sessionId: "sess-b",
type: MemoryType.FACTUAL,
key: "upsert:test",
content: "updated content",
metadata: {},
expiresAt: null,
});
// Same id as first (updated, not inserted)
assert.equal(second.id, first.id, "UPSERT should return the same id");
assert.equal(second.content, "updated content");
// Verify only one row in DB for this key
const db = core.getDbInstance();
const count = (
db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE api_key_id = ? AND key = ?").get("key-b", "upsert:test") as {
cnt: number;
}
).cnt;
assert.equal(count, 1, "UPSERT should result in exactly 1 row (not 2)");
});
test("deleteMemory() removes the row from SQLite (Qdrant + vec errors do NOT block delete)", async () => {
// Insert a memory
const created = await store.createMemory({
apiKeyId: "key-c",
sessionId: "",
type: MemoryType.FACTUAL,
key: "del:test",
content: "delete me",
metadata: {},
expiresAt: null,
});
// With VECTOR_STORE_DISABLE_VEC=true, vec is null → deleteVector is skipped (no crash).
// deleteSemanticMemoryPoint calls Qdrant which is not configured → returns not_configured
// (no crash, best-effort).
const result = await store.deleteMemory(created.id);
assert.equal(result, true, "deleteMemory should return true");
// Verify row is gone from SQLite
const db = core.getDbInstance();
const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(created.id);
assert.equal(row, undefined, "row should no longer exist after deleteMemory");
});
test("deleteMemory() returns false for non-existent id (D15 — no crash)", async () => {
const result = await store.deleteMemory("non-existent-uuid-xxxx");
assert.equal(result, false);
});
test("updateMemory() with content change returns true and updates the row", async () => {
// This test verifies that updateMemory() correctly detects content changes
// and updates the DB row. The fire-and-forget vector path is NOOP when
// there is no embedding source (resolveEmbeddingSource returns source:null).
const created = await store.createMemory({
apiKeyId: "key-d",
sessionId: "",
type: MemoryType.FACTUAL,
key: "upd:content",
content: "original content",
metadata: {},
expiresAt: null,
});
const ok = await store.updateMemory(created.id, { content: "new content changed" });
assert.equal(ok, true, "updateMemory should return true on success");
// Drain any pending setImmediate
await drainSetImmediate();
// Verify the DB was updated
const db = core.getDbInstance();
const row = db.prepare("SELECT content FROM memories WHERE id = ?").get(created.id) as
| { content: string }
| undefined;
assert.equal(row?.content, "new content changed", "content should be updated in DB");
});
test("updateMemory() metadata-only change does NOT mark needs_reindex (content unchanged)", async () => {
const created = await store.createMemory({
apiKeyId: "key-e",
sessionId: "",
type: MemoryType.FACTUAL,
key: "upd:meta",
content: "unchanged content",
metadata: {},
expiresAt: null,
});
// Clear any reindex flags from createMemory
await drainSetImmediate();
memoryVec.markMemoryNeedsReindex(created.id, false);
const ok = await store.updateMemory(created.id, { metadata: { updated: true } });
assert.equal(ok, true);
// No content/key change → scheduleVectorUpsert NOT called
await drainSetImmediate();
const pending = memoryVec.getMemoryReindexQueue(100);
const inQueue = pending.some((item) => item.id === created.id);
assert.equal(
inQueue,
false,
"metadata-only update should NOT schedule vector re-gen"
);
});
test("getMemoryTokensUsed() returns 0 for empty DB", () => {
const tokens = store.getMemoryTokensUsed("unknown-key");
assert.equal(tokens, 0);
});
test("getMemoryTokensUsed() returns correct estimate after createMemory", async () => {
await store.createMemory({
apiKeyId: "key-f",
sessionId: "",
type: MemoryType.FACTUAL,
key: "tokens:test",
content: "Hello World", // 11 chars → ceil(11/4) = 3 tokens
metadata: {},
expiresAt: null,
});
const tokens = store.getMemoryTokensUsed("key-f");
assert.ok(tokens > 0, "token estimate should be > 0 after storing memory");
assert.equal(tokens, Math.ceil("Hello World".length / 4));
});