// Phase 2A graph store test suite. // Run: node test/test_graph.js // Exits 0 on full pass, 1 on any failure. // // Style mirrors test/test-entities.js. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { Graph } from "../src/graph.js"; import { extractEntities } from "../src/entities.js"; // Pre-flight: lock the extractor output we expect, so the test stays // robust if the corpus drifts. We mirror test-entities.js's contract. const extractorCases = [ { name: "machine + product + tailscale ip", in: "DNS2 runs Triangles daemon on 100.121.150.22", want: ["DNS2", "Triangles", "100.121.150.22"], }, { name: "PR code + Phase code", in: "PR-30 fixed Phase 4 of the plan", want: ["PR-30", "Phase 4"], }, { name: "explicit arrow relations yield source+target entities", in: "DashCaddy → Caddy → nftables", want: ["DashCaddy", "Caddy", "nftables"], }, { name: "Hermes + krystie (profile) + MCP all-caps token", in: "Hermes (krystie profile) uses MCP stdio", want: ["Hermes", "krystie", "MCP"], }, ]; // A small in-memory fixture built from entities the EXTRACTOR actually // recognizes (see src/entities.js). The extractor's product list does NOT // include "Ollama" or "Qdrant" (those are inferred, not literal), so we // anchor the fixture on the real entity vocabulary: DNS2/DNS3, DashCaddy, // Caddy, nftables, Triangles, Hermes, Krystie, MCP, PR-30, Phase 4. const FIXTURE = [ { id: "urn:test:1", lifecycle: { status: "active" }, body: { text: "DNS2 runs DashCaddy on 100.121.150.22. DashCaddy -> Caddy.", subject: "DNS2 runs DashCaddy on 100.121.150.22", }, }, { id: "urn:test:2", lifecycle: { status: "active" }, body: { text: "Caddy runs on DNS2. Caddy uses nftables for fire-walling.", subject: "Caddy runs on DNS2", }, }, { id: "urn:test:3", lifecycle: { status: "active" }, body: { text: "Hermes (krystie) uses DashCaddy. Hermes -> MCP stdio.", subject: "Hermes uses DashCaddy", }, }, { id: "urn:test:4", lifecycle: { status: "active" }, body: { text: "PR-30 shipped Phase 2. DashCaddy -> Triangles for payments.", subject: "PR-30 shipped Phase 2", }, }, { id: "urn:test:5", lifecycle: { status: "active" }, body: { text: "DNS3 runs Triangles. DNS3 depends on DNS2 for replication.", subject: "DNS3 runs Triangles", }, }, { id: "urn:test:6", lifecycle: { status: "deleted" }, // must be skipped body: { text: "DNS2 ghost record", subject: "DNS2 ghost record", }, }, { id: "urn:test:7", lifecycle: { status: "tombstone" }, // must be skipped body: { text: "DNS2 tombstone", subject: "DNS2 tombstone", }, }, ]; let pass = 0; let fail = 0; function ok(cond, name, detail) { if (cond) { pass++; console.log(`PASS ${name}`); } else { fail++; console.log(`FAIL ${name}`); if (detail) console.log(` ${detail}`); } } function expect(label, actual, predicate) { if (predicate(actual)) { pass++; console.log(`PASS ${label}`); } else { fail++; console.log(`FAIL ${label}`); console.log(` actual=${JSON.stringify(actual)}`); } } // ---------- extractor contract ---------- console.log("---- extractor contract (smoke) ----"); for (const c of extractorCases) { const got = extractEntities(c.in); ok( JSON.stringify(got) === JSON.stringify(c.want), `extract: ${c.name}`, `got=${JSON.stringify(got)} want=${JSON.stringify(c.want)}` ); } // ---------- fixture build ---------- console.log("\n---- fixture build ----"); const g = new Graph(); g.buildFromRecords(FIXTURE); expect( "node DNS2 present", g.nodes.get("DNS2"), (n) => n && n.frequency >= 3 // 1,2,5 each contribute + URLs in 1 ); expect( "node DashCaddy present", g.nodes.get("DashCaddy"), (n) => n && n.frequency >= 2 // records 1,3 (and possibly 4 as relation source) ); expect( "node Caddy present", g.nodes.get("Caddy"), (n) => n && n.frequency >= 2 // records 1,2 ); expect( "edge DashCaddy -> Caddy (relates-to) from record 1", [...g.edges.values()].find( (e) => e.src === "DashCaddy" && e.tgt === "Caddy" && e.type === "relates-to" ), (e) => !!e && e.weight >= 1 ); expect( "edge Caddy located-on DNS2 (runs on = located-on)", [...g.edges.values()].find( (e) => (e.src === "Caddy" && e.tgt === "DNS2" && e.type === "located-on") || (e.src === "DNS2" && e.tgt === "Caddy" && e.type === "located-on") ), (e) => !!e ); expect( "edge DNS3 depends-on DNS2", [...g.edges.values()].find( (e) => e.src === "DNS3" && e.tgt === "DNS2" && e.type === "depends-on" ), (e) => !!e ); expect( "deleted record skipped (urn:test:6 not present)", g.stats().urns, (u) => u === 5 ); expect( "tombstone record skipped (urn:test:7 not present)", g.nodes.get("DNS2")?.urns.has("urn:test:7"), (v) => v === false ); // ---------- query API ---------- console.log("\n---- query API ----"); const nDns2 = g.neighbors("DNS2", 2); expect( "neighbors('DNS2', 2) returns >=1 URN", [...nDns2.entries()], (arr) => arr.length >= 1 ); const nDashCaddy = g.neighbors("DashCaddy", 1); expect( "neighbors('DashCaddy', 1) returns >=1 URN (Caddy is direct)", [...nDashCaddy.entries()], (arr) => arr.length >= 1 ); expect( "neighbors on unknown entity returns empty Map", g.neighbors("NotPresent", 2).size, (s) => s === 0 ); const dashSearch = g.searchEntities("Dash", 5); expect( "searchEntities('Dash', 5) finds DashCaddy", dashSearch.find((x) => x.entity === "DashCaddy"), (v) => !!v ); const mcpSearch = g.searchEntities("MCP", 5); expect( "searchEntities('MCP', 5) finds MCP", mcpSearch.find((x) => x.entity === "MCP"), (v) => !!v ); const emptySearch = g.searchEntities("__no_such_token__", 5); expect( "searchEntities with no matches returns []", emptySearch, (v) => v.length === 0 ); // ---------- persistence roundtrip ---------- console.log("\n---- persistence roundtrip ----"); // Use the LIVE GRAPH_FILE (default). Back it up first so the test is // non-destructive against /root/ump-recall/state/graph.json (which the // build script just produced). We back up -> save fixture -> load -> // restore from backup -> remove tmp dir. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "graph-test-")); const liveFile = "/root/ump-recall/state/graph.json"; let backedUp = null; try { if (fs.existsSync(liveFile)) { backedUp = fs.readFileSync(liveFile); } } catch (_) {} const saveRes = await g.save(); // writes to module's GRAPH_FILE (the live one) expect( "save() wrote file", fs.existsSync(saveRes.file), (v) => v === true ); const g2 = new Graph(); const loaded = await g2.load(); expect("load() returned true on existing file", loaded, (v) => v === true); expect( "roundtrip preserves node count", g2.nodes.size, (s) => s === g.nodes.size ); expect( "roundtrip preserves edge count", g2.edges.size, (s) => s === g.edges.size ); expect( "roundtrip preserves URN count", g2._countUniqueUrns(), (u) => u === g._countUniqueUrns() ); expect( "roundtrip preserves DNS2 frequency", g2.nodes.get("DNS2")?.frequency, (f) => f === g.nodes.get("DNS2")?.frequency ); // Exercise searchEntities on the reloaded graph. const reloadedSearch = g2.searchEntities("DNS"); expect( "reloaded graph still finds DNS-prefix entities", reloadedSearch.find((x) => x.entity === "DNS2"), (v) => !!v ); // ---------- top-N reporting ---------- console.log("\n---- reporting helpers ----"); const topE = g.topEntities(5); expect( "topEntities(5) returns at most 5 entries", topE.length, (n) => n <= 5 && n >= 1 ); const topX = g.topEdges(5); expect("topEdges returns array", Array.isArray(topX), (v) => v === true); // Cleanup tmp dir. fs.rmSync(tmpDir, { recursive: true, force: true }); // Restore the live graph.json from backup (the test overwrote it with the // 13-node fixture graph; this restores the production state). if (backedUp) { fs.writeFileSync(liveFile, backedUp); } console.log(`\n${pass}/${pass + fail} passed`); if (fail > 0) process.exit(1);