The machine now owns its bay counts outright. The operator publishes what it did — a refill in notes added, an empty, a recount, a denomination change — and this process applies it to the total it already holds. Both sides used to write the same value over a transport that never tells a writer it lost. Addressable events order by created_at at second granularity with ties broken on event id, and a relay returns OK for an event it then discards, so a dashboard form loaded before a dispense silently discarded that dispense and neither side could detect it. A value with one writer cannot be clobbered. Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is wrong and addressable events are re-delivered on every reconnect, so the operator mints an id per operation and this table records the ones applied. That also retires the created_at watermark on this path: it was the only replay defence under absolute counts, but it drops an out-of-order event whole, operations included, where per-op ids let the unseen ones through and no-op the rest. A window is applied oldest-first by `at`, ties broken by id, in one transaction with the count mutation. A recount then a refill is not the same as the reverse, and a crash mid-apply must roll back to a coherent count rather than a partial one. A malformed op or one naming a bay this machine does not have is neither applied nor recorded, so it stays pending on the operator's dashboard. That is the honest outcome. Recording it as applied would stop the noise by telling the operator their refill landed. The state document gains applied_ops, seq and schema_version. applied_ops is the acknowledgement leg — echoing the ids back is the only way the operator can tell an operation that landed from one merely sent. seq is bumped on every local count change from any cause, so a reader can reject a regression without trusting either clock.
495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
/**
|
|
* Tests for recordTransaction inventory accounting.
|
|
*
|
|
* Regression coverage for the position-vs-denomination decrement bug:
|
|
* position is the cassettes PK (v9) and duplicate denominations across
|
|
* bays are legal, so cash-out decrements MUST address bays by position.
|
|
* A denomination-keyed UPDATE would drain every matching bay at once.
|
|
*
|
|
* Uses an in-memory SQLite database — fresh per test, no on-disk
|
|
* artifacts, no parallel-test interference.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import {
|
|
applyOperatorCassetteOps,
|
|
closeDatabase,
|
|
getAppliedOpIds,
|
|
getCassetteStateSeq,
|
|
getCashbox,
|
|
getCountsUncertainSince,
|
|
getInventory,
|
|
initDatabase,
|
|
loadCassettes,
|
|
markCountsUncertain,
|
|
recordTransaction,
|
|
setCassettes,
|
|
} from '../state-store.js'
|
|
|
|
const TX_BASE = {
|
|
fiatCents: 4000,
|
|
sats: 100_000,
|
|
feeSats: 5_000,
|
|
feeFraction: 0.05,
|
|
exchangeRate: 2500,
|
|
currency: 'USD',
|
|
}
|
|
|
|
/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */
|
|
function seedDuplicateDenomBays() {
|
|
setCassettes([
|
|
{ position: 1, denomination: 20, count: 50 },
|
|
{ position: 2, denomination: 20, count: 50 },
|
|
{ position: 3, denomination: 50, count: 30 },
|
|
])
|
|
}
|
|
|
|
function countsByPosition(): Record<number, number> {
|
|
const out: Record<number, number> = {}
|
|
for (const row of loadCassettes()) out[row.position] = row.count
|
|
return out
|
|
}
|
|
|
|
beforeEach(() => {
|
|
initDatabase(':memory:')
|
|
seedDuplicateDenomBays()
|
|
})
|
|
afterEach(() => {
|
|
closeDatabase()
|
|
})
|
|
|
|
describe('state-store: recordTransaction cash_out inventory', () => {
|
|
it('decrements only the bay that actually dispensed (duplicate denominations)', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-single-bay',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 3 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette1',
|
|
position: 1,
|
|
denomination: 20,
|
|
provisioned: 3,
|
|
dispensed: 3,
|
|
rejected: 0,
|
|
},
|
|
{
|
|
name: 'cassette2',
|
|
position: 2,
|
|
denomination: 20,
|
|
provisioned: 0,
|
|
dispensed: 0,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 })
|
|
})
|
|
|
|
it('decrements each bay by its own dispensed count on a split dispense', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-split-bays',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 60 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette1',
|
|
position: 1,
|
|
denomination: 20,
|
|
provisioned: 50,
|
|
dispensed: 50,
|
|
rejected: 0,
|
|
},
|
|
{
|
|
name: 'cassette2',
|
|
position: 2,
|
|
denomination: 20,
|
|
provisioned: 10,
|
|
dispensed: 10,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
|
})
|
|
|
|
it('fallback without cassette results drains matching bays greedily by position', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-fallback',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 60 }],
|
|
})
|
|
|
|
// Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10.
|
|
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
|
})
|
|
|
|
it('never drives a bay count below zero', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-overdispense',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 50, count: 35 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette3',
|
|
position: 3,
|
|
denomination: 50,
|
|
provisioned: 35,
|
|
dispensed: 35,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 })
|
|
})
|
|
})
|
|
|
|
describe('state-store: recordTransaction cash_in cashbox', () => {
|
|
it('adds inserted bills to the cashbox and leaves cassettes untouched', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-cash-in',
|
|
type: 'cash_in',
|
|
status: 'complete',
|
|
bills: [
|
|
{ denomination: 20, count: 2 },
|
|
{ denomination: 50, count: 1 },
|
|
],
|
|
})
|
|
|
|
const cashbox = getCashbox()
|
|
expect(cashbox.totalBills).toBe(3)
|
|
expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents)
|
|
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
|
})
|
|
})
|
|
|
|
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
|
|
it('decrements the bays an operator remediation actually emptied', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-manual',
|
|
type: 'manual_dispense',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 2 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette1',
|
|
position: 1,
|
|
denomination: 20,
|
|
provisioned: 2,
|
|
dispensed: 2,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
// Bills physically left bay 1; before #76 this row was untouched and the
|
|
// inflated count became truth on the next boot.
|
|
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
|
|
})
|
|
|
|
it('decrements again when remediating a partly-dispensed cash-out', () => {
|
|
// Original cash-out managed 1 of the 2 notes it provisioned.
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-partial',
|
|
type: 'cash_out',
|
|
status: 'partial',
|
|
bills: [{ denomination: 50, count: 1 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette3',
|
|
position: 3,
|
|
denomination: 50,
|
|
provisioned: 2,
|
|
dispensed: 1,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
expect(countsByPosition()[3]).toBe(29)
|
|
|
|
// The operator dispenses the missing note by hand. That is a second lot of
|
|
// bills leaving the bay, so it debits again — the original only ever
|
|
// debited what physically left.
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-remediate',
|
|
type: 'manual_dispense',
|
|
status: 'complete',
|
|
bills: [{ denomination: 50, count: 1 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette3',
|
|
position: 3,
|
|
denomination: 50,
|
|
provisioned: 1,
|
|
dispensed: 1,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
expect(countsByPosition()[3]).toBe(28)
|
|
})
|
|
|
|
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
|
|
const before = getCashbox()
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-manual-cashbox',
|
|
type: 'manual_dispense',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 1 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette2',
|
|
position: 2,
|
|
denomination: 20,
|
|
provisioned: 1,
|
|
dispensed: 1,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
expect(getCashbox()).toEqual(before)
|
|
expect(countsByPosition()[2]).toBe(49)
|
|
})
|
|
})
|
|
|
|
describe('state-store: getInventory represents a drained machine', () => {
|
|
it('keeps configured bays at zero rather than dropping them', () => {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-drain-50s',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 50, count: 30 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette3',
|
|
position: 3,
|
|
denomination: 50,
|
|
provisioned: 30,
|
|
dispensed: 30,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
// The $50 bay is empty but still configured. Dropping the key made this
|
|
// look like "no inventory known", and callers then fell back to a stale
|
|
// snapshot or to HAL.
|
|
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
|
|
})
|
|
|
|
it('reports every bay at zero when the machine is fully drained', () => {
|
|
for (const [txid, position, denomination, count] of [
|
|
['d1', 1, 20, 50],
|
|
['d2', 2, 20, 50],
|
|
['d3', 3, 50, 30],
|
|
] as const) {
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid,
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination, count }],
|
|
cassettes: [
|
|
{
|
|
name: `cassette${position}`,
|
|
position,
|
|
denomination,
|
|
provisioned: count,
|
|
dispensed: count,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
|
|
})
|
|
|
|
it('returns an empty map only when no cassettes are configured', () => {
|
|
// A fresh DB with no bays at all — the one case that should read as
|
|
// "nothing known", so callers may legitimately defer to the hardware.
|
|
closeDatabase()
|
|
initDatabase(':memory:')
|
|
expect(getInventory()).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe('state-store: unverified counts after a silent dispense', () => {
|
|
it('starts clear, latches the first time, and keeps the earliest time', () => {
|
|
expect(getCountsUncertainSince()).toBeNull()
|
|
markCountsUncertain(1000)
|
|
expect(getCountsUncertainSince()).toBe(1000)
|
|
// A second failure does not move the clock forward — the question is how
|
|
// long the numbers have been untrustworthy, not when we last noticed.
|
|
markCountsUncertain(2000)
|
|
expect(getCountsUncertainSince()).toBe(1000)
|
|
})
|
|
|
|
it('clears on a recount, because that is what a recount is', () => {
|
|
markCountsUncertain(1000)
|
|
const result = applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
|
|
])
|
|
expect(result.applied).toEqual(['op-1'])
|
|
expect(getCountsUncertainSince()).toBeNull()
|
|
})
|
|
|
|
it('does not clear on a refill', () => {
|
|
// A refill adds to a number still known to be wrong. Only someone
|
|
// opening the bay and counting it resolves that.
|
|
markCountsUncertain(1000)
|
|
const result = applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
])
|
|
expect(result.applied).toEqual(['op-1'])
|
|
expect(getCountsUncertainSince()).toBe(1000)
|
|
})
|
|
|
|
it('leaves the flag alone when the op is rejected', () => {
|
|
markCountsUncertain(1000)
|
|
// Bay 9 does not exist — the layout is hardware-determined.
|
|
const result = applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
|
|
])
|
|
expect(result.applied).toEqual([])
|
|
expect(result.rejected).toHaveLength(1)
|
|
expect(getCountsUncertainSince()).toBe(1000)
|
|
})
|
|
})
|
|
|
|
describe('state-store: operator cassette operations (ADR-004)', () => {
|
|
beforeEach(() => {
|
|
seedDuplicateDenomBays()
|
|
})
|
|
|
|
it('applies a refill as a delta, not a total', () => {
|
|
applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
|
|
])
|
|
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
|
})
|
|
|
|
it('is a no-op on a re-delivered operation', () => {
|
|
// Addressable events are re-delivered on every relay reconnect and the
|
|
// operator republishes a WINDOW, so the same op arrives many times. A
|
|
// delta applied twice is simply wrong, which is why every op carries an
|
|
// id and this table records the ones already applied.
|
|
const op = {
|
|
id: 'op-1',
|
|
at: 1_700_000_000,
|
|
type: 'refill' as const,
|
|
position: 1,
|
|
bills: 30,
|
|
}
|
|
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
|
|
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
|
|
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
|
|
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
|
})
|
|
|
|
it('applies only the unseen ops from a window that mixes both', () => {
|
|
applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
])
|
|
const result = applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
|
|
])
|
|
expect(result.applied).toEqual(['op-2'])
|
|
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
|
|
})
|
|
|
|
it('applies a window oldest-first regardless of arrival order', () => {
|
|
// A recount then a refill is not the same as the reverse, so ordering is
|
|
// load-bearing and cannot be left to however the array arrived.
|
|
applyOperatorCassetteOps([
|
|
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
|
|
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
|
|
])
|
|
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
|
|
})
|
|
|
|
it('empties a bay and sets a denomination', () => {
|
|
applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
|
|
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
|
|
])
|
|
const bay = loadCassettes().find((c) => c.position === 2)!
|
|
expect(bay.count).toBe(0)
|
|
expect(bay.denomination).toBe(10)
|
|
})
|
|
|
|
it('rejects a malformed op without applying or recording it', () => {
|
|
// Unrecorded on purpose: it stays pending on the operator's dashboard,
|
|
// which is the honest outcome. Recording it as applied would silence the
|
|
// noise by telling the operator their refill landed.
|
|
const result = applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
|
|
])
|
|
expect(result.applied).toEqual([])
|
|
expect(result.rejected[0]!.id).toBe('op-1')
|
|
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
|
|
expect(getAppliedOpIds()).not.toContain('op-1')
|
|
})
|
|
|
|
it('echoes applied ids back, newest first', () => {
|
|
applyOperatorCassetteOps([
|
|
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
|
|
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
|
|
])
|
|
expect(getAppliedOpIds()).toContain('op-1')
|
|
expect(getAppliedOpIds()).toContain('op-2')
|
|
})
|
|
|
|
it('advances the sequence on an applied op but not on a duplicate', () => {
|
|
const op = {
|
|
id: 'op-1',
|
|
at: 1_700_000_000,
|
|
type: 'refill' as const,
|
|
position: 1,
|
|
bills: 1,
|
|
}
|
|
const before = getCassetteStateSeq()
|
|
applyOperatorCassetteOps([op])
|
|
const after = getCassetteStateSeq()
|
|
expect(after).toBeGreaterThan(before)
|
|
applyOperatorCassetteOps([op])
|
|
expect(getCassetteStateSeq()).toBe(after)
|
|
})
|
|
|
|
it('advances the sequence on a dispense', () => {
|
|
const before = getCassetteStateSeq()
|
|
recordTransaction({
|
|
...TX_BASE,
|
|
txid: 'tx-seq',
|
|
type: 'cash_out',
|
|
status: 'complete',
|
|
bills: [{ denomination: 20, count: 1 }],
|
|
cassettes: [
|
|
{
|
|
name: 'cassette1',
|
|
position: 1,
|
|
denomination: 20,
|
|
provisioned: 1,
|
|
dispensed: 1,
|
|
rejected: 0,
|
|
},
|
|
],
|
|
})
|
|
expect(getCassetteStateSeq()).toBeGreaterThan(before)
|
|
})
|
|
})
|