"""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"