import { assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { attendeeAccount, revenueAccount } from "#accounting/accounts.ts"; import { KIND } from "#accounting/kinds.ts"; import { backfillReversesGroup } from "#accounting/reverses-group.ts"; import { postTransfers } from "#accounting/store.ts"; import { getDb } from "#db/client.ts"; import { enableQueryLog, getQueryLog } from "#db/query-log.ts"; import type { TransferInput } from "#shared/ledger/types.ts"; import { expectStampedToBooking, forceLegLink, refundGroupOfBooking, refundLegLinks, seedUnattributedRefund, stampedReversesOf, } from "#test/shared/accounting/reverses-group/helpers.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { withDbFault } from "#test-utils/db-fault.ts"; import { withRequestContext } from "#test-utils/request-context.ts"; import { tx } from "#test-utils/transfer-factory.ts"; /** A refund leg no derivation can attribute — an event group nothing derived * ever lands on. `suffix` keeps several orphans' groups distinct. */ const orphanRefundLeg = (suffix = ""): TransferInput => tx({ amount: 100, destination: attendeeAccount(9), eventGroup: `evt-orphan-refund${suffix}`, kind: KIND.refundSale, reference: `orphan-refund-sale${suffix}`, source: revenueAccount(99), }); /** The stored cursor checkpoint, or "" when no run is paused. */ const storedCursor = async (): Promise => { const rows = await getDb().execute({ args: ["backfill_reverses_group_cursor"], sql: "SELECT value FROM settings WHERE key = ?", }); return String(rows.rows[0]?.value ?? ""); }; describeWithEnv("accounting > reverses-group backfill", { db: true }, () => { test("attributes every stored refund leg to the order it reversed", async () => { const bookingGroup = await seedUnattributedRefund( "reverses-group-order", 7, ); await backfillReversesGroup(); expect( await stampedReversesOf(await refundGroupOfBooking(bookingGroup)), ).toEqual(new Set([bookingGroup])); }); test("stamps several orders' reversals in the same run", async () => { const first = await seedUnattributedRefund("reverses-group-order", 7); const second = await seedUnattributedRefund("reverses-group-second", 8); await backfillReversesGroup(); await expectStampedToBooking(first); await expectStampedToBooking(second); }); test("is idempotent: a re-run writes nothing and still verifies", async () => { const bookingGroup = await seedUnattributedRefund( "reverses-group-rerun", 8, ); await backfillReversesGroup(); await backfillReversesGroup(); expect( await stampedReversesOf(await refundGroupOfBooking(bookingGroup)), ).toEqual(new Set([bookingGroup])); }); test("resumes at its checkpoint after a mid-walk stop", async () => { // A page-size of 1 puts each order on its own page. A trigger that // aborts the SECOND page's stamp models a crash mid-walk: the first // page is committed, its checkpoint written, the rest unprocessed. const first = await seedUnattributedRefund("reverses-group-page-a", 7); const second = await seedUnattributedRefund("reverses-group-page-b", 8); const laterGroup = [first, second].sort()[1]!; const stampsOf = async (bookingGroup: string): Promise> => stampedReversesOf(await refundGroupOfBooking(bookingGroup)); await expect( withDbFault( `CREATE TRIGGER test_reverses_page_stop BEFORE UPDATE ON transfers WHEN NEW.reverses_group = '${laterGroup}' BEGIN SELECT RAISE(ABORT, 'stop before the later page'); END`, "test_reverses_page_stop", () => backfillReversesGroup(1), ), ).rejects.toThrow("stop before the later page"); // One page committed and the checkpoint names it; the run stopped with // the other order's link still empty. expect( (await stampsOf(first)).has(first) !== (await stampsOf(second)).has(second), ).toBe(true); expect(await storedCursor()).not.toBe(""); await backfillReversesGroup(1); await expectStampedToBooking(first); await expectStampedToBooking(second); // A finished run leaves the terminal mark, not a walk position. expect(await storedCursor()).toBe("complete"); }); test("a clean finish keeps a terminal mark a retry reads instead of re-walking", async () => { // The migration runner verifies and marks the migration only AFTER up() // returns. A request whose database budget ended exactly at the finish // never verified, so its retry reads this mark and skips the walk — a // deleted mark would make that retry re-walk every page, and the same // budget alignment would repeat forever and block the upgrade. const bookingGroup = await seedUnattributedRefund( "reverses-group-terminal", 7, ); await backfillReversesGroup(); expect(await storedCursor()).toBe("complete"); await withRequestContext(async () => { enableQueryLog(); await backfillReversesGroup(); const ran = getQueryLog().map((entry) => entry.sql); expect(ran.length).toBe(1); assertStringIncludes(ran[0] ?? "", "FROM settings WHERE key = ?"); }); await expectStampedToBooking(bookingGroup); }); test("fills only empty links, never one an operator already set", async () => { const bookingGroup = await seedUnattributedRefund( "reverses-group-preset", 7, ); const refundGroup = await refundGroupOfBooking(bookingGroup); const legs = await refundLegLinks(refundGroup); // An operator's repair holds a foreign link: the backfill must leave it // alone even while attributing the sibling legs it came in with. const repaired = legs[0]!; await forceLegLink(repaired.id, "evt-operator-repair"); await backfillReversesGroup(); const after = await refundLegLinks(refundGroup); expect(after.find((leg) => leg.id === repaired.id)!.reverses_group).toBe( "evt-operator-repair", ); for (const leg of after) { if (leg.id === repaired.id) continue; expect(leg.reverses_group).toBe(bookingGroup); } }); test("names the orphan refund event and fails rather than guess", async () => { await postTransfers([orphanRefundLeg()]); await expect(backfillReversesGroup()).rejects.toThrow( "refund legs with no booking order they reverse: evt-orphan-refund" + " — repair the orphaned refund legs or their missing order, then re-run", ); }); test("names every orphan in one comma-separated report", async () => { // Each orphan event group is its own post: the store refuses one call // carrying legs from two events. await postTransfers([orphanRefundLeg()]); await postTransfers([orphanRefundLeg("-two")]); await expect(backfillReversesGroup()).rejects.toThrow( "refund legs with no booking order they reverse: evt-orphan-refund," + " evt-orphan-refund-two — repair the orphaned refund legs or their" + " missing order, then re-run", ); }); test("refuses a page size that would walk nothing and still mark complete", async () => { // A page size of 0 walks no page and scans no orphan, so without the // refusal it would write the terminal mark over unstamped legs and the // walk would stay skipped forever. const bookingGroup = await seedUnattributedRefund( "reverses-group-zero-page", 7, ); await expect(backfillReversesGroup(0)).rejects.toThrow( "backfillReversesGroup: pageSize must be a positive whole number, got 0", ); expect(await storedCursor()).toBe(""); expect( await stampedReversesOf(await refundGroupOfBooking(bookingGroup)), ).toEqual(new Set([""])); }); test("an orphan refusal leaves no checkpoint behind", async () => { // Page size 1 makes the walk checkpoint real pages before the final scan // refuses on the orphan. const first = await seedUnattributedRefund("reverses-group-refuse-a", 7); const second = await seedUnattributedRefund("reverses-group-refuse-b", 8); await postTransfers([orphanRefundLeg()]); await expect(backfillReversesGroup(1)).rejects.toThrow( "refund legs with no booking order they reverse: evt-orphan-refund", ); // The refusal is a finished run: an operator repairs an orphan by adding // legs that sort before the walked cursor, so a left-behind checkpoint // would make the retried walk skip the repaired region forever. expect(await storedCursor()).toBe(""); await expectStampedToBooking(first); await expectStampedToBooking(second); }); });