test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1)
The wire-shape pivot (m007 denomination-keyed → m008 position-keyed)
needs the unit test surface re-written to match:
test_cassette_configs.py
- PublishCassettesPayload tests pivot to positions-keyed input.
Validators reject non-int / non-positive position keys, negative
denom, negative count. Zero count allowed (empty cassette).
- NEW: test_accepts_multiple_same_denomination_cassettes — pins the
v1.1 operational requirement (real machines load 4×$20 for cash-out
throughput) per coord-log 18:45Z. No denom-unique validator.
- CassettePayloadRow tests pivot to the new field shape
(denomination + count, no position).
- UpsertCassetteConfigData tests cover edit-denomination (the v1.1
"operator swaps a cartridge during refill" scenario) and edit-count.
Position no longer in the model.
test_cassette_state_consumer.py
- _make_state_event helper builds {"positions": {...}} ciphertext.
- Happy-path assertion checks p.positions keys + denomination/count
per row.
- NEW: test_round_trips_multiple_same_denomination — covers the v1.1
four-of-the-same case through encrypt → decrypt → parse.
- All negative paths (tamper, wrong privkey, malformed pubkey,
missing fields, garbage JSON, wrong shape) carry over with the new
payload shape.
- d-tag tests unchanged (position vs denomination isn't on the d-tag).
test_nip44_v2.py
- TestBitspireCrossTest temporarily re-skipped at the class level: the
13:15Z fixture is encoded with the v1 denomination-keyed shape;
bitspire's posting a v1.1 fixture and commit g will swap +
unskip.
Total: 148 passed, 3 skipped (bitspire cross-test pending the v1.1
fixture from bitspire), 1 pre-existing async-plugin failure unchanged.
Branch tip is now functionally green (the pre-existing async failure
predates this PR + can't be addressed without a pytest plugin install).
Pending commit g for the cross-test fixture re-wire when bitspire posts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3014962563
commit
1cebefcde5
3 changed files with 132 additions and 80 deletions
|
|
@ -5,13 +5,14 @@ and `cassette_transport.decrypt_and_parse_state_event`).
|
|||
Covers the consumer-side validation path end-to-end without standing up
|
||||
the full nostrclient relay subscription:
|
||||
- happy path: signed event from a known ATM → decrypt → parse → returns
|
||||
a PublishCassettesPayload
|
||||
- sig-verify failure path (covered at the transport-decrypt level + the
|
||||
handler-level rejection test)
|
||||
a position-keyed PublishCassettesPayload
|
||||
- multiple same-denom cassettes (v1.1 operational case) — round-trips
|
||||
- tampered ciphertext → CassetteEventDecodeError
|
||||
- unknown sender pubkey → CassetteEventDecodeError (well-formed but
|
||||
- wrong operator privkey → CassetteEventDecodeError (well-formed but
|
||||
decrypt fails because conversation key is wrong)
|
||||
- malformed pubkey → CassetteEventDecodeError
|
||||
- missing fields → CassetteEventDecodeError
|
||||
- decrypted garbage / wrong-shape JSON → CassetteEventDecodeError
|
||||
|
||||
Full handler tests (the dispatch through verify_event → get_machine_by_atm_
|
||||
pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're
|
||||
|
|
@ -95,21 +96,40 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_happy_path(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 2, "count": 100},
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
"2": {"denomination": 50, "count": 100},
|
||||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
assert sorted(recovered.denominations.keys()) == [20, 50]
|
||||
assert recovered.denominations[20].position == 1
|
||||
assert recovered.denominations[20].count == 49
|
||||
assert recovered.denominations[50].count == 100
|
||||
assert sorted(recovered.positions.keys()) == [1, 2]
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
assert recovered.positions[2].denomination == 50
|
||||
assert recovered.positions[2].count == 100
|
||||
|
||||
def test_round_trips_multiple_same_denomination(self):
|
||||
"""v1.1 operational case from coord-log 18:45Z: real machines
|
||||
load multiple cassettes with the same denomination."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 100},
|
||||
"2": {"denomination": 20, "count": 100},
|
||||
"3": {"denomination": 20, "count": 100},
|
||||
"4": {"denomination": 20, "count": 100},
|
||||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
assert len(recovered.positions) == 4
|
||||
for pos in (1, 2, 3, 4):
|
||||
assert recovered.positions[pos].denomination == 20
|
||||
assert recovered.positions[pos].count == 100
|
||||
|
||||
def test_tampered_content_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
# Flip a base64 character — corrupts the ciphertext or MAC
|
||||
|
|
@ -124,7 +144,7 @@ class TestDecryptAndParseStateEvent:
|
|||
different hmac_key, so MAC verification inside NIP-44 v2 decrypt
|
||||
fails — surfaced as CassetteEventDecodeError."""
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
wrong_sec = "00" * 31 + "03"
|
||||
|
|
@ -133,7 +153,7 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_malformed_sender_pubkey_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
event["pubkey"] = "not-a-real-pubkey"
|
||||
|
|
@ -142,7 +162,9 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_missing_content_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
)
|
||||
del event["content"]
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
|
|
@ -150,7 +172,9 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_missing_pubkey_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
)
|
||||
del event["pubkey"]
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
|
|
@ -159,7 +183,6 @@ class TestDecryptAndParseStateEvent:
|
|||
def test_decrypted_garbage_json_rejected(self):
|
||||
"""If the plaintext decrypts but isn't JSON, we surface an error
|
||||
rather than crashing the consumer loop."""
|
||||
# Encrypt something that isn't JSON
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_plaintext_event = {
|
||||
"kind": 30078,
|
||||
|
|
@ -176,7 +199,7 @@ class TestDecryptAndParseStateEvent:
|
|||
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
|
||||
|
||||
def test_decrypted_json_with_wrong_shape_rejected(self):
|
||||
"""Well-formed JSON but missing the 'denominations' field is
|
||||
"""Well-formed JSON but missing the 'positions' field is
|
||||
a payload-shape failure, not a decrypt failure."""
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_shape_event = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue