test(machine): unit-cover operator-fees parser + state-store apply

24 tests for the load-bearing logic introduced by the previous commit:

`src/services/__tests__/operator-fees.test.ts` (10):
- canonical v1 payload with components parses cleanly
- absent schema_version treated as v1 (back-compat with cassette config
  doc that shipped without one)
- unknown top-level keys silently ignored (v2 forward-compat)
- absent `components` → WARN + zero breakdown (graceful degrade,
  producer-mandatory at v1 but consumer-safe)
- components present but sums disagree with totals → WARN + still
  parses (totals authoritative per coord log §`14:25Z`)
- tiny float drift (well under 1e-6) does NOT trip the consistency
  assert
- required fields missing → throws
- non-numeric component → throws with the offending key in the message
- FEE_CAP_PER_DIRECTION exposed at 0.15

`electron/__tests__/state-store-fees.test.ts` (14):
- null pre-apply (`getFeeConfig` + watermark)
- round-trip via getFeeConfig after applyFeeConfig
- upsert on subsequent newer event (singleton id=1)
- watermark dedup: rejects equal AND older event.created_at
- persisted row unchanged when stale event is rejected
- 15% per-direction cap: rejects above-cap on either direction
- accepts at the cap boundary exactly
- rejects negative + non-finite fractions
- schema_version < 1 rejected
- non-integer event_created_at rejected
- watermark does NOT advance when payload validation fails (atomicity)

Uses in-memory SQLite (`:memory:`) — fresh DB per test, no on-disk
artifacts, no parallel-test interference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 14:24:35 +02:00
commit bd6270cbd6
2 changed files with 352 additions and 0 deletions

View file

