feat(cassettes): schema and models for operator operations

First piece of the v2 wire (bitspire ADR-004). The operator stops
publishing counts and starts publishing what it DID; the machine, which
holds the notes, keeps the running total. A value with one writer cannot
be clobbered, which is the whole point: the absolute-count wire let a
form loaded before a dispense discard that dispense when published, and
nothing in an addressable event can tell the loser it lost.

m013 adds cassette_ops, append-only. The id is minted here and is the
idempotency key the machine dedups on, because a delta applied twice is
wrong and addressable events are re-delivered on reconnect. acked_at is
set when the machine reports that id back, which is the only
acknowledgement this transport can carry.

The models enforce that an op carries exactly the one field its type
means, so an instance is publishable by construction — the same contract
FeeConfigPayload has — and nulls never reach the wire for the machine to
disambiguate. recount is the only absolute, deliberately: it is what an
operator opening a bay and counting actually does, and it stays
auditable as its own act rather than looking like a stale form.

Vocabulary mirrors lamassu-server's cash_unit_operation_type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-23 09:50:28 +02:00
commit d8190375a6
3 changed files with 348 additions and 1 deletions

142
tests/test_cassette_ops.py Normal file
View file

@ -0,0 +1,142 @@
"""
Tests for the v2 cassette-operations models (bitspire ADR-004).
The operator no longer publishes counts; it publishes operations and the
machine keeps the running total. These cover the pure pieces: per-type field
validation, and the wire shape the publisher ships.
A CreateCassetteOpData instance is meant to be publishable by construction —
same contract as FeeConfigPayload — so the type/field agreement is enforced in
the model rather than at the endpoint.
"""
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from ..models import (
CASSETTE_OP_TYPES,
CassetteOp,
CreateCassetteOpData,
PublishCassetteOpsPayload,
)
AT = datetime.fromtimestamp(1790106060, timezone.utc)
def op(**kw) -> CassetteOp:
base = {"id": "op-1", "machine_id": "m1", "position": 2, "created_at": AT}
return CassetteOp(**{**base, **kw})
class TestCreateCassetteOpData:
def test_accepts_one_of_each_type(self):
CreateCassetteOpData(position=2, op_type="refill", bills=100)
CreateCassetteOpData(position=3, op_type="empty")
CreateCassetteOpData(position=1, op_type="recount", count=37)
CreateCassetteOpData(position=1, op_type="set_denomination", denomination=50)
@pytest.mark.parametrize(
"kwargs",
[
{"position": 2, "op_type": "refill"},
{"position": 1, "op_type": "recount"},
{"position": 1, "op_type": "set_denomination"},
],
)
def test_rejects_a_type_missing_its_field(self, kwargs):
with pytest.raises(ValidationError):
CreateCassetteOpData(**kwargs)
@pytest.mark.parametrize(
"kwargs",
[
{"position": 2, "op_type": "refill", "bills": 1, "count": 5},
{"position": 3, "op_type": "empty", "bills": 1},
{"position": 1, "op_type": "recount", "count": 1, "denomination": 50},
],
)
def test_rejects_a_type_carrying_a_foreign_field(self, kwargs):
"""An op that carries two meanings is ambiguous on the wire, and the
machine would have to guess which one to apply."""
with pytest.raises(ValidationError):
CreateCassetteOpData(**kwargs)
def test_rejects_a_refill_of_zero_or_fewer_notes(self):
"""A refill is a delta that adds notes. Zero is a no-op an operator
did not mean, and negative is a withdrawal wearing a refill's name."""
for bills in (0, -5):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="refill", bills=bills)
def test_allows_a_recount_to_zero(self):
"""Distinct from refill: counting a bay and finding it empty is a real
and important observation."""
assert CreateCassetteOpData(position=2, op_type="recount", count=0).count == 0
def test_rejects_a_negative_recount_and_a_non_positive_denomination(self):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="recount", count=-1)
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="set_denomination", denomination=0)
def test_rejects_an_unknown_type_and_a_non_positive_position(self):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=1, op_type="drain")
with pytest.raises(ValidationError):
CreateCassetteOpData(position=0, op_type="empty")
class TestWireShape:
def test_each_type_ships_only_its_own_field(self):
assert op(op_type="refill", bills=100).to_wire_dict() == {
"id": "op-1",
"at": 1790106060,
"type": "refill",
"position": 2,
"bills": 100,
}
assert op(op_type="empty").to_wire_dict() == {
"id": "op-1",
"at": 1790106060,
"type": "empty",
"position": 2,
}
assert op(op_type="recount", count=37).to_wire_dict()["count"] == 37
assert (
op(op_type="set_denomination", denomination=50).to_wire_dict()[
"denomination"
]
== 50
)
def test_nulls_never_reach_the_wire(self):
"""The row has three nullable columns and one op only ever means one
of them. Shipping the other two as null would make the machine guess."""
for op_type in CASSETTE_OP_TYPES:
kw = {
"refill": {"bills": 1},
"recount": {"count": 1},
"set_denomination": {"denomination": 1},
"empty": {},
}[op_type]
wire = op(op_type=op_type, **kw).to_wire_dict()
assert None not in wire.values()
def test_payload_declares_v2_and_preserves_order(self):
ops = [
op(id="a", op_type="refill", bills=1),
op(id="b", op_type="empty"),
]
wire = PublishCassetteOpsPayload(ops=ops).to_wire_dict()
assert wire["schema_version"] == 2
assert [o["id"] for o in wire["ops"]] == ["a", "b"]
def test_an_empty_window_is_representable(self):
"""A machine with no operator history still gets a well-formed
payload rather than the publisher having to special-case it."""
assert PublishCassetteOpsPayload(ops=[]).to_wire_dict() == {
"schema_version": 2,
"ops": [],
}