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

152
crud.py
View file

@ -12,6 +12,7 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
CassetteConfig,
ClientBalanceSummary,
CommissionSplit,
CommissionSplitLeg,
@ -26,6 +27,7 @@ from .models import (
DcaPayment,
DcaSettlement,
Machine,
PublishCassettesPayload,
SuperConfig,
TelemetrySnapshot,
UpdateDcaClientData,
@ -33,6 +35,7 @@ from .models import (
UpdateDepositStatusData,
UpdateMachineData,
UpdateSuperConfigData,
UpsertCassetteConfigData,
UpsertDcaLpData,
)
@ -1334,3 +1337,152 @@ async def upsert_fleet_snapshot(
{"mid": machine_id, "json": telemetry_json, "now": now},
)
return await get_telemetry(machine_id)
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29).
# =============================================================================
# Row lifecycle per #29:
# - First population for a (machine_id, denomination) pair → apply_bootstrap_state
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
# - Operator edit of count or position → update_cassette_config (refuses to
# create new rows; the denomination set is hardware-determined)
# - Row creation/deletion for a new denomination → admin only, via ATM
# re-provisioning + new bootstrap event (not exposed in v1 here)
def _should_apply_bootstrap_state(
existing_state_event_id: Optional[str], incoming_event_id: str
) -> bool:
"""Pure-function dedup gate for apply_bootstrap_state.
Returns False if any existing row for this machine already references
the incoming event_id (relay re-delivery after restart). True otherwise.
Extracted as a pure function so the dedup decision is unit-testable
without a database round-trip. The actual idempotency check in
apply_bootstrap_state fetches one existing row and passes its
state_event_id here.
"""
return existing_state_event_id != incoming_event_id
async def get_cassette_config(
machine_id: str, denomination: int
) -> Optional[CassetteConfig]:
return await db.fetchone(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid AND denomination = :denom",
{"mid": machine_id, "denom": denomination},
CassetteConfig,
)
async def list_cassette_configs_for_machine(
machine_id: str,
) -> List[CassetteConfig]:
return await db.fetchall(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid ORDER BY position, denomination",
{"mid": machine_id},
CassetteConfig,
)
async def update_cassette_config(
machine_id: str,
denomination: int,
data: UpsertCassetteConfigData,
*,
updated_by: Optional[str] = None,
) -> Optional[CassetteConfig]:
"""Operator-driven row update: change count and/or position for a single
cassette. Refuses to create new rows those only land via
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
lifecycle: hardware-determined denomination set, not operator-creatable).
Returns None if the (machine_id, denomination) row doesn't exist.
"""
existing = await get_cassette_config(machine_id, denomination)
if existing is None:
return None
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
if not update_data:
return existing
update_data["updated_at"] = datetime.now()
update_data["updated_by"] = updated_by
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["mid"] = machine_id
update_data["denom"] = denomination
await db.execute(
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
"WHERE machine_id = :mid AND denomination = :denom",
update_data,
)
return await get_cassette_config(machine_id, denomination)
async def apply_bootstrap_state(
machine_id: str,
event_id: str,
event_created_at: datetime,
payload: PublishCassettesPayload,
) -> bool:
"""Consume an ATM-published kind-30078 bitspire-cassettes-state:<m> event
and upsert one cassette_configs row per denomination in the payload.
Returns True if the upsert ran; False if any existing row for this
machine already references this event_id (idempotent on relay
re-delivery / restart).
Populates both the operator-believed columns (count, position,
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
columns (state_count, state_at, state_event_id) so the operator's
initial view matches the ATM's reported state. v2 reconciliation UI
will diverge them when continuous reverse-channel events land.
"""
existing_first = await db.fetchone(
"SELECT state_event_id FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid LIMIT 1",
{"mid": machine_id},
)
existing_event_id: Optional[str] = None
if existing_first is not None:
existing_event_id = (
existing_first.get("state_event_id")
if isinstance(existing_first, dict)
else getattr(existing_first, "state_event_id", None)
)
if not _should_apply_bootstrap_state(existing_event_id, event_id):
return False
now = datetime.now()
for denom, row in payload.denominations.items():
await db.execute(
"""
INSERT INTO satoshimachine.cassette_configs
(machine_id, denomination, count, position, updated_at,
updated_by, state_count, state_at, state_event_id)
VALUES (:mid, :denom, :count, :pos, :now, :by,
:state_count, :state_at, :event_id)
ON CONFLICT (machine_id, denomination) DO UPDATE SET
count = excluded.count,
position = excluded.position,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by,
state_count = excluded.state_count,
state_at = excluded.state_at,
state_event_id = excluded.state_event_id
""",
{
"mid": machine_id,
"denom": denom,
"count": row.count,
"pos": row.position,
"now": now,
"by": "atm-bootstrap",
"state_count": row.count,
"state_at": event_created_at,
"event_id": event_id,
},
)
return True