import { describe, it, expect, beforeEach } from "@jest/globals"; import express from "express"; import request from "supertest"; import { buildSandboxRouter, recordDispatch, advance, rejectMessage, getMessage, listMessages, resetSandboxForTests, finSignature, } from "../../src/services/finLink/sandbox"; import { createInProcessFinLinkClient, createHttpFinLinkClient, } from "../../src/services/finLink/client"; describe("FIN-link sandbox (gap-analysis v2 §7.1 / §10.6)", () => { beforeEach(() => { resetSandboxForTests(); }); describe("lifecycle (in-memory)", () => { it("assigns a FIN reference and records state received", () => { const msg = recordDispatch({ messageType: "MT760", payload: "MT760 payload", planId: "plan-1", }); expect(msg.reference).toMatch(/^FIN-[0-9A-F]{12}$/); expect(msg.state).toBe("received"); expect(msg.stateHistory).toHaveLength(1); expect(msg.planId).toBe("plan-1"); }); it("advances deterministically: received -> acknowledged -> accepted -> settled", async () => { const msg = recordDispatch({ messageType: "pacs.009", payload: "" }); expect((await advance(msg.reference))!.state).toBe("acknowledged"); expect((await advance(msg.reference))!.state).toBe("accepted"); expect((await advance(msg.reference))!.state).toBe("settled"); expect((await advance(msg.reference))!.state).toBe("settled"); // terminal const final = getMessage(msg.reference)!; expect(final.stateHistory.map((h) => h.state)).toEqual([ "received", "acknowledged", "accepted", "settled", ]); }); it("supports rejection and stops lifecycle progression", async () => { const msg = recordDispatch({ messageType: "MT202", payload: "MT202 payload" }); const rejected = rejectMessage(msg.reference, "bad coordinates")!; expect(rejected.state).toBe("rejected"); const afterAdvance = await advance(msg.reference); expect(afterAdvance!.state).toBe("rejected"); }); it("listMessages filters by planId", () => { recordDispatch({ messageType: "MT760", payload: "a", planId: "plan-a" }); recordDispatch({ messageType: "MT760", payload: "b", planId: "plan-b" }); recordDispatch({ messageType: "MT760", payload: "c", planId: "plan-a" }); expect(listMessages().length).toBe(3); expect(listMessages({ planId: "plan-a" }).length).toBe(2); }); }); describe("signature", () => { it("produces a stable 64-char hex HMAC", () => { const sig = finSignature("hello"); expect(sig).toMatch(/^[0-9a-f]{64}$/); expect(finSignature("hello")).toBe(sig); expect(finSignature("world")).not.toBe(sig); }); }); describe("HTTP router", () => { const app = express(); app.use("/fin", buildSandboxRouter()); beforeEach(() => resetSandboxForTests()); it("POST /fin/dispatch returns 202 + reference", async () => { const resp = await request(app) .post("/fin/dispatch") .send({ messageType: "MT760", payload: "mt760", planId: "plan-x" }) .expect(202); expect(resp.body.reference).toMatch(/^FIN-/); expect(resp.body.state).toBe("received"); }); it("POST /fin/dispatch 400s on missing payload", async () => { await request(app) .post("/fin/dispatch") .send({ messageType: "MT760" }) .expect(400); }); it("POST /fin/advance/:ref walks through lifecycle", async () => { const d = await request(app) .post("/fin/dispatch") .send({ messageType: "pacs.009", payload: "" }) .expect(202); const ref = d.body.reference; const a1 = await request(app).post(`/fin/advance/${ref}`).expect(200); expect(a1.body.state).toBe("acknowledged"); const a2 = await request(app).post(`/fin/advance/${ref}`).expect(200); expect(a2.body.state).toBe("accepted"); const a3 = await request(app).post(`/fin/advance/${ref}`).expect(200); expect(a3.body.state).toBe("settled"); }); it("GET /fin/messages?planId=... filters", async () => { await request(app) .post("/fin/dispatch") .send({ messageType: "MT760", payload: "a", planId: "p1" }); await request(app) .post("/fin/dispatch") .send({ messageType: "MT760", payload: "b", planId: "p2" }); const r = await request(app).get("/fin/messages?planId=p1").expect(200); expect(r.body.messages).toHaveLength(1); expect(r.body.messages[0].planId).toBe("p1"); }); it("GET /fin/messages/:ref returns 404 for unknown", async () => { await request(app).get("/fin/messages/FIN-UNKNOWN").expect(404); }); }); describe("client", () => { beforeEach(() => resetSandboxForTests()); it("createInProcessFinLinkClient dispatches and reads back", async () => { const client = await createInProcessFinLinkClient(); const ack = await client.dispatch({ messageType: "MT760", payload: "mt760", planId: "plan-ip", }); expect(ack.reference).toMatch(/^FIN-/); const msg = await client.getMessage(ack.reference); expect(msg?.planId).toBe("plan-ip"); }); it("createHttpFinLinkClient hits the live router", async () => { const app = express(); app.use("/fin", buildSandboxRouter()); const server = app.listen(0); try { const addr = server.address(); const port = typeof addr === "object" && addr ? addr.port : 0; const client = createHttpFinLinkClient(`http://127.0.0.1:${port}/fin`); const ack = await client.dispatch({ messageType: "pacs.009", payload: "", planId: "plan-http", }); expect(ack.reference).toMatch(/^FIN-/); const msg = await client.getMessage(ack.reference); expect(msg?.messageType).toBe("pacs.009"); const missing = await client.getMessage("FIN-DOES-NOT-EXIST"); expect(missing).toBeNull(); } finally { server.close(); } }); }); });