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
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.
183 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
});
|