v0.2: add store, mint, and monitor integration tests
This commit is contained in:
parent
3225e830a3
commit
5782d80a3b
3 changed files with 735 additions and 0 deletions
180
tests/test_store_protocol.py
Normal file
180
tests/test_store_protocol.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""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 = "example-studio",
|
||||
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 = "example-studio"
|
||||
m2 = "hostapp"
|
||||
|
||||
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("example-studio")
|
||||
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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue