Three new modules land in tests/ alongside the v0.1 pure-module tests: - test_store_protocol.py: InMemoryStore round-trips + Protocol conformance. Covers create / get / update / list_by_status / next_derivation_index / record_tx idempotency + defensive-copy semantics. - test_mint_metadata.py: mint_nft_cert end-to-end against a stubbed ChainContext (no live Ogmios). Exercises the 2-of-2 native-script policy shape, tx body construction, CIP-25 envelope CBOR round-trip, and the oversize-asset-name guard. - test_monitor_with_inmemory_store.py: monitor loop driven against InMemoryStore with Koios + the oracle monkeypatched. Covers every status transition the scheduler cares about (confirm, overpay, underpay, stay-pending, record_tx, expiry skip) and the two reprice code paths (successful reprice bumps expected_lovelace + expires_at; max_repricings flips to EXPIRED). All 42 tests pass on pycardano 0.16 with pytest-asyncio auto mode.
247 lines
7.9 KiB
Python
247 lines
7.9 KiB
Python
"""Monitor integration tests against InMemoryStore.
|
|
|
|
Stubs both Koios (via monkeypatch on ``check_address_utxos``) and the
|
|
ADA/USD oracle so the tests are deterministic and offline. Exercises
|
|
the main state transitions the scheduler cares about:
|
|
|
|
- PENDING → CONFIRMED when UTxOs satisfy the 98% threshold
|
|
- PENDING → OVERPAID when UTxOs exceed the 102% threshold
|
|
- PENDING → UNDERPAID when UTxOs are nonzero but below threshold
|
|
- PENDING (kept) when no UTxOs yet
|
|
- Reprice of an expired invoice → new expected_lovelace + new expires_at
|
|
- Reprice hitting max_repricings → EXPIRED
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus
|
|
from cardano_checkout import monitor
|
|
|
|
|
|
def _make(
|
|
id_: str = "inv",
|
|
expected_lovelace: int = 5_000_000,
|
|
expires_in_minutes: int = 15,
|
|
status: InvoiceStatus = InvoiceStatus.PENDING,
|
|
repriced_count: int = 0,
|
|
) -> Invoice:
|
|
now = datetime.now(timezone.utc)
|
|
return Invoice(
|
|
id=id_,
|
|
merchant_id="chromaticcraft",
|
|
derivation_index=0,
|
|
receive_address="addr1testreceive",
|
|
expected_lovelace=expected_lovelace,
|
|
usd_amount=2.50,
|
|
status=status,
|
|
expires_at=now + timedelta(minutes=expires_in_minutes),
|
|
metadata={"repriced_count": repriced_count} if repriced_count else {},
|
|
)
|
|
|
|
|
|
def _utxo(lovelace: int, tx_hash: str = "aa" * 32) -> dict:
|
|
return {
|
|
"tx_hash": tx_hash,
|
|
"tx_index": 0,
|
|
"value": str(lovelace),
|
|
"asset_list": [],
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_koios_and_oracle(monkeypatch):
|
|
"""Default: no UTxOs, oracle returns $0.45/ADA. Individual tests override."""
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
return []
|
|
|
|
async def fake_price():
|
|
return 0.45
|
|
|
|
async def fake_convert(usd):
|
|
if usd <= 0:
|
|
return 0
|
|
return int((usd / 0.45) * 1_000_000)
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
monkeypatch.setattr(monitor, "get_ada_usd_price", fake_price)
|
|
monkeypatch.setattr(monitor, "convert_usd_to_lovelace", fake_convert)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_pending_invoices
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_no_utxos_leaves_invoice_pending() -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make())
|
|
|
|
updated = await monitor.check_pending_invoices(store)
|
|
assert updated == 0
|
|
|
|
inv = await store.get("inv")
|
|
assert inv is not None
|
|
assert inv.status == InvoiceStatus.PENDING
|
|
assert inv.received_lovelace == 0
|
|
|
|
|
|
async def test_confirm_within_tolerance(monkeypatch) -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make(expected_lovelace=5_000_000))
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
# 4.9 ADA — right at the 98% confirm threshold.
|
|
return [_utxo(4_900_000)]
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
|
|
updated = await monitor.check_pending_invoices(store)
|
|
assert updated == 1
|
|
|
|
inv = await store.get("inv")
|
|
assert inv is not None
|
|
assert inv.status == InvoiceStatus.CONFIRMED
|
|
assert inv.received_lovelace == 4_900_000
|
|
assert inv.confirmed_at is not None
|
|
assert inv.tx_hashes == ["aa" * 32]
|
|
|
|
|
|
async def test_overpaid_flag_when_above_threshold(monkeypatch) -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make(expected_lovelace=5_000_000))
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
return [_utxo(5_200_000)]
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
|
|
await monitor.check_pending_invoices(store)
|
|
inv = await store.get("inv")
|
|
assert inv is not None
|
|
assert inv.status == InvoiceStatus.OVERPAID
|
|
assert inv.confirmed_at is not None
|
|
|
|
|
|
async def test_underpaid_when_below_tolerance(monkeypatch) -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make(expected_lovelace=5_000_000))
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
return [_utxo(3_000_000)]
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
|
|
await monitor.check_pending_invoices(store)
|
|
inv = await store.get("inv")
|
|
assert inv is not None
|
|
assert inv.status == InvoiceStatus.UNDERPAID
|
|
assert inv.received_lovelace == 3_000_000
|
|
assert inv.confirmed_at is None
|
|
|
|
|
|
async def test_record_tx_called_for_observed_hashes(monkeypatch) -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make(expected_lovelace=5_000_000))
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
return [_utxo(4_900_000, tx_hash="feed" + "00" * 30)]
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
|
|
await monitor.check_pending_invoices(store)
|
|
|
|
records = store._tx_records()
|
|
assert any(k[0] == "inv" and k[1] == "feed" + "00" * 30 for k in records)
|
|
|
|
|
|
async def test_already_expired_invoices_are_skipped(monkeypatch) -> None:
|
|
"""Invoices past their expiry are left for reprice_expired_invoices to handle."""
|
|
store = InMemoryStore()
|
|
expired = _make(expected_lovelace=5_000_000)
|
|
expired.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
await store.create(expired)
|
|
|
|
calls = []
|
|
|
|
async def fake_utxos(address, koios_url=None, timeout=None):
|
|
calls.append(address)
|
|
return [_utxo(5_000_000)]
|
|
|
|
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
|
|
|
|
await monitor.check_pending_invoices(store)
|
|
# check_pending_invoices must not have polled Koios for an already-expired invoice.
|
|
assert calls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reprice_expired_invoices
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_reprice_updates_expected_lovelace_and_extends_expiry() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make(expected_lovelace=5_000_000)
|
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
await store.create(inv)
|
|
|
|
updated = await monitor.reprice_expired_invoices(
|
|
store, window_minutes=15, max_repricings=3
|
|
)
|
|
assert updated == 1
|
|
|
|
fetched = await store.get("inv")
|
|
assert fetched is not None
|
|
# USD $2.50 @ $0.45/ADA = 5.555 ADA = 5555555 lovelace.
|
|
assert fetched.expected_lovelace == 5_555_555
|
|
assert fetched.status == InvoiceStatus.PENDING
|
|
assert fetched.expires_at is not None
|
|
assert fetched.expires_at > datetime.now(timezone.utc)
|
|
assert fetched.metadata["repriced_count"] == 1
|
|
|
|
|
|
async def test_reprice_gives_up_after_max_repricings() -> None:
|
|
store = InMemoryStore()
|
|
inv = _make(expected_lovelace=5_000_000, repriced_count=3)
|
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
await store.create(inv)
|
|
|
|
await monitor.reprice_expired_invoices(
|
|
store, window_minutes=15, max_repricings=3
|
|
)
|
|
|
|
fetched = await store.get("inv")
|
|
assert fetched is not None
|
|
assert fetched.status == InvoiceStatus.EXPIRED
|
|
|
|
|
|
async def test_reprice_noop_when_nothing_expired() -> None:
|
|
store = InMemoryStore()
|
|
await store.create(_make(expired_in_minutes=15) if False else _make())
|
|
|
|
updated = await monitor.reprice_expired_invoices(store)
|
|
assert updated == 0
|
|
|
|
|
|
async def test_reprice_skips_when_oracle_unavailable(monkeypatch) -> None:
|
|
store = InMemoryStore()
|
|
inv = _make(expected_lovelace=5_000_000)
|
|
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
await store.create(inv)
|
|
|
|
async def zero_price():
|
|
return 0.0
|
|
|
|
monkeypatch.setattr(monitor, "get_ada_usd_price", zero_price)
|
|
|
|
updated = await monitor.reprice_expired_invoices(store)
|
|
assert updated == 0
|
|
|
|
fetched = await store.get("inv")
|
|
assert fetched is not None
|
|
assert fetched.status == InvoiceStatus.PENDING # not flipped to expired
|