Files
suaveplan/e2e/foundation-stack-saga/tests/twopc-postgres.spec.ts
2026-05-27 23:29:53 +01:00

111 lines
3.9 KiB
TypeScript

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([]);
});
});