// Ephemeral round-trip test for the chain-graph I/O lib. Runs only when a // Neo4j is reachable (NEO4J_URI + NEO4J_PASSWORD set) — the sprint drives it // against a throwaway container; there is no CI Neo4j so it self-skips there. // // Proves: Loop's full chain model survives upsert -> read byte-identical; // milestone-set advances one date and only that date; pipeline flags a stalled // deal and not a fresh one; party-lookup resolves an inbound email to its role; // a priority gap is flagged at read time. import { describe, it, expect, beforeAll, afterAll } from "vitest"; import neo4j, { Driver, Session } from "neo4j-driver"; import { upsertChain, readChain, setMilestone, listPipeline, lookupParty, type ChainUpsertInput, } from "../lib/chain-graph.js"; const URI = process.env.NEO4J_URI; const PASSWORD = process.env.NEO4J_PASSWORD; const USER = process.env.NEO4J_USER ?? "neo4j"; const ACCOUNT = "test-1372-progression"; const NOW = Date.parse("2026-07-03T00:00:00Z"); const run = URI && PASSWORD ? describe : describe.skip; run("chain-graph round trip", () => { let driver: Driver; let session: Session; beforeAll(async () => { driver = neo4j.driver(URI as string, neo4j.auth.basic(USER, PASSWORD as string)); session = driver.session(); // Clean any prior run and seed the LocalBusiness the chain's OWNED_BY needs. await session.run(`MATCH (n {accountId: $a}) DETACH DELETE n`, { a: ACCOUNT }); await session.run(`MERGE (b:LocalBusiness {accountId: $a}) SET b.name = 'Test Agency'`, { a: ACCOUNT }); }); afterAll(async () => { await session.run(`MATCH (n {accountId: $a}) DETACH DELETE n`, { a: ACCOUNT }); await session.close(); await driver.close(); }); const twoLinkChain: ChainUpsertInput = { chainId: "chain-A", sale: { sourceSystem: "loop-crm", sourceId: "sale-1", salePrice: 42500000, status: "sale-agreed", sstcDate: "2026-06-01", isCompleteChain: true, }, chain: { anticipatedExchangeDate: "2026-08-15", desiredCompletionDate: "2026-09-01", createdDate: "2026-06-01", owningTeam: "sales", owningAgent: "Alex", isComplete: false, }, links: [ { priority: 1, linkType: "loop-property", address: "14 Garth Road", salePrice: 42500000, generalNotes: "subject property", proceedabilityNotes: "buyer proceedable", milestones: { "mortgage-valuation": "2026-06-05", survey: "2026-06-10", "draft-contract-issued": "2026-06-12", "searches-requested": "2026-06-14", "searches-received": "2026-06-20", "mortgage-offer-received": "2026-06-22", "seller-signed": "2026-06-25", "buyer-signed": "2026-06-26", exchanged: "2026-06-28", completed: "2026-06-30", }, parties: { seller: { name: "Jane Seller", email: "jane@example.com", phone: "0700000001" }, "seller-solicitor": { name: "Seller LLP", email: "ssol@firm.co", phone: "0700000002" }, buyer: { name: "Bob Buyer", email: "bob@example.com", phone: "0700000003" }, "buyer-solicitor": { name: "Buyer LLP", email: "bsol@firm.co", phone: "0700000004" }, agent: { name: "Alex Agent", email: "alex@agency.co", phone: "0700000005" }, }, }, { priority: 2, linkType: "external", address: "9 Onward Street", salePrice: 55000000, generalNotes: "onward purchase", proceedabilityNotes: "chain above unknown", milestones: { "mortgage-valuation": "2026-06-07", survey: "2026-06-11", "draft-contract-issued": "2026-06-13", "searches-requested": "2026-06-15", "searches-received": "2026-06-21", "mortgage-offer-received": "2026-06-23", "seller-signed": "2026-06-24", "buyer-signed": "2026-06-27", exchanged: "2026-06-29", completed: "2026-07-01", }, parties: { seller: { name: "Sam Vendor", email: "sam@example.com", phone: "0700000006" }, "buyer-solicitor": { name: "Buyer LLP", email: "bsol@firm.co", phone: "0700000004" }, }, }, ], }; it("upsert -> read returns every field byte-identical", async () => { const res = await upsertChain(session, ACCOUNT, twoLinkChain); expect(res.linkCount).toBe(2); expect(res.partyCount).toBe(7); const state = await readChain(session, ACCOUNT, { chainId: "chain-A" }, NOW); expect(state).not.toBeNull(); expect(state!.links.map((l) => l.priority)).toEqual([1, 2]); // priority-ordered expect(state!.sale!.salePrice).toBe(42500000); // int round-trip, not float expect(state!.sale!.isCompleteChain).toBe(true); expect(state!.chain!.anticipatedExchangeDate).toBe("2026-08-15"); expect(state!.chain!.owningAgent).toBe("Alex"); const link1 = state!.links[0]; expect(link1.milestones).toEqual(twoLinkChain.links[0].milestones); expect(link1.salePrice).toBe(42500000); expect(link1.address).toBe("14 Garth Road"); // five parties, each with contact on partyEmail/partyPhone. const seller = link1.parties.find((p) => p.role === "seller"); expect(seller).toMatchObject({ name: "Jane Seller", email: "jane@example.com", phone: "0700000001" }); expect(link1.parties.length).toBe(5); const link2 = state!.links[1]; expect(link2.milestones).toEqual(twoLinkChain.links[1].milestones); expect(link2.parties.length).toBe(2); }); it("re-upsert is idempotent (no duplicate nodes)", async () => { await upsertChain(session, ACCOUNT, twoLinkChain); const counts = await session.run( `MATCH (s:Sale {accountId: $a, chainId: 'chain-A'}) WITH count(s) AS sales MATCH (cl:ChainLink {accountId: $a, chainId: 'chain-A'}) WITH sales, count(cl) AS links MATCH (per:Person {accountId: $a, chainId: 'chain-A'}) RETURN sales, links, count(per) AS parties`, { a: ACCOUNT }, ); expect(counts.records[0].get("sales").toNumber()).toBe(1); expect(counts.records[0].get("links").toNumber()).toBe(2); expect(counts.records[0].get("parties").toNumber()).toBe(7); }); it("milestone-set advances one date and only that date", async () => { const before = await readChain(session, ACCOUNT, { chainId: "chain-A" }, NOW); const beforeLink1 = before!.links[0].milestones; await setMilestone(session, ACCOUNT, "chain-A", 1, "exchanged", "2026-08-01", "email-123"); const after = await readChain(session, ACCOUNT, { chainId: "chain-A" }, NOW); const afterLink1 = after!.links[0].milestones; expect(afterLink1.exchanged).toBe("2026-08-01"); for (const stage of Object.keys(beforeLink1)) { if (stage === "exchanged") continue; expect(afterLink1[stage]).toBe(beforeLink1[stage]); } }); it("party-lookup resolves an inbound email to its role", async () => { const matches = await lookupParty(session, ACCOUNT, { email: "bsol@firm.co" }); // buyer-solicitor on both links -> two matches. expect(matches.length).toBe(2); expect(matches.every((m) => m.partyRole === "buyer-solicitor")).toBe(true); expect(await lookupParty(session, ACCOUNT, { email: "nobody@nowhere.co" })).toEqual([]); }); it("pipeline flags a stalled deal and not a fresh one", async () => { // chain-A milestones are all in June/Aug 2026; relative to NOW (2026-07-03) // its newest date (the milestone-set exchanged 2026-08-01) is in the future, // so build a genuinely stale and a genuinely fresh sale for the assertion. const stale: ChainUpsertInput = { chainId: "chain-STALE", sale: { sourceSystem: "loop-crm", sourceId: "sale-stale", status: "sale-agreed", sstcDate: "2026-05-01" }, links: [{ priority: 1, linkType: "external", address: "Stale House", milestones: { "searches-received": "2026-05-10" } }], }; const fresh: ChainUpsertInput = { chainId: "chain-FRESH", sale: { sourceSystem: "loop-crm", sourceId: "sale-fresh", status: "sale-agreed", sstcDate: "2026-07-01" }, links: [{ priority: 1, linkType: "external", address: "Fresh House", milestones: { "searches-received": "2026-07-02" } }], }; await upsertChain(session, ACCOUNT, stale); await upsertChain(session, ACCOUNT, fresh); const rows = await listPipeline(session, ACCOUNT, NOW, 7); const staleRow = rows.find((r) => r.chainId === "chain-STALE"); const freshRow = rows.find((r) => r.chainId === "chain-FRESH"); expect(staleRow?.stalled).toBe(true); expect(freshRow?.stalled).toBe(false); }); it("rejects (rolls back) when the account has no LocalBusiness to anchor the chain", async () => { const orphanAcct = "test-1372-no-business"; await session.run(`MATCH (n {accountId: $a}) DETACH DELETE n`, { a: orphanAcct }); await expect( upsertChain(session, orphanAcct, { chainId: "no-biz", sale: { sourceSystem: "loop-crm", sourceId: "s", status: "sale-agreed", sstcDate: "2026-07-01" }, links: [{ priority: 1, linkType: "external", address: "Nowhere" }], }), ).rejects.toThrow(/LocalBusiness/); // The whole transaction rolled back — no orphaned Chain was written. const c = await session.run(`MATCH (c:Chain {accountId: $a}) RETURN count(c) AS n`, { a: orphanAcct }); expect(c.records[0].get("n").toNumber()).toBe(0); await session.run(`MATCH (n {accountId: $a}) DETACH DELETE n`, { a: orphanAcct }); }); it("orders a mixed date-only / datetime milestone set correctly (numeric, not lexical)", async () => { // "2026-06-30" (date-only) is lexically AFTER "2026-06-29T23:59:59Z" but // chronologically BEFORE it. The stall clock must use the datetime. const mixed: ChainUpsertInput = { chainId: "chain-MIXED", sale: { sourceSystem: "loop-crm", sourceId: "sale-mixed", status: "sale-agreed", sstcDate: "2026-06-01" }, links: [{ priority: 1, linkType: "external", address: "Mixed", milestones: { "searches-received": "2026-06-30", exchanged: "2026-06-29T23:59:59Z" }, }], }; await upsertChain(session, ACCOUNT, mixed); // NOW = 2026-07-03; newest real movement is 2026-06-30 (3 days) -> not stalled at 7d. const state = await readChain(session, ACCOUNT, { chainId: "chain-MIXED" }, NOW, 7); expect(state!.stalled).toBe(false); await session.run(`MATCH (n {accountId: $a, chainId: 'chain-MIXED'}) DETACH DELETE n`, { a: ACCOUNT }); }); it("flags a priority gap at read time", async () => { const gapped: ChainUpsertInput = { chainId: "chain-GAP", sale: { sourceSystem: "loop-crm", sourceId: "sale-gap", status: "sale-agreed", sstcDate: "2026-07-01" }, links: [ { priority: 1, linkType: "external", address: "First" }, { priority: 3, linkType: "external", address: "Third" }, ], }; await upsertChain(session, ACCOUNT, gapped); const state = await readChain(session, ACCOUNT, { chainId: "chain-GAP" }, NOW); expect(state!.structuralFlags.some((f) => f.includes("gap"))).toBe(true); }); });