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