test/shared/accounting/rows.test.ts
Rama main · réplica de lectura del repositorio original.
import { assert, assertArrayIncludes, assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import {
bookingLegBatchInsert,
insertStatement,
selectByEventGroup,
selectById,
selectTransfers,
selectTransfersMany,
} from "#accounting/rows.ts";
import {
type BatchExecutor,
executeBatch,
queryBatch,
queryOne,
type SqlStatement,
type TxScope,
withTransaction,
} from "#db/client.ts";
import { inList } from "#db/where-clauses.ts";
import { account } from "#shared/ledger/account.ts";
import type { TransferInput } from "#shared/ledger/types.ts";
import { emptyResultSet } from "#test-utils/db-helpers/result-set.ts";
import { useTransactionalDb } from "#test-utils/ledger.ts";
/** The value bound to `column` in a built INSERT, by pairing the SQL's leading
* column list with its positional args. */
const boundValue = (
statement: { sql: string; args: readonly unknown[] },
column: string,
): unknown => {
const columns = statement.sql
.slice(statement.sql.indexOf("(") + 1, statement.sql.indexOf(")"))
.split(",")
.map((name) => name.trim());
return statement.args[columns.indexOf(column)];
};
describe("accounting > rows > insertStatement", () => {
const base: TransferInput = {
amount: 5000,
destination: account("revenue", 1),
eventGroup: "evt",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref",
source: account("attendee", 1),
};
const recordedAt = "2026-06-21T12:00:00.000Z";
test("defaults posted_by and reverses_id only when absent, preserving explicit edge values", () => {
// `?? "system"` / `?? null` default solely on undefined, so an explicit ""
// actor and a 0 reverses_id are kept — a `|| "system"` / `|| null` would
// wrongly replace the empty string and the zero.
const explicit = insertStatement(
{ ...base, postedBy: "", reversesId: 0 },
recordedAt,
);
expect(boundValue(explicit, "posted_by")).toBe("");
expect(boundValue(explicit, "reverses_id")).toBe(0);
const absent = insertStatement(base, recordedAt);
expect(boundValue(absent, "posted_by")).toBe("system");
expect(boundValue(absent, "reverses_id")).toBe(null);
});
});
describe("accounting > rows > bookingLegBatchInsert", () => {
const recordedAt = "2026-06-21T12:00:00.000Z";
// Distinct attendee (3) and revenue (9) ids, so a swapped side is visible:
// the attendee literal must vanish (replaced by the subquery) while the
// revenue literal stays bound.
const leg: TransferInput = {
amount: 5000,
destination: account("revenue", 9),
eventGroup: "evt",
kind: "sale",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref",
source: account("attendee", 3),
};
const guard = { args: ["g"], sql: "? = 'g'" };
test("renders ONLY the attendee side via the in-batch id subquery", () => {
const built = bookingLegBatchInsert(leg, recordedAt, "MAX(id)", 7, guard);
// source is the attendee account -> subquery; destination (revenue) stays a
// bound literal. Swapping the two would post the sale to a phantom account.
assertStringIncludes(built.sql, "CAST(MAX(id) AS TEXT)");
expect(built.sql.indexOf("CAST(MAX(id) AS TEXT)")).toBe(
built.sql.lastIndexOf("CAST(MAX(id) AS TEXT)"),
);
assertArrayIncludes(built.args, [7]); // the subquery's bound arg
assertArrayIncludes(built.args, ["9"]); // revenue id still a bound literal
assert(!built.args.includes("3")); // attendee id replaced by subquery
expect(built.args.at(-1)).toBe("g"); // guard args come last
expect(built.sql).toMatch(/^INSERT OR IGNORE INTO transfers /);
assertStringIncludes(built.sql, "WHERE ? = 'g'");
});
});
/** A reader that answers nothing but remembers what it was handed. */
const recordingReader = (): {
asked: SqlStatement[][];
reader: BatchExecutor;
} => {
const asked: SqlStatement[][] = [];
const reader: BatchExecutor = (statements) => {
asked.push([...statements]);
return Promise.resolve(statements.map(() => emptyResultSet()));
};
return { asked, reader };
};
const STORED_AT = "2026-06-21T12:00:00.000Z";
const leg = (reference: string, eventGroup: string): TransferInput => ({
amount: 5000,
destination: account("revenue", 7),
eventGroup,
occurredAt: "2026-06-21T00:00:00.000Z",
reference,
source: account("attendee", 3),
});
/** Two legs under different events, so a reader that ignores its filter and
* returns everything is visible. */
const storeTwoLegs = (): Promise<void> =>
executeBatch([
insertStatement(leg("ref-a", "evt-a"), STORED_AT),
insertStatement(leg("ref-b", "evt-b"), STORED_AT),
]);
/** The id a reference was stored under, read straight from the table: a test of
* one reader must not lean on another to set itself up. */
const storedIdFor = async (reference: string): Promise<number> => {
const row = await queryOne<{ id: number | bigint }>(
"SELECT id FROM transfers WHERE reference = ?",
[reference],
);
if (row === null) throw new Error(`Nothing was stored for ${reference}`);
return Number(row.id);
};
describe("accounting > rows > selectTransfersMany", () => {
useTransactionalDb();
test("gives each set of rows back to the query that asked for it", async () => {
await storeTwoLegs();
const [byGroup, byReference] = await selectTransfersMany(queryBatch, [
{ where: inList("event_group", ["evt-b"]) },
{ where: inList("reference", ["ref-a"]) },
]);
// Swapped answers would read as a stored collision that is not there.
expect(byGroup.map((row) => row.reference)).toEqual(["ref-b"]);
expect(byReference.map((row) => row.reference)).toEqual(["ref-a"]);
});
test("answers a query nothing can match without asking for it", async () => {
const { asked, reader } = recordingReader();
const [none, some] = await selectTransfersMany(reader, [
{ where: inList("id", []) },
{ where: inList("reference", ["ref-a"]) },
]);
expect(none).toEqual([]);
expect(some).toEqual([]);
expect(asked).toHaveLength(1);
expect(asked[0]?.map((statement) => statement.args)).toEqual([["ref-a"]]);
});
test("asks for nothing at all when no query can match", async () => {
const { asked, reader } = recordingReader();
const rows = await selectTransfersMany(reader, [
{ where: inList("id", []) },
{ where: inList("reference", []) },
]);
expect(rows).toEqual([[], []]);
expect(asked).toEqual([]);
});
test("asks an open transaction once, not once per query", async () => {
await storeTwoLegs();
let batches = 0;
let singles = 0;
const rows = await withTransaction((scope) => {
const counted: TxScope = {
batch: (statements) => {
batches += 1;
return scope.batch(statements);
},
execute: (statement) => {
singles += 1;
return scope.execute(statement);
},
};
return selectTransfersMany(counted.batch, [
{ where: inList("event_group", ["evt-a"]) },
{ where: inList("reference", ["ref-b"]) },
]);
});
expect(batches).toBe(1);
expect(singles).toBe(0);
expect(rows.map((set) => set.map((row) => row.reference))).toEqual([
["ref-a"],
["ref-b"],
]);
});
});
describe("accounting > rows > readers for one event and one row", () => {
useTransactionalDb();
test("selectByEventGroup reads that event's legs and not its neighbour's", async () => {
await storeTwoLegs();
const legs = await selectByEventGroup(queryBatch, "evt-a");
expect(legs.map((row) => row.reference)).toEqual(["ref-a"]);
});
test("selectById reads the transfer stored under that id", async () => {
await storeTwoLegs();
const id = await storedIdFor("ref-b");
const found = await selectById(queryBatch, id);
expect(found?.id).toBe(id);
expect(found?.reference).toBe("ref-b");
});
test("selectById answers null when no transfer has that id", async () => {
await storeTwoLegs();
expect(await selectById(queryBatch, 999_999)).toBeNull();
});
});
describe("accounting > rows > stored-row round-trip", () => {
useTransactionalDb();
const recordedAt = "2026-06-21T12:00:00.000Z";
test("selectTransfers reads every column back, reversesId present and absent", async () => {
const plain: TransferInput = {
amount: 5000,
destination: account("revenue", 7),
eventGroup: "evt-plain",
kind: "sale",
memo: "first",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref-plain",
source: account("attendee", 3),
};
// A void leg carrying a (non-FK) reverses_id, so the NULL vs real-id branch of
// the row→Transfer mapping is exercised both ways.
const voiding: TransferInput = {
...plain,
eventGroup: "evt-void",
kind: "void",
reference: "ref-void",
reversesId: 999,
};
await executeBatch([
insertStatement(plain, recordedAt),
insertStatement(voiding, recordedAt),
]);
const all = await selectTransfers(queryBatch, { order: "id" });
expect(all.length).toBe(2);
const [first, second] = all;
// Full-fidelity round-trip — a corrupted SELECT column list loses these.
expect(first!.amount).toBe(5000);
expect(first!.source).toEqual(account("attendee", 3));
expect(first!.destination).toEqual(account("revenue", 7));
expect(first!.reference).toBe("ref-plain");
expect(first!.eventGroup).toBe("evt-plain");
expect(first!.kind).toBe("sale");
expect(first!.memo).toBe("first");
expect(first!.occurredAt).toBe("2026-06-21T00:00:00.000Z");
// NULL reverses_id maps to undefined; a real id maps to the Number.
expect(first!.reversesId).toBeUndefined();
expect(second!.reversesId).toBe(999);
});
test("a kindless leg stores as '' and reads back with kind omitted", async () => {
const kindless: TransferInput = {
amount: 100,
destination: account("revenue", 7),
eventGroup: "evt-kindless",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref-kindless",
source: account("attendee", 3),
};
await executeBatch([insertStatement(kindless, recordedAt)]);
const [stored] = await selectTransfers(queryBatch);
// Omitted, not "": a stored transfer and a never-stored input must agree
// on what "no kind" looks like (mirroring reverses_id).
expect(stored!.kind).toBeUndefined();
expect("kind" in stored!).toBe(false);
});
test("a leg with no memo stores an empty one, not a stand-in", async () => {
const memoless: TransferInput = {
amount: 100,
destination: account("revenue", 7),
eventGroup: "evt-memoless",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref-memoless",
source: account("attendee", 3),
};
await executeBatch([insertStatement(memoless, recordedAt)]);
const [stored] = await selectTransfers(queryBatch);
// Unlike kind, memo keeps its "" — so the absence must be stored as empty
// rather than as any filler a reader would later show to an operator.
expect(stored!.memo).toBe("");
});
test("a reversal leg reads back naming its order; a plain leg carries no mark", async () => {
// A refund leg and a plain leg of the same order: the round-trip must
// return the reversed order's group on the refund leg and omit the field
// for a leg with none, mirroring `reverses_id`'s NULL-vs-id mapping.
const refund: TransferInput = {
amount: 5000,
destination: account("attendee", 3),
eventGroup: "evt-refund",
kind: "refund_sale",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref-refund",
reversesGroup: "evt-order",
source: account("revenue", 7),
};
const plain: TransferInput = {
amount: 5000,
destination: account("revenue", 7),
eventGroup: "evt-order",
kind: "sale",
occurredAt: "2026-06-21T00:00:00.000Z",
reference: "ref-sale",
source: account("attendee", 3),
};
await executeBatch([
insertStatement(plain, recordedAt),
insertStatement(refund, recordedAt),
]);
const all = await selectTransfers(queryBatch, { order: "id" });
const [sale, reversal] = all;
expect(sale!.reversesGroup).toBeUndefined();
expect("reversesGroup" in sale!).toBe(false);
expect(reversal!.reversesGroup).toBe("evt-order");
});
});