From bd6270cbd63f62f51c3905d3c98eec33d55937c6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 14:24:35 +0200 Subject: [PATCH] test(machine): unit-cover operator-fees parser + state-store apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/state-store-fees.test.ts | 170 ++++++++++++++++ .../services/__tests__/operator-fees.test.ts | 182 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 apps/machine/electron/__tests__/state-store-fees.test.ts create mode 100644 apps/machine/src/services/__tests__/operator-fees.test.ts diff --git a/apps/machine/electron/__tests__/state-store-fees.test.ts b/apps/machine/electron/__tests__/state-store-fees.test.ts new file mode 100644 index 0000000..797113a --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-fees.test.ts @@ -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) + }) + }) +}) diff --git a/apps/machine/src/services/__tests__/operator-fees.test.ts b/apps/machine/src/services/__tests__/operator-fees.test.ts new file mode 100644 index 0000000..45c7ffa --- /dev/null +++ b/apps/machine/src/services/__tests__/operator-fees.test.ts @@ -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 + + 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) + }) +})