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>
170 lines
4.3 KiB
TypeScript
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 })
|
|
})
|
|
})
|