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:
parent
30870ebd16
commit
d8190375a6
3 changed files with 348 additions and 1 deletions
142
tests/test_cassette_ops.py
Normal file
142
tests/test_cassette_ops.py
Normal 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": [],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue