Initial Commit
This commit is contained in:
185
e2e/foundation-stack-saga/tests/saga.spec.ts
Normal file
185
e2e/foundation-stack-saga/tests/saga.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user