test/shared/accounting/conflicts.test.ts
Rama main · réplica de lectura del repositorio original.
import { assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import {
LedgerConflictError,
reversalConflict,
} from "#accounting/conflicts.ts";
import { allTransfers, transfersByEventGroup } from "#accounting/queries.ts";
import { postTransfers } from "#accounting/store.ts";
import { account } from "#shared/ledger/account.ts";
import type { TransferInput } from "#shared/ledger/types.ts";
import {
rejection,
saleAndPayment,
tx,
useTransactionalDb,
} from "#test-utils/ledger.ts";
describe("db > accounting > conflicts", () => {
useTransactionalDb();
describe("replay divergence", () => {
test("rejects a replay whose leg changed financial facts", async () => {
await postTransfers([tx({ amount: 5000, reference: "sale-1" })]);
const error = await rejection(
postTransfers([tx({ amount: 9999, reference: "sale-1" })]),
);
expect(error).toBeInstanceOf(LedgerConflictError);
expect(error.name).toBe("LedgerConflictError");
assertStringIncludes(error.message, "amount");
expect((await allTransfers()).length).toBe(1);
});
test("rejects a replay that adds a leg to a posted event", async () => {
await postTransfers(saleAndPayment());
const error = await rejection(
postTransfers([
...saleAndPayment(),
tx({
destination: account("fee_income", "booking"),
reference: "fee-1",
}),
]),
);
expect(error).toBeInstanceOf(LedgerConflictError);
expect((await allTransfers()).length).toBe(2);
});
test("rejects a replay that drops a leg from a posted event", async () => {
await postTransfers(saleAndPayment());
const error = await rejection(
postTransfers([
tx({ reference: "sale-1", source: account("attendee", 1) }),
]),
);
expect(error).toBeInstanceOf(LedgerConflictError);
expect((await allTransfers()).length).toBe(2);
});
test("replays the same instant in a different ISO form as a no-op", async () => {
// The store persists time as epoch-millis and reads it back canonical, so
// a replay carrying the same moment without milliseconds, or as an offset,
// must match the stored leg rather than read as an occurredAt conflict.
await postTransfers([tx({ occurredAt: "2026-06-21T00:00:00Z" })]);
expect(
await postTransfers([tx({ occurredAt: "2026-06-21T01:00:00+01:00" })]),
).toEqual({ inserted: 0, skipped: 1 });
expect((await allTransfers()).length).toBe(1);
});
test("preserves kind/memo/posted_by/reverses_id and replays them", async () => {
// A reversal always references an already-stored leg (the admin-void shape):
// post the original as its own event, read back its id, then void it in a
// separate event that links to that stored id — never a leg being inserted
// in the same call.
await postTransfers([tx({ eventGroup: "e0", reference: "orig" })]);
const origId = (await transfersByEventGroup("e0"))[0]!.id;
const voidLegs = [
tx({
destination: account("attendee", 1),
eventGroup: "e1",
kind: "reversal",
memo: "owner-key-ciphertext",
postedBy: "user:5",
reference: "void",
reversesId: origId,
source: account("revenue", 1),
}),
];
expect(await postTransfers(voidLegs)).toEqual({
inserted: 1,
skipped: 0,
});
// Replaying exercises the reversesId/kind equality path too.
expect(await postTransfers(voidLegs)).toEqual({
inserted: 0,
skipped: 1,
});
const voided = (await transfersByEventGroup("e1"))[0]!;
expect(voided.kind).toBe("reversal");
expect(voided.memo).toBe("owner-key-ciphertext");
expect(voided.postedBy).toBe("user:5");
expect(voided.reversesId).toBe(origId);
const orig = (await transfersByEventGroup("e0"))[0]!;
expect(orig.reversesId).toBeUndefined();
// A kindless leg round-trips to an omitted kind, like reverses_id.
expect(orig.kind).toBeUndefined();
});
});
describe("reversal links", () => {
const postSale = (): Promise<unknown> =>
postTransfers([
tx({
destination: account("revenue", 1),
eventGroup: "evt-1",
reference: "sale",
source: account("attendee", 1),
}),
]);
const storedSaleId = async (): Promise<number> =>
(await transfersByEventGroup("evt-1"))[0]!.id;
const voidLeg = (overrides: Partial<TransferInput>): TransferInput =>
tx({
destination: account("attendee", 1),
eventGroup: "evt-2",
reference: "void",
source: account("revenue", 1),
...overrides,
});
test("a leg with no reverses_id passes trivially, even against a null original", () => {
// The absent-link short-circuit must return before the "refers to no
// transfer" guard, regardless of the original passed (a `||` instead of the
// `||`-less guard would fall through and wrongly throw here).
expect(reversalConflict(tx({ reference: "plain" }), null)).toBeNull();
});
test("accepts a leg that is the exact inverse of the original", async () => {
await postSale();
const result = await postTransfers([
voidLeg({ reversesId: await storedSaleId() }),
]);
expect(result.inserted).toBe(1);
});
test("rejects a reverses_id that refers to no transfer", async () => {
assertStringIncludes(
(await rejection(postTransfers([voidLeg({ reversesId: 9999 })])))
.message,
"refers to no transfer",
);
});
test("rejects a reversal whose amount differs from the original", async () => {
await postSale();
const error = await rejection(
postTransfers([
voidLeg({ amount: 4000, reversesId: await storedSaleId() }),
]),
);
assertStringIncludes(error.message, "not the exact inverse");
});
test("rejects a reversal that does not swap the accounts", async () => {
await postSale();
const error = await rejection(
postTransfers([
voidLeg({
destination: account("revenue", 1),
reversesId: await storedSaleId(),
source: account("attendee", 1),
}),
]),
);
assertStringIncludes(error.message, "not the exact inverse");
});
});
});