"""Hermetic tests for the billing ledger core. Zero reliance on network / Stripe / Gitea: every test uses a temp-dir SQLite file and pure stdlib. Run with: python -m pytest tests/test_billing_core.py -q """ import sys import threading from datetime import datetime, timedelta, timezone from pathlib import Path import pytest # The package is a PEP 420 namespace package; make the project root importable # regardless of how pytest was invoked. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from gitea_saas.billing_core import ( # noqa: E402 BillingCore, DUPLICATE_CLIENT, HOLD_ALREADY_RELEASED, HOLD_NOT_FOUND, IDEMPOTENCY_CONFLICT, INSUFFICIENT_BALANCE, INVALID_AMOUNT, INVALID_KEY, UNKNOWN_CLIENT, ) class FakeClock: """Deterministic clock for hold-expiry tests (injected via now_fn).""" def __init__(self, start: datetime | None = None): self.now = start or datetime(2026, 1, 1, tzinfo=timezone.utc) def __call__(self) -> datetime: return self.now def advance(self, minutes: float) -> None: self.now += timedelta(minutes=minutes) @pytest.fixture def core(tmp_path): return BillingCore(tmp_path / "billing.db") def make_client(core, name="alice"): ok, details = core.create_client(name) assert ok, details return details["id"] # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- def test_create_tables_idempotent(core, tmp_path): # Constructor already created them; calling again must be safe and # non-destructive. core.create_tables() core.create_tables() cid = make_client(core) ok, details = core.get_client(cid) assert ok and details["id"] == cid # --------------------------------------------------------------------------- # Credit / debit # --------------------------------------------------------------------------- def test_credit_balance_up_debit_balance_down(core): cid = make_client(core) ok, d = core.credit(cid, 500, "c1") assert ok and d["kind"] == "credit" and d["amount_minor"] == 500 assert d["balance"] == 500 ok, d = core.credit(cid, 250, "c2") assert ok and d["balance"] == 750 ok, d = core.debit(cid, 300, "d1") assert ok and d["kind"] == "debit" and d["balance"] == 450 ok, details = core.get_client(cid, include_balance=True) assert ok and details["balance"] == 450 # Debit down to exactly zero is allowed (never *negative*). ok, d = core.debit(cid, 450, "d2") assert ok and d["balance"] == 0 def test_debit_below_zero_rejected_and_no_row_written(core): cid = make_client(core) core.credit(cid, 100, "c1") ok, details = core.debit(cid, 200, "d1") assert ok is False assert details["error"]["code"] == INSUFFICIENT_BALANCE # No debit row was written; balance unchanged. ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 1 assert ledger["entries"][0]["kind"] == "credit" ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 100 def test_duplicate_idempotency_key_credit_applied_once(core): cid = make_client(core) ok, first = core.credit(cid, 100, "k1") assert ok and first["balance"] == 100 ok, second = core.credit(cid, 100, "k1") assert ok and second["replayed"] is True assert second["original"]["ledger_id"] == first["ledger_id"] assert second["original"]["amount_minor"] == 100 assert second["balance"] == 100 # applied once ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 1 def test_duplicate_idempotency_key_debit_applied_once(core): cid = make_client(core) core.credit(cid, 200, "c1") ok, d1 = core.debit(cid, 50, "k1") assert ok and d1["balance"] == 150 ok, d2 = core.debit(cid, 50, "k1") assert ok and d2["replayed"] is True and d2["balance"] == 150 ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 2 # one credit, one debit def test_idempotency_key_conflict_across_clients_is_an_error(core): cid1, cid2 = make_client(core, "a"), make_client(core, "b") ok, _ = core.credit(cid1, 100, "shared-key") assert ok ok, details = core.credit(cid2, 100, "shared-key") assert ok is False and details["error"]["code"] == IDEMPOTENCY_CONFLICT # --------------------------------------------------------------------------- # Holds # --------------------------------------------------------------------------- def test_hold_reserves_release_returns(core): cid = make_client(core) core.credit(cid, 1000, "c1") ok, d = core.reserve_hold(cid, 300, "h1") assert ok and d["hold_id"] and d["balance"] == 700 ok, details = core.get_client(cid) assert ok and details["balance"] == 700 ok, d = core.release_hold(d["hold_id"], "r1") assert ok and d["amount_minor"] == 300 and d["balance"] == 1000 ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 3 # credit + hold + release def test_hold_cannot_exceed_available_balance(core): cid = make_client(core) core.credit(cid, 1000, "c1") ok, _ = core.reserve_hold(cid, 700, "h1") assert ok ok, details = core.reserve_hold(cid, 400, "h2") assert ok is False and details["error"]["code"] == INSUFFICIENT_BALANCE ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 300 def test_hold_expiry_auto_releases_on_next_query(tmp_path): clock = FakeClock() core = BillingCore(tmp_path / "billing.db", now_fn=clock) cid = make_client(core) core.credit(cid, 1000, "c1") ok, d = core.reserve_hold(cid, 200, "h1") # default 30 min assert ok and d["balance"] == 800 # reserved # 31 minutes later the hold has expired; the next read auto-releases it. clock.advance(minutes=31) ok, details = core.get_client(cid) assert ok and details["balance"] == 1000 ok, ledger = core.list_ledger(cid) assert ledger["count"] == 3 # credit + hold + auto-release kinds = {e["kind"] for e in ledger["entries"]} assert kinds == {"credit", "hold", "release"} assert any(e["idempotency_key"].startswith("auto-release:") for e in ledger["entries"]) def test_expire_stale_holds_releases_only_expired(tmp_path): clock = FakeClock() core = BillingCore(tmp_path / "billing.db", now_fn=clock) cid = make_client(core) core.credit(cid, 1000, "c1") ok, hold_live = core.reserve_hold(cid, 200, "h-live", expires_in_minutes=60) assert ok clock.advance(minutes=30) ok, hold_stale = core.reserve_hold(cid, 400, "h-stale", expires_in_minutes=10) assert ok ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 400 # both holds active clock.advance(minutes=11) # h-stale expired (t+41 > t+40), h-live not (t+41 < t+90) ok, d = core.expire_stale_holds(cid) assert ok and d["count"] == 1 and d["expired"][0]["hold_id"] == hold_stale["hold_id"] ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 800 # only the live hold still reserves # Re-running is a no-op. ok, d = core.expire_stale_holds(cid) assert ok and d["count"] == 0 def test_hold_expired_at_reservation_never_reserves(tmp_path): # A hold born already-expired (negative TTL / backdated) does not lock # balance and is cleaned up on the next query. clock = FakeClock() core = BillingCore(tmp_path / "billing.db", now_fn=clock) cid = make_client(core) core.credit(cid, 1000, "c1") ok, d = core.reserve_hold(cid, 200, "h1", expires_in_minutes=-1) assert ok and d["balance"] == 1000 # already expired: no reservation ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 1000 ok, ledger = core.list_ledger(cid) assert ledger["count"] == 3 # credit + hold + auto-release def test_reserve_hold_replay_is_noop(core): cid = make_client(core) core.credit(cid, 1000, "c1") ok, first = core.reserve_hold(cid, 300, "h1") assert ok ok, second = core.reserve_hold(cid, 300, "h1") assert ok and second["replayed"] is True assert second["hold_id"] == first["hold_id"] ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 700 ok, ledger = core.list_ledger(cid) assert ledger["count"] == 2 # credit + one hold row def test_release_hold_replay_and_double_release(core): cid = make_client(core) core.credit(cid, 1000, "c1") ok, hold = core.reserve_hold(cid, 300, "h1") ok, rel = core.release_hold(hold["hold_id"], "r1") assert ok and rel["balance"] == 1000 # Same key: idempotent replay, not an error. ok, rel2 = core.release_hold(hold["hold_id"], "r1") assert ok and rel2["replayed"] is True and rel2["balance"] == 1000 # Different key on an already-released hold: structured error. ok, details = core.release_hold(hold["hold_id"], "r2") assert ok is False and details["error"]["code"] == HOLD_ALREADY_RELEASED def test_release_unknown_hold(core): ok, details = core.release_hold(99999, "r1") assert ok is False and details["error"]["code"] == HOLD_NOT_FOUND # --------------------------------------------------------------------------- # Refunds # --------------------------------------------------------------------------- def test_refund_adds_back_and_preserves_idempotency(core): cid = make_client(core) core.credit(cid, 100, "c1") core.debit(cid, 100, "d1") ok, bal = core.get_balance(cid) assert ok and bal["balance"] == 0 ok, d = core.refund(cid, 100, "rf1") assert ok and d["kind"] == "refund" and d["balance"] == 100 ok, d2 = core.refund(cid, 100, "rf1") assert ok and d2["replayed"] is True and d2["balance"] == 100 ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 3 # --------------------------------------------------------------------------- # Structured errors / validation (never raise, always tuples) # --------------------------------------------------------------------------- def test_unknown_client_structured_errors(core): for op in ( lambda: core.get_client(999), lambda: core.get_balance(999), lambda: core.credit(999, 100, "x"), lambda: core.debit(999, 100, "x"), lambda: core.refund(999, 100, "x"), lambda: core.reserve_hold(999, 100, "x"), lambda: core.list_ledger(999), lambda: core.expire_stale_holds(999), ): ok, details = op() assert ok is False assert details["error"]["code"] == UNKNOWN_CLIENT def test_invalid_amounts_rejected(core): cid = make_client(core) for bad in (0, -5, True, "10", 1.5, None): ok, details = core.credit(cid, bad, f"k-{bad}") assert ok is False and details["error"]["code"] == INVALID_AMOUNT def test_missing_idempotency_key_rejected(core): cid = make_client(core) for bad in ("", " ", None, 42): ok, details = core.credit(cid, 100, bad) assert ok is False and details["error"]["code"] == INVALID_KEY def test_duplicate_client_name_rejected(core): make_client(core, "bob") ok, details = core.create_client("bob") assert ok is False and details["error"]["code"] == DUPLICATE_CLIENT def test_empty_client_name_rejected(core): ok, details = core.create_client(" ") assert ok is False def test_all_operations_return_tuples(core): # Smoke check across the whole public surface: (bool, dict) always. cid = make_client(core) ops = [ core.get_client(cid), core.get_balance(cid), core.credit(cid, 50, "c1"), core.debit(cid, 10, "d1"), core.refund(cid, 5, "rf1"), core.reserve_hold(cid, 5, "h1"), core.list_ledger(cid), core.expire_stale_holds(cid), ] ok, hold = core.reserve_hold(cid, 5, "h2") ops.append(core.release_hold(hold["hold_id"], "r1")) ops.append(core.get_client(424242)) for result in ops: assert isinstance(result, tuple) and len(result) == 2 assert isinstance(result[0], bool) assert isinstance(result[1], dict) # --------------------------------------------------------------------------- # Queries / misc # --------------------------------------------------------------------------- def test_list_ledger_filters(core): cid = make_client(core) core.credit(cid, 100, "c1") core.credit(cid, 50, "c2") ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == 2 ok, ledger = core.list_ledger(cid, to="2000-01-01T00:00:00+00:00") assert ok and ledger["count"] == 0 ok, ledger = core.list_ledger(cid, from_="3000-01-01T00:00:00+00:00") assert ok and ledger["count"] == 0 def test_get_client_include_balance_false(core): cid = make_client(core) core.credit(cid, 100, "c1") ok, details = core.get_client(cid, include_balance=False) assert ok and "balance" not in details assert details["name"] == "alice" def test_create_client_meta_roundtrip(core): cid = make_client(core) ok, carol = core.create_client("carol", tier="enterprise", status="active", meta={"plan": "pro", "seats": 3}) assert ok ok, details = core.get_client(cid) assert ok and details["tier"] == "free" # default tier ok, details = core.get_client(carol["id"]) assert ok and details["name"] == "carol" assert details["meta"] == {"plan": "pro", "seats": 3} # --------------------------------------------------------------------------- # Issue #16: client-record shape consistency across methods # --------------------------------------------------------------------------- CLIENT_RECORD_KEYS = {"id", "name", "tier", "status", "meta", "created_at"} def test_client_record_shape_consistent_across_methods(core): """create_client / get_client / list_clients must agree on the key set. This is the cross-method test whose absence let the shapes drift (issue #16): it fails if any one method's record keys diverge. """ cid = make_client(core) core.credit(cid, 250, "c1") ok, created = core.create_client("bob") assert ok assert set(created) == CLIENT_RECORD_KEYS ok, fetched = core.get_client(cid) assert ok assert set(fetched) == CLIENT_RECORD_KEYS | {"balance"} assert fetched["id"] == cid and fetched["balance"] == 250 ok, listing = core.list_clients() assert ok by_id = {c["id"]: c for c in listing["clients"]} assert set(by_id) == {cid, created["id"]} for record in listing["clients"]: assert set(record) == CLIENT_RECORD_KEYS | {"balance"} assert by_id[cid]["id"] == cid assert by_id[cid]["name"] == "alice" and by_id[cid]["balance"] == 250 assert by_id[created["id"]]["name"] == "bob" and by_id[created["id"]]["balance"] == 0 def test_list_clients_flat_records_and_order(core): a = make_client(core, "alpha") b = make_client(core, "beta") core.credit(b, 100, "c1") ok, listing = core.list_clients() assert ok records = listing["clients"] assert [r["id"] for r in records] == [a, b] assert records[1]["balance"] == 100 assert "meta_json" not in records[0] # normalized to flat 'meta' assert records[0]["meta"] == {} def test_wire_shape_has_server_allowlist_fields(core): """The HTTP layer's _client_out allow-list needs id/name/tier/status/ meta/created_at/balance at the TOP level of every client payload -- the exact rows from the issue #16 defect table.""" cid = make_client(core) core.credit(cid, 500, "c1") ok, created = core.create_client("wire-probe") assert ok ok, fetched = core.get_client(cid) assert ok ok, listing = core.list_clients() assert ok allow = {"id", "name", "tier", "status", "meta", "created_at", "balance"} # POST /api/clients row: identifier present at top level (no client_id-only). assert "id" in created and "client_id" not in created assert set(created) <= allow # GET /api/clients/{id} row: every allow-listed field present, flat. assert set(fetched) == allow # GET /api/clients row: list_clients exists and yields the same shape. assert set(listing["clients"][0]) == allow def test_ledger_entries_expose_all_columns(core): cid = make_client(core) core.credit(cid, 100, "c1", ref_type="issue", ref_id="42") ok, ledger = core.list_ledger(cid) entry = ledger["entries"][0] assert entry["ref_type"] == "issue" and entry["ref_id"] == "42" assert entry["currency"] == "USD" assert entry["amount_minor"] == 100 assert entry["client_id"] == cid # --------------------------------------------------------------------------- # Thread safety # --------------------------------------------------------------------------- def test_concurrent_credits_are_serialized(core): cid = make_client(core) n_threads, per_thread = 8, 25 errors = [] def worker(t): for i in range(per_thread): ok, details = core.credit(cid, 1, f"t{t}-i{i}") if not ok: errors.append(details) threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)] for t in threads: t.start() for t in threads: t.join() assert errors == [] ok, bal = core.get_balance(cid) assert ok and bal["balance"] == n_threads * per_thread ok, ledger = core.list_ledger(cid) assert ok and ledger["count"] == n_threads * per_thread