Files
2026-05-27 23:29:53 +01:00

186 lines
6.8 KiB
TypeScript

import { expect, test } from "@playwright/test";
/**
* Stage-6 closure smoke — saga orchestration across two plugins.
*
* The orchestrator owns the saga (`plugin-a`); `plugin-a` exports
* step-1 + step-3, `plugin-b` exports step-2. The smoke runs the
* happy path, then a step-2-failure path that triggers reverse-order
* compensation across the two plugins.
*/
interface SagaInstance {
readonly id: string;
readonly state: string;
readonly pluginName: string;
readonly payload: {
readonly sagaName: string;
readonly currentStepIndex: number;
readonly tenantId: string;
};
}
interface SagaStep {
readonly sagaId: string;
readonly stepIndex: number;
readonly name: string;
readonly state: string;
}
interface ParticipantOp {
readonly plugin: string;
readonly action: string;
readonly kind: "do" | "compensate";
readonly at: number;
}
interface SagaEvent {
readonly name: string;
readonly payload: Record<string, unknown>;
readonly at: number;
}
test.describe("foundation-stack-saga — 3-step compensable workflow", () => {
test.beforeEach(async ({ request }) => {
const res = await request.post("/saga/reset");
expect(res.status()).toBe(200);
});
test("1. happy path — all 3 steps complete; no compensation", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "none" },
});
expect(start.status()).toBe(200);
const { sagaId, state } = (await start.json()) as {
sagaId: string;
state: string;
};
expect(typeof sagaId).toBe("string");
expect(state).toBe("completed");
const inst = await request.get(`/saga/${sagaId}`);
expect(inst.status()).toBe(200);
const body = (await inst.json()) as {
instance: SagaInstance;
steps: SagaStep[];
};
expect(body.instance.state).toBe("completed");
expect(body.instance.payload.sagaName).toBe("smoke-three-step");
expect(body.steps).toHaveLength(3);
expect(body.steps.map((s) => s.state)).toEqual([
"completed",
"completed",
"completed",
]);
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const aDoActions = aBody.ops
.filter((o) => o.kind === "do")
.map((o) => o.action);
expect(aDoActions).toEqual(["step-one", "step-three"]);
expect(aBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0);
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
expect(
bBody.ops.filter((o) => o.kind === "do" && o.action === "step-two")
).toHaveLength(1);
expect(bBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0);
const events = await request.get(`/saga/${sagaId}/events`);
const eBody = (await events.json()) as { events: SagaEvent[] };
const names = eBody.events.map((e) => e.name);
expect(names).toContain("saga.instance.started");
expect(names).toContain("saga.instance.completed");
expect(names.filter((n) => n.includes("compensat"))).toHaveLength(0);
expect(names.filter((n) => n === "saga.step.completed")).toHaveLength(
3
);
});
test("2. step-2 fails — reverse-order compensation across both plugins", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "step-2" },
});
expect(start.status()).toBe(200);
const { sagaId, state } = (await start.json()) as {
sagaId: string;
state: string;
};
expect(state).toBe("compensated");
const inst = await request.get(`/saga/${sagaId}`);
const body = (await inst.json()) as {
instance: SagaInstance;
steps: SagaStep[];
};
expect(body.instance.state).toBe("compensated");
expect(body.steps).toHaveLength(3);
const byIndex: Record<number, string> = {};
for (const s of body.steps) {
byIndex[s.stepIndex] = s.state;
}
// Step 1 was completed then compensated; step 2 failed; step 3
// never ran.
expect(byIndex[0]).toBe("compensated");
expect(byIndex[1]).toBe("failed");
expect(byIndex[2]).toBe("pending");
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const aActions = aBody.ops.map((o) => `${o.action}:${o.kind}`);
expect(aActions).toEqual(["step-one:do", "step-one:compensate"]);
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
const bActions = bBody.ops.map((o) => `${o.action}:${o.kind}`);
expect(bActions).toEqual(["step-two:do"]);
const events = await request.get(`/saga/${sagaId}/events`);
const eBody = (await events.json()) as { events: SagaEvent[] };
const names = eBody.events.map((e) => e.name);
expect(names).toContain("saga.instance.started");
expect(names).toContain("saga.step.failed");
expect(names).toContain("saga.step.compensated");
expect(names).toContain("saga.instance.compensated");
expect(names.filter((n) => n === "saga.step.completed")).toHaveLength(
1
);
});
test("3. cross-plugin invocation proves SagaParticipantRegistry routes correctly", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "none" },
});
const { sagaId } = (await start.json()) as { sagaId: string };
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
const interleaved = [...aBody.ops, ...bBody.ops].sort(
(x, y) => x.at - y.at
);
const trace = interleaved
.filter((o) => o.kind === "do")
.map((o) => `${o.plugin}/${o.action}`);
expect(trace).toEqual([
"plugin-a/step-one",
"plugin-b/step-two",
"plugin-a/step-three",
]);
const inst = await request.get(`/saga/${sagaId}`);
const body = (await inst.json()) as { instance: SagaInstance };
expect(body.instance.state).toBe("completed");
});
});