import { describe, expect, it } from "@suaveplan/testing/runner"; import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js"; import { createTwoPcInstruments } from "../instruments/instruments.js"; import type { RegisteredTwoPcParticipant } from "../types/types.types.js"; import type { ParticipantPrepareOutcome } from "./prepare.js"; import { isUnknownPreparedTransaction, rollbackPhaseTwo } from "./rollback.js"; const INSTRUMENTS = createTwoPcInstruments({ outstandingPreparedSnapshot: () => 0, }); const OUTCOME: ParticipantPrepareOutcome = { participantId: "billing", vote: "commit", prepared: true, gid: "2pc-h-t-billing", }; function makeParticipant(opts: { fail?: unknown } = {}) { const calls: string[] = []; const p: RegisteredTwoPcParticipant = { id: "billing", voterMode: "writer", connection: { engine: "postgres", run: async (sql: string) => { calls.push(sql); if (opts.fail !== undefined) throw opts.fail; return undefined; }, }, prepare: async () => ({ vote: "commit", writeIntent: true }), }; return { participant: p, calls }; } describe("rollbackPhaseTwo", () => { it("issues ROLLBACK PREPARED against a prepared writer", { timeout: 5000, }, async () => { const { participant, calls } = makeParticipant(); await rollbackPhaseTwo({ txId: "t", participant, outcome: OUTCOME, instruments: INSTRUMENTS, }); expect(calls[0]).toBe("ROLLBACK PREPARED '2pc-h-t-billing'"); }); it("is a no-op when outcome.prepared === false", { timeout: 5000, }, async () => { const { participant, calls } = makeParticipant(); await rollbackPhaseTwo({ txId: "t", participant, outcome: { ...OUTCOME, prepared: false, gid: "" }, instruments: INSTRUMENTS, }); expect(calls).toEqual([]); }); it("treats 42704 (undefined_object) as a no-op (idempotency)", { timeout: 5000, }, async () => { const cause = Object.assign(new Error("does not exist"), { code: "42704", }); const { participant } = makeParticipant({ fail: cause }); await expect( rollbackPhaseTwo({ txId: "t", participant, outcome: OUTCOME, instruments: INSTRUMENTS, }) ).resolves.toBeUndefined(); }); it("treats 'does not exist' message-only errors as a no-op", { timeout: 5000, }, async () => { const cause = new Error( "prepared transaction with identifier 'x' does not exist" ); const { participant } = makeParticipant({ fail: cause }); await expect( rollbackPhaseTwo({ txId: "t", participant, outcome: OUTCOME, instruments: INSTRUMENTS, }) ).resolves.toBeUndefined(); }); it("wraps a non-idempotency SQL throw into TWOPC.ROLLBACK_FAILED", { timeout: 5000, }, async () => { const cause = new Error("connection broken"); const { participant } = makeParticipant({ fail: cause }); try { await rollbackPhaseTwo({ txId: "t", participant, outcome: OUTCOME, instruments: INSTRUMENTS, }); throw new Error("should throw"); } catch (err) { expect(err).toBeInstanceOf(TwoPcError); expect((err as TwoPcError).twoPcCode).toBe( TWOPC_CODES.ROLLBACK_FAILED ); expect((err as TwoPcError).cause).toBe(cause); } }); }); describe("isUnknownPreparedTransaction", () => { it("returns true for SQLSTATE 42704", { timeout: 5000 }, () => { expect(isUnknownPreparedTransaction({ code: "42704" })).toBe(true); }); it("returns true for messages containing 'does not exist'", { timeout: 5000, }, () => { expect( isUnknownPreparedTransaction({ message: "tx X does not exist" }) ).toBe(true); }); it("returns false for null / undefined / primitives", { timeout: 5000, }, () => { expect(isUnknownPreparedTransaction(null)).toBe(false); expect(isUnknownPreparedTransaction(undefined)).toBe(false); expect(isUnknownPreparedTransaction(42)).toBe(false); }); it("returns false for unrelated errors", { timeout: 5000 }, () => { expect(isUnknownPreparedTransaction(new Error("other"))).toBe(false); }); });