gitea-saas-core/tests/test_server.py
2026-08-25 06:29:19 -05:00

326 lines
12 KiB
Python

"""Tests for gitea_saas.server against the real BillingCore (issue #1).
Hermetic by design: every test builds a temp SQLite DB and drives the app
through Flask's test client — no network, no Stripe, no Gitea.
Issue #1 (the billing core) has landed on ``main``, so this suite runs
against the real ``gitea_saas.billing_core.BillingCore``. The only doubles
left are two minimal stubs that force error paths the real core cannot
produce on demand: a core missing an op (501) and a core that raises (500).
Every endpoint has at least one test against the real core.
"""
from __future__ import annotations
import datetime
from pathlib import Path
import pytest
from gitea_saas import billing_core, server
from gitea_saas.config import API_KEY_HEADER
API_KEY = "test-api-key-secret"
AUTH = {API_KEY_HEADER: API_KEY}
# --------------------------------------------------------------------------
# Real-core app fixture
# --------------------------------------------------------------------------
@pytest.fixture()
def app(tmp_path: Path):
core = billing_core.BillingCore(tmp_path / "test.db")
application = server.create_app(core, API_KEY)
application.config.update(TESTING=True)
try:
yield application
finally:
core._conn.close()
@pytest.fixture()
def client(app):
return app.test_client()
def _post(client, url, body, key=API_KEY):
return client.post(url, json=body, headers={API_KEY_HEADER: key})
# --------------------------------------------------------------------------
# Minimal doubles for error paths the real core cannot produce on demand
# --------------------------------------------------------------------------
class MissingOpCore:
"""Looks like the core but omits ``credit`` -> drives the 501 path."""
def create_client(self, name, tier="free", status="active", meta=None):
return True, {"client_id": 1, "name": name, "tier": tier,
"status": status, "meta": meta or {},
"created_at": "2026-01-01T00:00:00+00:00"}
def get_client(self, client_id, include_balance=True):
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
class RaisingCore:
"""Raises where the real core returns a tuple -> drives the 500 path."""
def credit(self, **kwargs):
raise RuntimeError("boom")
# --------------------------------------------------------------------------
# Auth / health
# --------------------------------------------------------------------------
def test_health_is_public(client):
resp = client.get("/health")
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
def test_missing_key_is_401(client):
resp = client.get("/api/clients/1")
assert resp.status_code == 401
body = resp.get_json()
assert body["code"] == "unauthorized"
def test_wrong_key_is_401(client):
resp = client.get("/api/clients/1", headers={API_KEY_HEADER: "nope"})
assert resp.status_code == 401
assert resp.get_json()["code"] == "unauthorized"
def test_correct_key_is_200(client):
created = _post(client, "/api/clients", {"name": "AuthOK"})
assert created.status_code == 201
cid = created.get_json()["id"]
resp = client.get(f"/api/clients/{cid}", headers=AUTH)
assert resp.status_code == 200
# --------------------------------------------------------------------------
# Client lifecycle
# --------------------------------------------------------------------------
def test_full_lifecycle_via_api(client):
# create -> response carries the API's id plus a balance
created = _post(client, "/api/clients",
{"name": "Acme", "tier": "enterprise", "meta": {"v": 1}})
assert created.status_code == 201
out = created.get_json()
cid = out["id"]
assert out["balance"] == 0
assert out["name"] == "Acme"
assert out["tier"] == "enterprise"
assert out["meta"] == {"v": 1}
assert out["status"] == "active"
# credit
cred = _post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 5000, "idempotency_key": "c1"})
assert cred.status_code == 200
assert cred.get_json()["balance"] == 5000
# get -> full client record plus balance
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.status_code == 200
g = got.get_json()
assert g["balance"] == 5000
assert g["name"] == "Acme"
assert g["tier"] == "enterprise"
# hold
held = _post(client, f"/api/clients/{cid}/holds",
{"amount_minor": 2000, "idempotency_key": "h1",
"ref_type": "job", "ref_id": "j1"})
assert held.status_code == 201
hold_id = held.get_json()["hold_id"]
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.get_json()["balance"] == 3000 # 5000 - 2000 held
# debit (up to held-away balance)
deb = _post(client, f"/api/clients/{cid}/debits",
{"amount_minor": 3000, "idempotency_key": "d1"})
assert deb.status_code == 200
assert deb.get_json()["balance"] == 0 # 5000 - 3000 - 2000(held)
# release hold -> balance restored
rel = _post(client, f"/api/holds/{hold_id}/release",
{"idempotency_key": "r1"})
assert rel.status_code == 200
assert "released_at" in rel.get_json()
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.get_json()["balance"] == 2000
# ledger shows all four kinds (credit, hold, debit, release)
led = client.get(f"/api/clients/{cid}/ledger", headers=AUTH)
assert led.status_code == 200
kinds = {r["kind"] for r in led.get_json()["rows"]}
assert kinds == {"credit", "hold", "debit", "release"}
def test_debit_below_zero_is_409_and_no_change(client):
created = _post(client, "/api/clients", {"name": "Broke"})
cid = created.get_json()["id"]
resp = _post(client, f"/api/clients/{cid}/debits",
{"amount_minor": 100, "idempotency_key": "d-lose"})
assert resp.status_code == 409
body = resp.get_json()
assert body["code"] == "insufficient_balance"
assert body["error"]
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.get_json()["balance"] == 0
# idempotency key must not be burned by a rejected debit
resp2 = _post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 50, "idempotency_key": "d-lose"})
assert resp2.status_code == 200
assert resp2.get_json()["balance"] == 50
def test_duplicate_idempotency_key_no_double_apply(client):
created = _post(client, "/api/clients", {"name": "Dup"})
cid = created.get_json()["id"]
first = _post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 777, "idempotency_key": "same"})
second = _post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 777, "idempotency_key": "same"})
assert first.status_code == second.status_code == 200
assert first.get_json()["balance"] == second.get_json()["balance"] == 777
assert second.get_json().get("replayed") is True
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.get_json()["balance"] == 777
# exactly one credit row in the ledger
led = client.get(f"/api/clients/{cid}/ledger", headers=AUTH)
credits = [r for r in led.get_json()["rows"] if r["kind"] == "credit"]
assert len(credits) == 1
def test_hold_insufficient_is_409(client):
created = _post(client, "/api/clients", {"name": "Tiny"})
cid = created.get_json()["id"]
resp = _post(client, f"/api/clients/{cid}/holds",
{"amount_minor": 10, "idempotency_key": "h-x"})
assert resp.status_code == 409
assert resp.get_json()["code"] == "insufficient_balance"
def test_hold_expiry_auto_releases(client, app):
created = _post(client, "/api/clients", {"name": "Expiry"})
cid = created.get_json()["id"]
_post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 1000, "idempotency_key": "e1"})
held = _post(client, f"/api/clients/{cid}/holds",
{"amount_minor": 400, "idempotency_key": "h2", "ttl_s": 60})
hold_id = held.get_json()["hold_id"]
assert client.get(f"/api/clients/{cid}",
headers=AUTH).get_json()["balance"] == 600
# force expiry by rewriting timestamps back in time
core = app.config["CORE"]
past = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=5)).isoformat()
with core._lock:
core._conn.execute("UPDATE holds SET expires_at = ? WHERE id = ?",
(past, hold_id))
core._conn.commit()
got = client.get(f"/api/clients/{cid}", headers=AUTH)
assert got.get_json()["balance"] == 1000 # auto-released on read
def test_release_missing_hold_is_404(client):
resp = _post(client, "/api/holds/999999/release",
{"idempotency_key": "r-x"})
assert resp.status_code == 404
assert resp.get_json()["code"] == "hold_not_found"
def test_unknown_client_is_404(client):
resp = client.get("/api/clients/424242", headers=AUTH)
assert resp.status_code == 404
assert resp.get_json()["code"] == "unknown_client"
def test_ledger_filter_from_to(client):
created = _post(client, "/api/clients", {"name": "Filter"})
cid = created.get_json()["id"]
_post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 10, "idempotency_key": "f1"})
_post(client, f"/api/clients/{cid}/credits",
{"amount_minor": 20, "idempotency_key": "f2"})
# from=epoch start -> both rows; to=something future -> both rows
led = client.get(f"/api/clients/{cid}/ledger?from=2000-01-01T00:00:00",
headers=AUTH)
assert led.status_code == 200
assert led.get_json()["count"] == 2
# --------------------------------------------------------------------------
# Validation / error shape
# --------------------------------------------------------------------------
def test_errors_are_json_objects(client):
resp = _post(client, "/api/clients", {"amount_minor": "x"}) # bad body
assert resp.status_code == 400
body = resp.get_json()
assert set(body) == {"error", "code"}
assert isinstance(body["error"], str)
def test_invalid_amount_is_400(client):
created = _post(client, "/api/clients", {"name": "Bad"})
cid = created.get_json()["id"]
resp = _post(client, f"/api/clients/{cid}/credits",
{"amount_minor": -5, "idempotency_key": "neg"})
assert resp.status_code == 400
assert resp.get_json()["code"] == "invalid_request"
def test_missing_idempotency_key_is_400(client):
created = _post(client, "/api/clients", {"name": "NoKey"})
cid = created.get_json()["id"]
resp = _post(client, f"/api/clients/{cid}/credits", {"amount_minor": 1})
assert resp.status_code == 400
def test_create_client_requires_name(client):
resp = _post(client, "/api/clients", {"tier": "standard"})
assert resp.status_code == 400
def test_duplicate_client_name_is_409(client):
first = _post(client, "/api/clients", {"name": "DupName"})
assert first.status_code == 201
second = _post(client, "/api/clients", {"name": "DupName"})
assert second.status_code == 409
assert second.get_json()["code"] == "duplicate_client"
# --------------------------------------------------------------------------
# Error paths the real core cannot produce on demand (fakes only here)
# --------------------------------------------------------------------------
def test_missing_core_op_is_501():
application = server.create_app(MissingOpCore(), API_KEY)
application.config.update(TESTING=True)
c = application.test_client()
resp = c.post("/api/clients/1/credits",
json={"amount_minor": 100, "idempotency_key": "x"},
headers=AUTH)
assert resp.status_code == 501
assert resp.get_json()["code"] == "not_implemented"
def test_core_raise_is_500():
application = server.create_app(RaisingCore(), API_KEY)
application.config.update(TESTING=True)
c = application.test_client()
resp = c.post("/api/clients/1/credits",
json={"amount_minor": 100, "idempotency_key": "x"},
headers=AUTH)
assert resp.status_code == 500
assert resp.get_json()["code"] == "core_error"