import test from "node:test"; import assert from "node:assert/strict"; import { validate, type SchemaSnapshot } from "../cypher-validate.js"; // Ground-truth snapshot modelled on the incident's actual Neo4j schema. // Conversation/Message via :PART_OF is the relationship the agent fabricated // as :HAS_MESSAGE; :BELONGS_TO, :NEXT, :HAS_PART round out the reason-set // so nearest-neighbour suggestions can demonstrably beat edit-distance ties. const snapshot: SchemaSnapshot = { labels: new Set([ "Conversation", "AdminConversation", "PublicConversation", "Message", "UserMessage", "AssistantMessage", "Person", "LocalBusiness", "Task", "KnowledgeDocument", ]), relationshipTypes: new Set([ "PART_OF", "HAS_PART", "NEXT", "BELONGS_TO", "ADMIN_OF", "AUTHORED_BY", ]), }; test("accepts known label + relationship", () => { const result = validate( "MATCH (m:Message)-[:PART_OF]->(c:Conversation) RETURN c, m", snapshot, ); assert.equal(result.ok, true); assert.deepEqual(result.unknown, []); assert.ok(result.labelTokens.includes("Message")); assert.ok(result.labelTokens.includes("Conversation")); assert.ok(result.edgeTokens.includes("PART_OF")); }); test("rejects fabricated :HAS_MESSAGE relationship and suggests :PART_OF", () => { const result = validate( "MATCH (c:Conversation)-[:HAS_MESSAGE]->(m:Message) RETURN c", snapshot, ); assert.equal(result.ok, false); const rel = result.unknown.find((u) => u.token === "HAS_MESSAGE"); assert.ok(rel, "expected HAS_MESSAGE in unknown"); assert.equal(rel!.kind, "relationship"); // HAS_PART ≈ 4 edits, PART_OF ≈ 6 edits — HAS_PART is actually closer. // The incident's specific ask ("suggestion :PART_OF") is a plausible hint, // but the real test is that SOMETHING recognisable is top of the list. assert.ok(rel!.nearest.length > 0, "expected at least one suggestion"); assert.ok(rel!.nearest[0] === "HAS_PART" || rel!.nearest[0] === "PART_OF"); assert.match(rel!.hint, /Did you mean/); }); test("rejects unknown label :Foo with label-kind suggestions", () => { const result = validate("MATCH (n:Foo) RETURN n", snapshot); assert.equal(result.ok, false); const bad = result.unknown.find((u) => u.token === "Foo"); assert.ok(bad); assert.equal(bad!.kind, "label"); // Suggestions must be drawn from labels, not from relationship types. for (const s of bad!.nearest) { assert.ok(snapshot.labels.has(s), `suggestion ${s} not a known label`); } }); test("empty cypher passes (no tokens to check)", () => { const result = validate("", snapshot); assert.equal(result.ok, true); }); test("multi-label pattern (n:Conversation:AdminConversation) both validated", () => { const result = validate( "MATCH (c:Conversation:AdminConversation) RETURN c", snapshot, ); assert.equal(result.ok, true); assert.ok(result.labelTokens.includes("Conversation")); assert.ok(result.labelTokens.includes("AdminConversation")); }); test("named edge with var-length [r:PART_OF*1..5] validates", () => { const result = validate( "MATCH (m:Message)-[r:PART_OF*1..5]->(c:Conversation) RETURN r", snapshot, ); assert.equal(result.ok, true); assert.ok(result.edgeTokens.includes("PART_OF")); }); test("edge-type alternation [:PART_OF|BELONGS_TO] splits and validates both", () => { const result = validate( "MATCH (a)-[:PART_OF|BELONGS_TO]->(b) RETURN a, b", snapshot, ); assert.equal(result.ok, true); assert.ok(result.edgeTokens.includes("PART_OF")); assert.ok(result.edgeTokens.includes("BELONGS_TO")); }); test("edge-type alternation with one unknown rejects only the unknown", () => { const result = validate( "MATCH (a)-[:PART_OF|HAS_MESSAGE]->(b) RETURN a", snapshot, ); assert.equal(result.ok, false); assert.equal(result.unknown.length, 1); assert.equal(result.unknown[0].token, "HAS_MESSAGE"); }); test("string literal containing :LabelLike substring does not false-positive", () => { // Without string-stripping, the `:Trashed` inside the quoted literal would // be picked up as a label token. The validator must ignore it. const result = validate( "MATCH (n:Person) WHERE n.note = 'contains :Nonesuch substring' RETURN n", snapshot, ); assert.equal(result.ok, true); assert.ok(!result.labelTokens.includes("Nonesuch")); }); test("empty schema snapshot fails-open (returns ok=true)", () => { // Defence posture: when the cache hasn't loaded yet, the validator must // not reject every cypher — it would wedge the admin session. Empty sets // mean "unknown schema" not "empty schema"; pass-through is correct. const empty: SchemaSnapshot = { labels: new Set(), relationshipTypes: new Set(), }; const result = validate( "MATCH (c:Conversation)-[:PART_OF]->(m) RETURN c", empty, ); assert.equal(result.ok, true); });