import { describe, it, expect, beforeEach } from "vitest"; import { hybrid, clearIndexCache } from "../index.js"; /** * graph expand is one Cypher round-trip per `hybrid()` call, * not one per result. Pre-fix the lib looped at index.ts:421-462, issuing * `MATCH (n)-[r]-(related)` once per merged node — at slider=2000 that was * 2000 round-trips through the driver per search. Post-fix, a single * `UNWIND $nodeIds AS nid MATCH (n)-[r]-(related) WHERE elementId(n) = nid …` * with `WITH nid, collect({...})[0..20]` returns all expansions in one query. * * Invariants pinned here: * 1. exactly one expand round-trip per hybrid() call (regardless of N) * 2. per-result related cap of 20 preserved (slice notation in the lib) * 3. each `related` entry carries the neighbour's `elementId` so the * canvas can render edges (post-Task-747 lib shape change) * 4. `expandHops: 0` short-circuits the expand round-trip entirely */ interface ScriptedRun { match: (query: string) => boolean; records: Array>; } function record(fields: Record) { return { get: (k: string) => fields[k] }; } function makeStubSession(scripted: ScriptedRun[]) { const calls: Array<{ query: string; params: Record }> = []; const session = { run(query: string, params: Record) { calls.push({ query, params }); const hit = scripted.find((s) => s.match(query)); if (!hit) return Promise.resolve({ records: [] }); return Promise.resolve({ records: hit.records.map(record) }); }, } as unknown as import("neo4j-driver").Session; return { session, calls }; } beforeEach(() => { clearIndexCache(); }); describe("hybrid — batched expand", () => { it("issues exactly one expand round-trip for N merged results", async () => { const merged = [ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 }, { nodeId: "n2", nodeLabels: ["Person"], node: { properties: { name: "B" } }, score: 0.8 }, { nodeId: "n3", nodeLabels: ["Person"], node: { properties: { name: "C" } }, score: 0.7 }, ]; const { session, calls } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_person", labelsOrTypes: ["Person"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: merged, }, { match: (q) => q.includes("UNWIND $nodeIds"), records: [ { nid: "n1", items: [{ relType: "KNOWS", direction: "outgoing", relatedNodeId: "r1", relatedLabels: ["Person"], related: { properties: { name: "X" } } }], }, { nid: "n2", items: [{ relType: "KNOWS", direction: "incoming", relatedNodeId: "r2", relatedLabels: ["Person"], related: { properties: { name: "Y" } } }], }, ], }, ]); const embed = async () => [0.1, 0.2]; await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, labels: ["Person"], }); const expandCalls = calls.filter((c) => c.params && "nodeIds" in (c.params as Record)); expect(expandCalls).toHaveLength(1); expect((expandCalls[0].params as { nodeIds: string[] }).nodeIds).toEqual(["n1", "n2", "n3"]); }); it("preserves per-result related cap of 20 via slice notation", async () => { const { session, calls } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_person", labelsOrTypes: ["Person"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 }, ], }, ]); const embed = async () => [0.1]; await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, labels: ["Person"], }); const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds")); expect(expandCall).toBeDefined(); expect(expandCall!.query).toContain("[0..20]"); }); it("returns related entries carrying neighbour elementId for canvas edge rendering", async () => { const { session } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_person", labelsOrTypes: ["Person"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 }, ], }, { match: (q) => q.includes("UNWIND $nodeIds"), records: [ { nid: "n1", items: [ { relType: "KNOWS", direction: "outgoing", relatedNodeId: "r1", relatedLabels: ["Person"], related: { properties: { name: "B" } }, }, ], }, ], }, ]); const embed = async () => [0.1]; const res = await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, labels: ["Person"], }); expect(res.results[0]?.related[0]?.nodeId).toBe("r1"); }); it("skips the expand round-trip entirely when expandHops is 0", async () => { const { session, calls } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_person", labelsOrTypes: ["Person"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 }, ], }, ]); const embed = async () => [0.1]; await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, labels: ["Person"], expandHops: 0, }); const expandCalls = calls.filter((c) => c.query.includes("UNWIND $nodeIds")); expect(expandCalls).toHaveLength(0); }); it("preserves trashed/scope/agent gates on the related neighbour", async () => { const { session, calls } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_person", labelsOrTypes: ["Person"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 }, ], }, ]); const embed = async () => [0.1]; await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, labels: ["Person"], allowedScopes: ["public"], agentSlug: "support", }); const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds")); expect(expandCall).toBeDefined(); expect(expandCall!.query).toContain("related"); // notTrashed predicate target expect(expandCall!.query).toContain("$allowedScopes"); expect(expandCall!.query).toContain("$agentSlug"); }); });