Files
CurrenciCombo/orchestrator/tests/unit/participants.test.ts
Devin 5741fd5d9b
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 7s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 10s
CI / Orchestrator Build (pull_request) Failing after 6s
CI / Contracts Compile (pull_request) Failing after 7s
CI / Contracts Test (pull_request) Failing after 5s
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 7s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Persistent participant registry (plans_participants + API)
Closes gap-analysis v2 §4 + §10.9.

- Migration 005 adds plans_participants (plan_id, participant_id, role,
  lei, did, created_at) with a composite UNIQUE(plan_id, role,
  participant_id) and CHECK on role vocabulary matching types/plan.ts.
- services/participants.ts: idempotent add() / bulkAdd() / listForPlan() /
  listByRole() / remove() — the SoD layer reads listByRole() to resolve
  the authorised actor set for a transition.
- api/participants.ts + wiring in index.ts:
    GET    /api/plans/:planId/participants
    POST   /api/plans/:planId/participants          -> participants.authorized
    DELETE /api/plans/:planId/participants/:role/:participantId
- 7 unit tests against a Map-backed query stub; full suite 87/87 green.
2026-04-22 18:15:26 +00:00

183 lines
5.6 KiB
TypeScript

/**
* Tests for the plans_participants registry service (arch §4 + gap v2 §10.9).
* Stubs the Postgres `query` to a Map-backed fake so we can exercise the
* service contract without standing up a real database.
*/
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
interface Row {
id: string;
plan_id: string;
participant_id: string;
role: string;
lei: string | null;
did: string | null;
created_at: string;
}
const rows: Row[] = [];
let rowCounter = 1;
jest.mock("../../src/db/postgres", () => ({
query: async (sql: string, params: unknown[] = []) => {
if (sql.startsWith("INSERT INTO plans_participants")) {
const [plan_id, participant_id, role, lei, did] = params as [
string, string, string, string | null, string | null,
];
const existing = rows.find(
(r) =>
r.plan_id === plan_id &&
r.role === role &&
r.participant_id === participant_id,
);
if (existing) {
if (lei !== null) existing.lei = lei;
if (did !== null) existing.did = did;
return [
{
plan_id: existing.plan_id,
id: existing.participant_id,
role: existing.role,
lei: existing.lei,
did: existing.did,
created_at: existing.created_at,
},
];
}
const row: Row = {
id: `row-${rowCounter++}`,
plan_id,
participant_id,
role,
lei,
did,
created_at: new Date().toISOString(),
};
rows.push(row);
return [
{
plan_id: row.plan_id,
id: row.participant_id,
role: row.role,
lei: row.lei,
did: row.did,
created_at: row.created_at,
},
];
}
if (sql.startsWith("DELETE FROM plans_participants")) {
const [plan_id, role, participant_id] = params as [string, string, string];
const idx = rows.findIndex(
(r) =>
r.plan_id === plan_id &&
r.role === role &&
r.participant_id === participant_id,
);
if (idx === -1) return [];
const [removed] = rows.splice(idx, 1);
return [{ id: removed.id }];
}
if (sql.startsWith("SELECT") && sql.includes("FROM plans_participants") && sql.includes("WHERE plan_id = $1 AND role = $2")) {
const [plan_id, role] = params as [string, string];
return rows
.filter((r) => r.plan_id === plan_id && r.role === role)
.map((r) => ({
plan_id: r.plan_id,
id: r.participant_id,
role: r.role,
lei: r.lei,
did: r.did,
created_at: r.created_at,
}));
}
if (sql.includes("FROM plans_participants") && sql.includes("WHERE plan_id = $1")) {
const [plan_id] = params as [string];
return rows
.filter((r) => r.plan_id === plan_id)
.map((r) => ({
plan_id: r.plan_id,
id: r.participant_id,
role: r.role,
lei: r.lei,
did: r.did,
created_at: r.created_at,
}));
}
return [];
},
}));
import * as participants from "../../src/services/participants";
describe("participants service", () => {
beforeEach(() => {
rows.splice(0, rows.length);
rowCounter = 1;
});
it("persists a participant and returns the row", async () => {
const r = await participants.add("plan-1", {
id: "p-42",
role: "applicant",
lei: "HWUPKR0MPOU8FGXBT394",
});
expect(r.id).toBe("p-42");
expect(r.role).toBe("applicant");
expect(r.lei).toBe("HWUPKR0MPOU8FGXBT394");
});
it("is idempotent on (plan_id, role, participant_id)", async () => {
await participants.add("plan-1", { id: "p-1", role: "applicant" });
await participants.add("plan-1", { id: "p-1", role: "applicant" });
const all = await participants.listForPlan("plan-1");
expect(all).toHaveLength(1);
});
it("allows the same id in a different role on the same plan", async () => {
await participants.add("plan-1", { id: "p-1", role: "applicant" });
await participants.add("plan-1", { id: "p-1", role: "coordinator" });
const all = await participants.listForPlan("plan-1");
expect(all.map((r) => r.role).sort()).toEqual([
"applicant",
"coordinator",
]);
});
it("listByRole filters correctly for SoD lookups", async () => {
await participants.add("plan-1", { id: "bank-A", role: "issuing_bank" });
await participants.add("plan-1", { id: "bank-B", role: "beneficiary_bank" });
const issuers = await participants.listByRole("plan-1", "issuing_bank");
expect(issuers).toHaveLength(1);
expect(issuers[0].id).toBe("bank-A");
});
it("rejects unknown roles", async () => {
await expect(
participants.add("plan-1", {
id: "p-1",
role: "intruder" as unknown as "applicant",
}),
).rejects.toThrow(/unknown participant role/);
});
it("bulkAdd persists every participant in order", async () => {
const out = await participants.bulkAdd("plan-1", [
{ id: "a", role: "applicant" },
{ id: "b", role: "issuing_bank" },
{ id: "c", role: "beneficiary" },
]);
expect(out).toHaveLength(3);
expect(out.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
it("remove() returns 0 for missing row and 1 on success", async () => {
expect(
await participants.remove("plan-1", "applicant", "nobody"),
).toBe(0);
await participants.add("plan-1", { id: "p-1", role: "applicant" });
expect(await participants.remove("plan-1", "applicant", "p-1")).toBe(1);
expect(await participants.listForPlan("plan-1")).toHaveLength(0);
});
});