326 lines
12 KiB
Python
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"
|