bitspire/apps/machine/electron/__tests__/state-store-transactions.test.ts
Padreug 79e1823cc5 fix(machine): decrement cassettes by position, not denomination, on cash-out
recordTransaction() updated cassette counts with WHERE denomination = ?,
but the v9 migration made position the PK precisely so duplicate
denominations across bays are legal (and the HAL dispense path already
returns authoritative per-position results). On any machine with two
bays of the same denomination, a single dispense drained every matching
bay row — silently corrupting inventory, the operator cassette-state
publish, and out-of-money gating.

- cassettes branch: decrement by c.position
- mock-only fallback (no per-bay results): drain matching bays greedily
  in position order, mirroring the dispenser's own fill order
- regression tests with a duplicate-denomination layout (3 of 5 fail
  against the old code)

Found during the dev-branch architecture review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:28:24 +02:00

170 lines
4.3 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 {
closeDatabase,
getCashbox,
initDatabase,
loadCassettes,
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 })
})
})