feat(v2): cassette_configs CRUD + unit tests (#29 v1)

Wire up the cassette_configs storage layer:
  - get_cassette_config / list_cassette_configs_for_machine — reads
  - update_cassette_config — operator UI per-row edit (count + position).
    Refuses to create new rows; the denomination set is hardware-determined
    per #29 row lifecycle.
  - apply_bootstrap_state — consumer-side upsert from an ATM-published
    kind-30078 bitspire-cassettes-state event. Populates both the
    operator-believed columns and the v2 reverse-channel columns
    (state_count, state_at, state_event_id) in one transaction. Returns
    False on relay re-delivery (any existing row's state_event_id matches
    the incoming event_id).
  - _should_apply_bootstrap_state — pure-function dedup gate extracted
    from apply_bootstrap_state so the relay-re-delivery decision is
    unit-testable without a database round-trip.

23 new pure-function/model tests in tests/test_cassette_configs.py
covering the wire-shape validators (denomination key coercion, no-duplicate-
positions, int ranges, wire-dict round-trip) and the dedup-helper logic.
DB-touching CRUD follows the existing project convention (see
test_deposit_currency.py rationale): smoke-tested manually via the dev
container, integration tests deferred.

Total: 98 passed, 1 pre-existing async-plugin failure unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 18:03:52 +02:00
commit 9b8008db1f
2 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,220 @@
"""
Tests for the v1 cassette-config layer (aiolabs/satmachineadmin#29).
Covers the pure pieces that don't need a live DB:
- Pydantic validator behaviour on PublishCassettesPayload + the row /
upsert models (denomination key coercion, integer ranges, no-duplicate-
positions, wire-format round-trip)
- _should_apply_bootstrap_state dedup helper (extracted from
apply_bootstrap_state so the relay-re-delivery decision is testable
without a database round-trip)
DB-touching tests (apply_bootstrap_state actually upserting, list-by-
machine ordering, etc.) follow the project convention from
test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better
covered by an integration test against a running LNbits; tracked in #26
as a follow-up." Smoke-tested manually via the dev container.
"""
import pytest
from ..crud import _should_apply_bootstrap_state
from ..models import (
CassettePayloadRow,
PublishCassettesPayload,
UpsertCassetteConfigData,
)
# =============================================================================
# PublishCassettesPayload — wire-shape validators
# =============================================================================
class TestPublishCassettesPayload:
"""The kind-30078 content payload, bidirectional (operator→ATM and
ATMoperator share the shape). String JSON keys must coerce to int;
duplicate positions must reject; per-row int constraints enforced."""
def test_happy_path_coerces_string_keys_to_int(self):
p = PublishCassettesPayload(
denominations={
"20": {"position": 1, "count": 49},
"50": {"position": 2, "count": 100},
}
)
assert set(p.denominations.keys()) == {20, 50}
assert p.denominations[20].position == 1
assert p.denominations[20].count == 49
assert p.denominations[50].count == 100
def test_wire_dict_round_trip_restringifies_keys(self):
"""to_wire_dict() must restringify denomination keys so the
resulting JSON is parseable by clients (including the ATM-side
nostr-tools NIP-44 v2 consumer per the byte-compat cross-test)."""
original = PublishCassettesPayload(
denominations={
"20": {"position": 1, "count": 49},
"50": {"position": 2, "count": 100},
}
)
wire = original.to_wire_dict()
assert wire == {
"denominations": {
"20": {"position": 1, "count": 49},
"50": {"position": 2, "count": 100},
}
}
# And the wire form round-trips back through the parser cleanly.
reparsed = PublishCassettesPayload(**wire)
assert reparsed.denominations == original.denominations
def test_rejects_non_int_key(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(
denominations={"abc": {"position": 1, "count": 1}}
)
assert "is not an int" in str(exc.value)
def test_rejects_non_positive_denomination(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(
denominations={"0": {"position": 1, "count": 1}}
)
assert "denomination must be > 0" in str(exc.value)
def test_rejects_negative_denomination(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(
denominations={"-20": {"position": 1, "count": 1}}
)
assert "denomination must be > 0" in str(exc.value)
def test_rejects_duplicate_position(self):
"""Two cassettes can't occupy the same physical slot. The schema
PK is (machine_id, denomination), so duplicates land via the
payload; reject at the validator layer before the publish path
builds an event the ATM will misinterpret."""
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(
denominations={
"20": {"position": 1, "count": 49},
"50": {"position": 1, "count": 100},
}
)
assert "duplicate position" in str(exc.value)
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
PublishCassettesPayload(
denominations={"20": {"position": 1, "count": -1}}
)
def test_rejects_zero_position(self):
with pytest.raises(ValueError):
PublishCassettesPayload(
denominations={"20": {"position": 0, "count": 1}}
)
def test_allows_zero_count(self):
"""An empty cassette is a legal state — operator must be able to
record `count=0` after a dispatcher pulled the cassette mid-day."""
p = PublishCassettesPayload(
denominations={"20": {"position": 1, "count": 0}}
)
assert p.denominations[20].count == 0
# =============================================================================
# CassettePayloadRow — per-row int constraints (single-row tests)
# =============================================================================
class TestCassettePayloadRow:
def test_happy_path(self):
row = CassettePayloadRow(position=1, count=49)
assert row.position == 1
assert row.count == 49
@pytest.mark.parametrize("bad_position", [0, -1, -100])
def test_rejects_non_positive_position(self, bad_position):
with pytest.raises(ValueError):
CassettePayloadRow(position=bad_position, count=1)
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
CassettePayloadRow(position=1, count=-1)
# =============================================================================
# UpsertCassetteConfigData — operator-edit form
# =============================================================================
class TestUpsertCassetteConfigData:
"""Operator-driven row edit. Both fields optional; same int constraints
as the wire-format row but applied independently per-edit."""
def test_partial_update_count_only(self):
d = UpsertCassetteConfigData(count=80)
assert d.count == 80
assert d.position is None
def test_partial_update_position_only(self):
d = UpsertCassetteConfigData(position=3)
assert d.position == 3
assert d.count is None
def test_empty_update_is_legal(self):
"""An empty UpsertCassetteConfigData parses fine; the CRUD short-
circuits a no-op on empty payload (no SQL emitted)."""
d = UpsertCassetteConfigData()
assert d.count is None
assert d.position is None
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
UpsertCassetteConfigData(count=-1)
def test_rejects_non_positive_position(self):
with pytest.raises(ValueError):
UpsertCassetteConfigData(position=0)
# =============================================================================
# _should_apply_bootstrap_state — relay re-delivery dedup
# =============================================================================
class TestShouldApplyBootstrapState:
"""Pure-function dedup gate extracted from apply_bootstrap_state so the
decision is testable without a DB. Logic: apply if-and-only-if the
existing row's state_event_id differs from the incoming event_id.
In v1 the ATM publishes the bootstrap event exactly once per machine,
so this is sufficient for replay protection. v2 will need a
`last_state_created_at` watermark in addition (per bitspire's
`meta.lastKnownConfigCreatedAt` on the ATM side) flagged in #29's
v2 forward-look section.
"""
def test_applies_when_no_existing_row(self):
assert _should_apply_bootstrap_state(None, "new-event-id") is True
def test_applies_when_existing_event_id_differs(self):
assert (
_should_apply_bootstrap_state("old-event-id", "new-event-id") is True
)
def test_skips_when_existing_event_id_matches(self):
"""The same bootstrap event re-delivered after a relay reconnect
or satmachineadmin restart should no-op, not re-upsert the same
rows (which would clobber any operator edits since)."""
assert (
_should_apply_bootstrap_state("same-event", "same-event") is False
)
def test_applies_when_existing_is_empty_string_and_incoming_is_id(self):
"""Defensive — a sentinel empty-string existing_state_event_id
shouldn't block a real incoming event from applying."""
assert _should_apply_bootstrap_state("", "real-event-id") is True