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");
|
||||
});
|
||||
});
|
||||
110
e2e/foundation-stack-saga/tests/twopc-postgres.spec.ts
Normal file
110
e2e/foundation-stack-saga/tests/twopc-postgres.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Stage-6 closure smoke — 2PC against a real Postgres testcontainer.
|
||||
*
|
||||
* The container is started by `global/postgres-setup.ts` with
|
||||
* `max_prepared_transactions=8` so `PREPARE TRANSACTION` is accepted.
|
||||
* Specs drive the `api-2pc` coordinator through commit + abort paths
|
||||
* and assert that `pg_prepared_xacts` is empty after every round.
|
||||
*/
|
||||
|
||||
interface TwoPcVote {
|
||||
readonly participantId: string;
|
||||
readonly vote: string;
|
||||
}
|
||||
interface TwoPcOutcome {
|
||||
readonly txId: string;
|
||||
readonly state: "committed" | "aborted" | "in_doubt";
|
||||
readonly votes: ReadonlyArray<TwoPcVote>;
|
||||
readonly prepareDurationMs: number;
|
||||
readonly commitDurationMs: number;
|
||||
}
|
||||
|
||||
test.describe("foundation-stack-saga — 2PC against real Postgres", () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const xacts = await request.get("/2pc/prepared-xacts");
|
||||
expect(xacts.status()).toBe(200);
|
||||
const body = (await xacts.json()) as { count: number; gids: string[] };
|
||||
if (body.count > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`pre-test pg_prepared_xacts non-empty: ${body.gids.join(", ")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("1. commit path — both participants COMMIT PREPARED; rows visible", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.post("/2pc/run", {
|
||||
data: { mode: "commit" },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
outcome: TwoPcOutcome;
|
||||
rows: { a: number; b: number };
|
||||
};
|
||||
expect(body.outcome.state).toBe("committed");
|
||||
expect(body.outcome.votes.map((v) => v.vote)).toEqual([
|
||||
"commit",
|
||||
"commit",
|
||||
]);
|
||||
expect(body.rows.a).toBe(1);
|
||||
expect(body.rows.b).toBe(1);
|
||||
|
||||
const xacts = await request.get("/2pc/prepared-xacts");
|
||||
const xBody = (await xacts.json()) as { count: number };
|
||||
expect(xBody.count).toBe(0);
|
||||
});
|
||||
|
||||
test("2. abort path — one participant votes abort; both roll back; rows absent", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.post("/2pc/run", {
|
||||
data: { mode: "abort" },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
error?: string;
|
||||
rows: { a: number; b: number };
|
||||
};
|
||||
expect(body.error).toBe("TWOPC.VOTE_ABORT");
|
||||
expect(body.rows.a).toBe(0);
|
||||
expect(body.rows.b).toBe(0);
|
||||
|
||||
const xacts = await request.get("/2pc/prepared-xacts");
|
||||
const xBody = (await xacts.json()) as { count: number };
|
||||
expect(xBody.count).toBe(0);
|
||||
});
|
||||
|
||||
test("3. recovery — orphan prepared xact is rolled back; rows absent", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.post("/2pc/recover-orphan");
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
gid: string;
|
||||
rowsAfter: number;
|
||||
};
|
||||
expect(typeof body.gid).toBe("string");
|
||||
expect(body.rowsAfter).toBe(0);
|
||||
|
||||
const xacts = await request.get("/2pc/prepared-xacts");
|
||||
const xBody = (await xacts.json()) as { count: number };
|
||||
expect(xBody.count).toBe(0);
|
||||
});
|
||||
|
||||
test("4. commit + abort round-trip leaves pg_prepared_xacts empty", async ({
|
||||
request,
|
||||
}) => {
|
||||
const r1 = await request.post("/2pc/run", { data: { mode: "commit" } });
|
||||
expect(r1.status()).toBe(200);
|
||||
const r2 = await request.post("/2pc/run", { data: { mode: "abort" } });
|
||||
expect(r2.status()).toBe(200);
|
||||
const xacts = await request.get("/2pc/prepared-xacts");
|
||||
const xBody = (await xacts.json()) as { count: number; gids: string[] };
|
||||
expect(xBody.count).toBe(0);
|
||||
expect(xBody.gids).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user