@ -0,0 +1,170 @@
/**
* Tests for fee-config persistence in state-store (aiolabs/lamassu-next#57).
*
* Validates the v9→v10 migration shape, the 15% cap enforced at the IPC
* boundary (defense in depth — renderer is the primary check), watermark
* dedup, and atomic apply semantics.
*
* 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 {
applyFeeConfig,
closeDatabase,
getFeeConfig,
getLastKnownFeeConfigCreatedAt,
initDatabase,
} from '../state-store.js'
const VALID_PAYLOAD = {
cashInFeeFraction: 0.0633,
cashOutFeeFraction: 0.1077,
schemaVersion: 1,
}
beforeEach(() => {
initDatabase(':memory:')
})
afterEach(() => {
closeDatabase()
})
describe('state-store: fee_config', () => {
it('returns null before any event has been applied', () => {
expect(getFeeConfig()).toBeNull()
expect(getLastKnownFeeConfigCreatedAt()).toBe(0)
})
it('applies a valid event and round-trips via getFeeConfig', () => {
const result = applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
expect(result).toEqual({ applied: true })
const row = getFeeConfig()
expect(row).not.toBeNull()
expect(row!.cashInFeeFraction).toBe(0.0633)
expect(row!.cashOutFeeFraction).toBe(0.1077)
expect(row!.schemaVersion).toBe(1)
expect(row!.eventCreatedAt).toBe(1_780_256_000)
expect(row!.appliedAt).toBeGreaterThan(0)
expect(getLastKnownFeeConfigCreatedAt()).toBe(1_780_256_000)
})
it('upserts subsequent newer events (replaces the singleton row)', () => {
applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
const result = applyFeeConfig(
{ cashInFeeFraction: 0.05, cashOutFeeFraction: 0.08, schemaVersion: 1 },
1_780_256_100
)
expect(result).toEqual({ applied: true })
const row = getFeeConfig()!
expect(row.cashInFeeFraction).toBe(0.05)
expect(row.cashOutFeeFraction).toBe(0.08)
expect(row.eventCreatedAt).toBe(1_780_256_100)
})
describe('watermark dedup', () => {
it('rejects an event whose created_at equals the watermark', () => {
applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
const result = applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
expect(result.applied).toBe(false)
if (result.applied === false) {
expect(result.reason).toMatch(/event\.created_at .* <= lastKnownFeeConfigCreatedAt/)
}
})
it('rejects an event whose created_at is older than the watermark', () => {
applyFeeConfig(VALID_PAYLOAD, 1_780_256_500)
const result = applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
expect(result.applied).toBe(false)
})
it('leaves the persisted row unchanged when a stale event is rejected', () => {
applyFeeConfig(VALID_PAYLOAD, 1_780_256_500)
const before = getFeeConfig()!
applyFeeConfig(
{ cashInFeeFraction: 0.01, cashOutFeeFraction: 0.02, schemaVersion: 1 },
1_780_256_000
)
const after = getFeeConfig()!
expect(after.cashInFeeFraction).toBe(before.cashInFeeFraction)
expect(after.cashOutFeeFraction).toBe(before.cashOutFeeFraction)
expect(after.eventCreatedAt).toBe(before.eventCreatedAt)
})
})
describe('per-direction 15% cap', () => {
it('rejects when cash_in_fee_fraction exceeds 0.15', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: 0.151, cashOutFeeFraction: 0.05, schemaVersion: 1 },
1_780_256_000
)
expect(result.applied).toBe(false)
if (result.applied === false) {
expect(result.reason).toMatch(/cash_in_fee_fraction out of range/)
}
})
it('rejects when cash_out_fee_fraction exceeds 0.15', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: 0.05, cashOutFeeFraction: 0.16, schemaVersion: 1 },
1_780_256_000
)
expect(result.applied).toBe(false)
})
it('accepts when both fractions are exactly at the 15% cap', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: 0.15, cashOutFeeFraction: 0.15, schemaVersion: 1 },
1_780_256_000
)
expect(result.applied).toBe(true)
})
it('rejects negative fractions', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: -0.01, cashOutFeeFraction: 0.05, schemaVersion: 1 },
1_780_256_000
)
expect(result.applied).toBe(false)
})
it('rejects non-finite fractions', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: NaN, cashOutFeeFraction: 0.05, schemaVersion: 1 },
1_780_256_000
)
expect(result.applied).toBe(false)
})
})
describe('schema validation', () => {
it('rejects schema_version less than 1', () => {
const result = applyFeeConfig(
{ cashInFeeFraction: 0.05, cashOutFeeFraction: 0.05, schemaVersion: 0 },
1_780_256_000
)
expect(result.applied).toBe(false)
})
it('rejects non-integer event_created_at', () => {
const result = applyFeeConfig(VALID_PAYLOAD, 1_780_256_000.5)
expect(result.applied).toBe(false)
})
})
describe('atomicity', () => {
it('does not advance watermark when payload validation fails', () => {
applyFeeConfig(VALID_PAYLOAD, 1_780_256_000)
applyFeeConfig(
{ cashInFeeFraction: 0.99, cashOutFeeFraction: 0.05, schemaVersion: 1 },
1_780_256_500
)
// Cap rejection — watermark should still be at the first apply.
expect(getLastKnownFeeConfigCreatedAt()).toBe(1_780_256_000)
})
})
})

View file

@ -0,0 +1,182 @@
/**
* Tests for the operator-fees parser (aiolabs/lamassu-next#57).
*
* Covers the consumer-side wire-format invariants locked in the coord
* log §`14:25Z`:
* - schema_version absent → treated as v1
* - components absent → WARN + zero breakdown (graceful degrade)
* - components present → consistency assert: sums must match totals
* within 1e-6; on drift WARN + still apply (sums are authoritative)
* - 15% per-direction cap enforced
* - Unknown top-level keys ignored (v2 forward-compat)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __testing } from '../operator-fees.js'
const { parseV1Payload, FEE_CAP_PER_DIRECTION } = __testing
describe('parseV1Payload', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('parses a canonical v1 payload with components', () => {
const parsed = parseV1Payload({
schema_version: 1,
cash_in_fee_fraction: 0.0633,
cash_out_fee_fraction: 0.1077,
components: {
super_cash_in: 0.03,
super_cash_out: 0.03,
operator_cash_in: 0.0333,
operator_cash_out: 0.0777,
},
})
expect(parsed).toEqual({
cashInFeeFraction: 0.0633,
cashOutFeeFraction: 0.1077,
superCashInFraction: 0.03,
superCashOutFraction: 0.03,
operatorCashInFraction: 0.0333,
operatorCashOutFraction: 0.0777,
schemaVersion: 1,
})
expect(warnSpy).not.toHaveBeenCalled()
})
it('treats absent schema_version as v1 (back-compat with cassette config)', () => {
const parsed = parseV1Payload({
cash_in_fee_fraction: 0.05,
cash_out_fee_fraction: 0.05,
components: {
super_cash_in: 0.025,
super_cash_out: 0.025,
operator_cash_in: 0.025,
operator_cash_out: 0.025,
},
})
expect(parsed.schemaVersion).toBe(1)
})
it('ignores unknown top-level keys (v2 forward-compat)', () => {
// Simulated v2 payload — adds a `discounts` field. v1 consumer
// should parse the known fields and silently drop the rest.
const parsed = parseV1Payload({
schema_version: 2,
cash_in_fee_fraction: 0.05,
cash_out_fee_fraction: 0.05,
components: {
super_cash_in: 0.025,
super_cash_out: 0.025,
operator_cash_in: 0.025,
operator_cash_out: 0.025,
},
discounts: [{ npub: 'abc', applies_to: 'super', fraction: 0.01 }],
future_field: { whatever: true },
})
expect(parsed.cashInFeeFraction).toBe(0.05)
expect(parsed.schemaVersion).toBe(2)
// No throws — extra fields silently ignored.
})
it('falls through to zero components + WARN when components absent', () => {
const parsed = parseV1Payload({
schema_version: 1,
cash_in_fee_fraction: 0.05,
cash_out_fee_fraction: 0.05,
})
expect(parsed.superCashInFraction).toBe(0)
expect(parsed.superCashOutFraction).toBe(0)
expect(parsed.operatorCashInFraction).toBe(0)
expect(parsed.operatorCashOutFraction).toBe(0)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('payload missing `components`')
)
})
it('WARNs + still parses when components sum disagrees with totals', () => {
// Producer-bug shape: totals say 0.10 but components sum to 0.05.
// Per coord log §`07:33Z`+§`14:25Z` consumer logs WARN + applies
// sums (totals are authoritative); does NOT throw.
const parsed = parseV1Payload({
schema_version: 1,
cash_in_fee_fraction: 0.1,
cash_out_fee_fraction: 0.1,
components: {
super_cash_in: 0.025,
super_cash_out: 0.025,
operator_cash_in: 0.025,
operator_cash_out: 0.025,
},
})
expect(parsed.cashInFeeFraction).toBe(0.1)
expect(parsed.cashOutFeeFraction).toBe(0.1)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('payload internally inconsistent')
)
})
it('does NOT warn on tiny float drift well under the 1e-6 threshold', () => {
// 0.0333 + 0.0300 == 0.0633 modulo IEEE-754; this should not trip
// the consistency assert.
parseV1Payload({
schema_version: 1,
cash_in_fee_fraction: 0.0333 + 0.03,
cash_out_fee_fraction: 0.0777 + 0.03,
components: {
super_cash_in: 0.03,
super_cash_out: 0.03,
operator_cash_in: 0.0333,
operator_cash_out: 0.0777,
},
})
// No consistency-inconsistency warnings.
const inconsistent = warnSpy.mock.calls.find((c) =>
String(c[0]).includes('internally inconsistent')
)
expect(inconsistent).toBeUndefined()
})
it('throws when cash_in_fee_fraction is missing', () => {
expect(() =>
parseV1Payload({
cash_out_fee_fraction: 0.05,
})
).toThrow(/cash_in_fee_fraction/)
})
it('throws when cash_out_fee_fraction is missing', () => {
expect(() =>
parseV1Payload({
cash_in_fee_fraction: 0.05,
})
).toThrow(/cash_out_fee_fraction/)
})
it('throws when a component is non-numeric', () => {
expect(() =>
parseV1Payload({
cash_in_fee_fraction: 0.05,
cash_out_fee_fraction: 0.05,
components: {
super_cash_in: 'oops',
super_cash_out: 0.025,
operator_cash_in: 0.025,
operator_cash_out: 0.025,
},
})
).toThrow(/components\.super_cash_in/)
})
it('exposes the 15% cap constant', () => {
expect(FEE_CAP_PER_DIRECTION).toBe(0.15)
})
})