import { describe, it, expect } from "vitest"; import { bm25Only } from "../index.js"; /** * `bm25Only` accepts an optional `labels` array. When set, the * Cypher gates results to nodes carrying at least one of the requested labels * (matches the hybrid vector half's per-index filter at index.ts:286-295). * * Pre-Task-747: `bm25Only` ignored labels entirely, so the admin route's * Ollama-down fallback returned BM25 hits across every label population — a * silent second-order failure on top of the loud-failure violation that the * mode banner addresses on the client. */ function makeStubSession(records: Array> = []) { const calls: Array<{ query: string; params: Record }> = []; const session = { run(query: string, params: Record) { calls.push({ query, params }); return Promise.resolve({ records: records.map((r) => ({ get: (k: string) => r[k] })), }); }, } as unknown as import("neo4j-driver").Session; return { session, calls }; } describe("bm25Only — label gate", () => { it("omits the label clause when labels arg is absent (back-compat)", async () => { const { session, calls } = makeStubSession(); await bm25Only(session, { query: "x", accountId: "acc-1", limit: 10 }); expect(calls[0].query).not.toContain("$labels"); expect(calls[0].params).not.toHaveProperty("labels"); }); it("omits the label clause when labels arg is an empty array", async () => { // Empty allowlist is treated as "no gate" rather than "match nothing" — // mirrors the hybrid vector half's `if (labels && labels.length > 0)` // guard so the two halves degrade identically. const { session, calls } = makeStubSession(); await bm25Only(session, { query: "x", accountId: "acc-1", limit: 10, labels: [], }); expect(calls[0].query).not.toContain("$labels"); expect(calls[0].params).not.toHaveProperty("labels"); }); it("adds the label clause and param when labels arg is set", async () => { const { session, calls } = makeStubSession(); await bm25Only(session, { query: "x", accountId: "acc-1", limit: 10, labels: ["Person"], }); expect(calls[0].query).toContain("any(l IN labels(node) WHERE l IN $labels)"); expect(calls[0].params.labels).toEqual(["Person"]); }); it("passes the full labels array as a single param (multi-label)", async () => { const { session, calls } = makeStubSession(); await bm25Only(session, { query: "x", accountId: "acc-1", limit: 10, labels: ["Person", "KnowledgeDocument"], }); expect(calls[0].params.labels).toEqual(["Person", "KnowledgeDocument"]); }); it("composes label gate alongside scope and agent clauses", async () => { const { session, calls } = makeStubSession(); await bm25Only(session, { query: "x", accountId: "acc-1", limit: 10, labels: ["Person"], allowedScopes: ["public"], agentSlug: "support", }); expect(calls[0].query).toContain("$allowedScopes"); expect(calls[0].query).toContain("$agentSlug"); expect(calls[0].query).toContain("$labels"); }); });