/** * Binding service + in-memory link repo tests. * * Exercises every public surface plus the ยง4 events the service emits. */ import { describe, expect, it } from "vitest"; import type { EvidenceLink, EvidenceTarget, } from "@citation-evidence/engine/shared"; import type { EvidenceItemId, EvidenceLinkId, } from "@citation-evidence/engine/shared"; import { createEventBus } from "@citation-evidence/engine"; import type { EngineEvent } from "@citation-evidence/engine"; import { createInMemoryLinkRepo } from "../repos/in-memory-links"; import { createBindingService } from "./bindings"; function makeFixture() { const bus = createEventBus(); const repo = createInMemoryLinkRepo(); const events: EngineEvent[] = []; bus.onAny((e) => events.push(e)); let counter = 0; const now = () => `2026-05-25T00:00:0${counter++}.000Z`; const service = createBindingService(repo, bus, now); return { bus, repo, events, service }; } const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" }; const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" }; const EV1 = "ev_test_one" as EvidenceItemId; const EV2 = "ev_test_two" as EvidenceItemId; describe("createBindingService", () => { it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => { const { service, repo, events } = makeFixture(); const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A, }); expect(link.evidenceItemId).toBe(EV1); expect(link.targetType).toBe("form-field"); expect(link.targetId).toBe("summary"); expect(link.relation).toBe("supports"); expect(link.status).toBe("candidate"); expect(link.createdAt).toBe(link.updatedAt); expect(repo.get(link.id)).toEqual(link); const created = events.filter((e) => e.type === "EvidenceLinkCreated"); expect(created).toHaveLength(1); expect(created[0]).toMatchObject({ linkId: link.id, link }); }); it("honours explicit relation/status/confidence", () => { const { service } = makeFixture(); const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A, relation: "contradicts", status: "conflicting", confidence: 0.42, createdBy: "tegwick", }); expect(link.relation).toBe("contradicts"); expect(link.status).toBe("conflicting"); expect(link.confidence).toBe(0.42); expect(link.createdBy).toBe("tegwick"); }); it("listEvidenceForTarget returns only links for the requested target", () => { const { service } = makeFixture(); const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A }); service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B }); const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A }); const linksForA = service.listEvidenceForTarget(FIELD_A); expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort()); }); it("listTargetsForEvidence returns all targets an evidence item is linked to", () => { const { service } = makeFixture(); const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A }); const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B }); service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A }); const targets = service.listTargetsForEvidence(EV1); expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort()); }); it("unlinkEvidence removes the link and reports success/failure", () => { const { service } = makeFixture(); const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A }); expect(service.unlinkEvidence(link.id)).toBe(true); expect(service.getLink(link.id)).toBeNull(); expect(service.unlinkEvidence(link.id)).toBe(false); expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false); }); it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => { const { service, events } = makeFixture(); const original = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A, }); const updated = service.updateLink(original.id, { status: "confirmed", confidence: 0.9, }); expect(updated.status).toBe("confirmed"); expect(updated.confidence).toBe(0.9); expect(updated.relation).toBe(original.relation); expect(updated.updatedAt).not.toBe(original.updatedAt); const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated"); expect(updatedEvents).toHaveLength(1); expect((updatedEvents[0] as Extract).link).toEqual(updated); }); it("updateLink throws on unknown id", () => { const { service } = makeFixture(); expect(() => service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }), ).toThrow(/unknown id/); }); it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => { const { service, events } = makeFixture(); service.setActiveEvidence(EV1); const activated = events.filter((e) => e.type === "EvidenceItemActivated"); expect(activated).toHaveLength(1); expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" }); }); }); describe("EvidenceLinkRepository (in-memory)", () => { it("rejects duplicate ids on create", () => { const repo = createInMemoryLinkRepo(); const link: EvidenceLink = { id: "evlink_x" as EvidenceLinkId, evidenceItemId: EV1, targetType: "form-field", targetId: "f", relation: "supports", status: "candidate", createdAt: "2026-05-25T00:00:00.000Z", updatedAt: "2026-05-25T00:00:00.000Z", }; repo.create(link); expect(() => repo.create(link)).toThrow(/duplicate/); }); it("update throws on unknown id", () => { const repo = createInMemoryLinkRepo(); const link: EvidenceLink = { id: "evlink_unknown" as EvidenceLinkId, evidenceItemId: EV1, targetType: "form-field", targetId: "f", relation: "supports", status: "candidate", createdAt: "2026-05-25T00:00:00.000Z", updatedAt: "2026-05-25T00:00:00.000Z", }; expect(() => repo.update(link)).toThrow(/unknown/); }); });