186 lines
6.8 KiB
TypeScript
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");
|
|
});
|
|
});
|