@@ -0,0 +1,92 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user