cardano-api: strip 'Fix #N:' audit-ticket prefixes from inline comments (was 50+ in main.py), drop hardening-pass changelog blocks from module docstring, rewrite README to drop deploy paths + marketing sections, keep tier/auth/TTL + policy IDs. cardano-checkout-py: drop TradeCraft lineage refs, swap chromaticcraft/tradecraft test fixtures for acme/globex, repository URL → git.sulkta.com.
180 lines
5.5 KiB
Python
180 lines
5.5 KiB
Python
"""InvoiceStore protocol conformance + InMemoryStore round-trips.
|
|
|
|
These tests exist to catch regressions in the reference implementation
|
|
and to document the exact semantics the monitor + scheduler rely on.
|
|
Any backend consumers implement should pass the same suite (we don't
|
|
parameterize yet — that lands when we ship the SQLAlchemy adapter).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceStore
|
|
|
|
|
|
def _make_invoice(
|
|
id_: str = "inv_001",
|
|
merchant: str = "acme",
|
|
index: int = 0,
|
|
status: InvoiceStatus = InvoiceStatus.PENDING,
|
|
) -> Invoice:
|
|
return Invoice(
|
|
id=id_,
|
|
merchant_id=merchant,
|
|
derivation_index=index,
|
|
receive_address=f"addr1...{index}",
|
|
expected_lovelace=5_000_000,
|
|
usd_amount=2.50,
|
|
status=status,
|
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
)
|
|
|
|
|
|
def test_inmemory_store_satisfies_protocol() -> None:
|
|
"""Runtime isinstance check against the Protocol — catches API drift."""
|
|
assert isinstance(InMemoryStore(), InvoiceStore)
|
|
|
|
|
|
async def test_create_then_get_round_trips() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
|
|
await store.create(inv)
|
|
fetched = await store.get(inv.id)
|
|
|
|
assert fetched is not None
|
|
assert fetched.id == inv.id
|
|
assert fetched.merchant_id == inv.merchant_id
|
|
assert fetched.expected_lovelace == 5_000_000
|
|
assert fetched.status == InvoiceStatus.PENDING
|
|
|
|
|
|
async def test_create_rejects_duplicate_id() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
await store.create(inv)
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
await store.create(_make_invoice())
|
|
|
|
|
|
async def test_get_missing_returns_none() -> None:
|
|
store = InMemoryStore()
|
|
assert await store.get("does-not-exist") is None
|
|
|
|
|
|
async def test_update_persists_state_changes() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
await store.create(inv)
|
|
|
|
inv.status = InvoiceStatus.CONFIRMED
|
|
inv.received_lovelace = 5_100_000
|
|
inv.confirmed_at = datetime.now(timezone.utc)
|
|
await store.update(inv)
|
|
|
|
fetched = await store.get(inv.id)
|
|
assert fetched is not None
|
|
assert fetched.status == InvoiceStatus.CONFIRMED
|
|
assert fetched.received_lovelace == 5_100_000
|
|
assert fetched.confirmed_at is not None
|
|
|
|
|
|
async def test_update_on_missing_raises() -> None:
|
|
store = InMemoryStore()
|
|
with pytest.raises(KeyError):
|
|
await store.update(_make_invoice(id_="never-created"))
|
|
|
|
|
|
async def test_get_is_defensive_copy() -> None:
|
|
"""Mutating a fetched invoice must not change stored state."""
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
await store.create(inv)
|
|
|
|
fetched = await store.get(inv.id)
|
|
assert fetched is not None
|
|
fetched.status = InvoiceStatus.CANCELLED
|
|
fetched.metadata["tampered"] = True
|
|
|
|
# Re-fetch — store should still have the original.
|
|
refetched = await store.get(inv.id)
|
|
assert refetched is not None
|
|
assert refetched.status == InvoiceStatus.PENDING
|
|
assert "tampered" not in refetched.metadata
|
|
|
|
|
|
async def test_list_by_status_returns_only_matching() -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make_invoice(id_="p1", index=0))
|
|
await store.create(_make_invoice(id_="p2", index=1))
|
|
await store.create(
|
|
_make_invoice(id_="c1", index=2, status=InvoiceStatus.CONFIRMED)
|
|
)
|
|
|
|
pending = await store.list_by_status(InvoiceStatus.PENDING)
|
|
confirmed = await store.list_by_status(InvoiceStatus.CONFIRMED)
|
|
expired = await store.list_by_status(InvoiceStatus.EXPIRED)
|
|
|
|
assert {inv.id for inv in pending} == {"p1", "p2"}
|
|
assert {inv.id for inv in confirmed} == {"c1"}
|
|
assert expired == []
|
|
|
|
|
|
async def test_list_by_status_honours_limit() -> None:
|
|
store = InMemoryStore()
|
|
for i in range(5):
|
|
await store.create(_make_invoice(id_=f"p{i}", index=i))
|
|
|
|
results = await store.list_by_status(InvoiceStatus.PENDING, limit=3)
|
|
assert len(results) == 3
|
|
|
|
|
|
async def test_next_derivation_index_is_monotonic_per_merchant() -> None:
|
|
store = InMemoryStore()
|
|
m1 = "acme"
|
|
m2 = "globex"
|
|
|
|
assert await store.next_derivation_index(m1) == 0
|
|
assert await store.next_derivation_index(m1) == 1
|
|
assert await store.next_derivation_index(m2) == 0 # independent per merchant
|
|
assert await store.next_derivation_index(m1) == 2
|
|
|
|
|
|
async def test_create_bumps_index_cursor_if_higher() -> None:
|
|
"""If a caller creates an invoice at index N, the next derivation MUST skip past it."""
|
|
store = InMemoryStore()
|
|
await store.create(_make_invoice(id_="manual", index=7))
|
|
|
|
nxt = await store.next_derivation_index("acme")
|
|
assert nxt == 8
|
|
|
|
|
|
async def test_record_tx_is_idempotent() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
await store.create(inv)
|
|
|
|
await store.record_tx(inv.id, "deadbeef", 5_000_000)
|
|
await store.record_tx(inv.id, "deadbeef", 5_000_000)
|
|
|
|
fetched = await store.get(inv.id)
|
|
assert fetched is not None
|
|
# tx_hashes list should contain the hash once, not twice.
|
|
assert fetched.tx_hashes.count("deadbeef") == 1
|
|
|
|
|
|
async def test_record_tx_appends_multiple_distinct_hashes() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make_invoice()
|
|
await store.create(inv)
|
|
|
|
await store.record_tx(inv.id, "aaaa", 1_000_000)
|
|
await store.record_tx(inv.id, "bbbb", 2_000_000)
|
|
|
|
fetched = await store.get(inv.id)
|
|
assert fetched is not None
|
|
assert set(fetched.tx_hashes) == {"aaaa", "bbbb"}
|