93 lines
4.4 KiB
TypeScript
93 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi } from "vitest"
|
|
import { positiveMinor, type RecordTerms, recordTerms, revision } from "../contract/index.js"
|
|
import { recordBalanced, transition, validateBalanced } from "../core/index.js"
|
|
import type { LedgerService } from "../core/ports.js"
|
|
import { retryIdentity } from "../ui/retry.js"
|
|
|
|
export const A = "acc_00000000-0000-4000-8000-000000000001"
|
|
export const B = "acc_00000000-0000-4000-8000-000000000002"
|
|
export const T = "txn_00000000-0000-4000-8000-000000000001"
|
|
export const terms: RecordTerms = {
|
|
effectiveAt: "2026-11-03T00:00:00Z",
|
|
description: "Synthetic transfer",
|
|
status: "pending",
|
|
entries: [
|
|
{ accountId: A, direction: "debit", amountMinor: "9007199254740993" },
|
|
{ accountId: B, direction: "credit", amountMinor: "9007199254740993" },
|
|
],
|
|
}
|
|
export const service = () =>
|
|
({
|
|
verify: vi.fn(async () => ({ checkedAccounts: 2, drift: [] as string[] })),
|
|
account: vi.fn(async (id: string) => ({ accountId: id, currency: "EUR", status: "active" })),
|
|
record: vi.fn(async () => ({ transactionId: T })),
|
|
transition: vi.fn(async () => ({ transactionId: T })),
|
|
}) as unknown as LedgerService
|
|
|
|
describe("exact ledger core and retry policy (port tier)", () => {
|
|
it("accepts i64 maximum and refuses overflow, zero, floats and unsafe numeric inputs", () => {
|
|
for (const schema of [positiveMinor, revision]) {
|
|
expect(schema.parse("9223372036854775807")).toBe("9223372036854775807")
|
|
for (const invalid of ["9223372036854775808", "0", "01", "1.5", 9007199254740992])
|
|
expect(schema.safeParse(invalid).success).toBe(false)
|
|
}
|
|
expect(recordTerms.parse(terms).entries[0]?.amountMinor).toBe("9007199254740993")
|
|
})
|
|
it("balances exact BigInt totals by currency and detects one-unit and cross-currency negatives", async () => {
|
|
const s = service()
|
|
await validateBalanced(s, terms)
|
|
const [debit, credit] = terms.entries
|
|
if (!debit || !credit) throw new Error("missing synthetic entries")
|
|
await expect(
|
|
validateBalanced(s, {
|
|
...terms,
|
|
entries: [debit, { ...credit, amountMinor: "9007199254740992" }],
|
|
}),
|
|
).rejects.toThrow()
|
|
vi.mocked(s.account).mockImplementation(
|
|
async (id) => ({ accountId: id, currency: id === A ? "EUR" : "USD", status: "active" }) as never,
|
|
)
|
|
await expect(validateBalanced(s, terms)).rejects.toThrow()
|
|
})
|
|
it("does not pre-read mutable account or transaction state before service replay", async () => {
|
|
const s = service()
|
|
vi.mocked(s.account).mockRejectedValue(new Error("archived after first commit"))
|
|
await recordBalanced(s, terms, "retry")
|
|
await recordBalanced(s, terms, "retry")
|
|
expect(s.account).not.toHaveBeenCalled()
|
|
expect(vi.mocked(s.record).mock.calls.map((call) => call[1])).toEqual([
|
|
"app-ledger:record:retry",
|
|
"app-ledger:record:retry",
|
|
])
|
|
await transition(s, "post", T, "9007199254740993", "retry")
|
|
expect(s.transition).toHaveBeenCalledWith("post", T, "9007199254740993", "app-ledger:post:retry")
|
|
})
|
|
it("fences new holds, posts and reversals on drift, fails closed on verifier outage, permits void recovery", async () => {
|
|
const s = service()
|
|
vi.mocked(s.verify).mockResolvedValue({ checkedAccounts: 2, drift: [A] })
|
|
await expect(recordBalanced(s, terms, "r")).rejects.toThrow()
|
|
for (const action of ["post", "reverse"] as const)
|
|
await expect(transition(s, action, T, "1", "r")).rejects.toThrow()
|
|
expect(s.record).not.toHaveBeenCalled()
|
|
expect(s.transition).not.toHaveBeenCalled()
|
|
await transition(s, "void", T, "1", "r")
|
|
vi.mocked(s.verify).mockRejectedValue(new Error("offline"))
|
|
await expect(recordBalanced(s, terms, "r")).rejects.toThrow("offline")
|
|
})
|
|
it("preserves service state/funds/overflow refusals without replacing them with local storage", async () => {
|
|
const s = service()
|
|
for (const reason of ["transaction-state", "insufficient-funds", "overflow", "revision-conflict"]) {
|
|
const error = new Error(reason)
|
|
vi.mocked(s.transition).mockRejectedValueOnce(error)
|
|
await expect(transition(s, "post", T, "1", "r")).rejects.toBe(error)
|
|
}
|
|
})
|
|
it("reuses identical payload keys, rotates for changed input and for returning to an earlier payload", () => {
|
|
const key = retryIdentity()
|
|
const first = key(terms)
|
|
expect(key(structuredClone(terms))).toBe(first)
|
|
expect(key({ ...terms, description: "Changed operational label" })).not.toBe(first)
|
|
expect(key(terms)).not.toBe(first)
|
|
})
|
|
})
|