create_client now returns 'id' (was 'client_id'), get_client returns a flat
record with balance as a field (was nested under 'client'), and list_clients
is implemented returning {'clients': [...]} with the same flat shape. A
cross-method shape-consistency test (create/get/list key-set agreement)
plus a wire-shape test mirroring the server's _client_out allow-list guard
against future drift. server.py/test_server.py untouched (issue #2/!13).
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""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
|