diff --git a/gitea_saas/config.py b/gitea_saas/config.py new file mode 100644 index 0000000..f3dcd13 --- /dev/null +++ b/gitea_saas/config.py @@ -0,0 +1,58 @@ +"""Environment-backed configuration for the gitea-saas-core HTTP service. + +Reads only the SAAS_* variables. It deliberately never reads real Stripe +keys (STRIPE_* / *_SECRET) or any other external credentials: deployment-time +secrets belong to the platform layers, not to this config module. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Fallback used only when SAAS_PORT is unset / empty. +DEFAULT_PORT = 8080 + +# When SAAS_DB_PATH is unset the CLI entrypoint defaults to an ephemeral +# (in-memory) SQLite database. Real deployments must set SAAS_DB_PATH. +DEFAULT_DB_PATH: Optional[Path] = None + +# Header name carrying the API key on every authenticated request. +API_KEY_HEADER = "X-API-Key" + + +def _env_nonempty(name: str) -> Optional[str]: + value = os.environ.get(name, "").strip() + return value or None + + +@dataclass(frozen=True) +class ServerConfig: + """Resolved configuration for the Flask API process.""" + + api_key: Optional[str] + port: int + db_path: Optional[Path] + + +def load_config() -> ServerConfig: + """Read the SAAS_* environment into a frozen config object. + + Sane defaults, never average anything: the only mutable knob that has a + bare fallback is the port; the API key and DB path are explicit so that a + misconfigured deployment fails loudly instead of serving unauthenticated. + """ + raw_port = os.environ.get("SAAS_PORT", "").strip() + db_path_raw = os.environ.get("SAAS_DB_PATH", "").strip() + return ServerConfig( + api_key=_env_nonempty("SAAS_API_KEY"), + port=int(raw_port) if raw_port else DEFAULT_PORT, + db_path=Path(db_path_raw).expanduser() if db_path_raw else DEFAULT_DB_PATH, + ) + + +def api_key() -> Optional[str]: + """Convenience accessor matching :func:`load_config` behaviour.""" + return _env_nonempty("SAAS_API_KEY") diff --git a/gitea_saas/server.py b/gitea_saas/server.py new file mode 100644 index 0000000..efecfb9 --- /dev/null +++ b/gitea_saas/server.py @@ -0,0 +1,365 @@ +"""Flask HTTP API over the billing core. + +Consumed core interface (issue #1, ``gitea_saas/billing_core.py``): + + Every operation returns ``(ok, details)`` and never raises for domain + failures; ``ok`` is bool and ``details`` is a dict. Domain errors carry + ``details = {"error": {"code": "...", "message": "..."}}`` with codes + such as ``UNKNOWN_CLIENT``, ``INSUFFICIENT_BALANCE``, ``INVALID_AMOUNT``, + ``HOLD_NOT_FOUND``, ``INTERNAL`` (see ``billing_core`` for the full set). + + create_client(name, tier="free", status="active", meta=None) + -> {"client_id", "name", "tier", + "status", "meta", "created_at"} + get_client(client_id, include_balance=True) + -> {"client": {...}, "balance": N} + credit(client_id, amount_minor, idempotency_key, + currency="USD", ref_type=None, ref_id=None) + -> {"ledger_id", "client_id", + "balance", ...} + debit(...) -> same shape as credit + reserve_hold(client_id, amount_minor, idempotency_key, + ref_type=None, ref_id=None, expires_in_minutes=30) + -> {"hold_id", "client_id", + "amount_minor", "balance", ...} + release_hold(hold_id, idempotency_key) + -> {"hold_id", "released_at", + "balance", ...} + list_ledger(client_id, from_=None, to=None) + -> {"entries": [ledger row, ...], + "count": N} + +No ``list_clients`` exists on the core (issue #1 did not land one), so this +API has no list-all-clients endpoint. + +Duplicate ``idempotency_key`` mutations are no-ops that return the original +result with ``ok=True`` and ``replayed=True`` (never an error). Balances and +amounts are integers in minor units. + +The factory is deliberately pure HTTP: the core is injected, which keeps the +tests hermetic (Flask test client + temp DB) and free of any network/Stripe. +""" + +from __future__ import annotations + +import hmac +import sys +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from flask import Flask, jsonify, request + +from .config import API_KEY_HEADER, load_config + + +class ApiError(Exception): + """An error that maps to a concrete HTTP response.""" + + def __init__(self, status: int, code: str, message: str): + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +def _core(app: Flask) -> Any: + return app.config["CORE"] + + +def _error(status: int, code: str, message: str): + payload = {"error": message, "code": code} + resp = jsonify(payload) + resp.status_code = status + return resp + + +# -------------------------------------------------------------------------- +# Core result handling +# -------------------------------------------------------------------------- + +def _call(app: Flask, fn: str, **kwargs) -> Dict[str, Any]: + """Invoke a core operation, enforcing the (ok, details) contract.""" + try: + op = getattr(_core(app), fn) + except AttributeError: + raise ApiError(501, "not_implemented", + f"core does not implement {fn}()") + try: + result = op(**kwargs) + except TypeError as exc: + # Guard against core variants with different signatures surfacing as + # 500s instead of a clear 400/501. + raise ApiError(500, "core_contract", f"core.{fn}() failed: {exc}") + except Exception as exc: # noqa: BLE001 - fail closed, never leak internals + raise ApiError(500, "core_error", f"core.{fn}() raised: {exc!r}") + if not isinstance(result, tuple) or len(result) != 2: + raise ApiError(500, "core_contract", + f"core.{fn}() must return (ok, details)") + ok, details = result + if not ok: + raise _map_core_error(fn, details) + return details if isinstance(details, dict) else {"result": details} + + +def _map_core_error(fn: str, details: Any) -> ApiError: + """Map a real-core error payload to an HTTP error. + + The real ``BillingCore`` reports failures as + ``(False, {"error": {"code": ..., "message": ...}})`` with codes such as + ``UNKNOWN_CLIENT`` / ``INSUFFICIENT_BALANCE`` / ``INVALID_AMOUNT`` / + ``HOLD_NOT_FOUND``. Translate those to HTTP statuses while keeping the + response body a plain ``{"error", "code"}`` pair. + """ + err = details.get("error") if isinstance(details, dict) else None + if isinstance(err, dict): + code = err.get("code") + message = err.get("message") + else: + # Defensive: tolerate the older flat {code, error} shape. + code = details.get("code") if isinstance(details, dict) else None + message = details.get("error") if isinstance(details, dict) else str(details) + message = message or f"{fn} failed" + + status_map = { + "UNKNOWN_CLIENT": 404, + "HOLD_NOT_FOUND": 404, + "INSUFFICIENT_BALANCE": 409, + "DUPLICATE_CLIENT": 409, + "HOLD_ALREADY_RELEASED": 409, + "IDEMPOTENCY_CONFLICT": 409, + "INVALID_AMOUNT": 400, + "INVALID_KEY": 400, + "INVALID_CURRENCY": 400, + "INVALID_NAME": 400, + "INVALID_HOLD_TTL": 400, + "INTERNAL": 500, + } + if code in status_map: + return ApiError(status_map[code], code.lower(), message) + # Unknown structured errors are still surfaced (fail-closed), not 500'd. + if code: + return ApiError(400, code, message) + return ApiError(500, "core_error", message) + + +def _bad_request(message: str) -> ApiError: + return ApiError(400, "invalid_request", message) + + +def _require_int_field(body: Dict[str, Any], name: str, *, positive: bool = True) -> int: + value = body.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise _bad_request(f"'{name}' must be an integer (minor units)") + if positive and value <= 0: + raise _bad_request(f"'{name}' must be a positive integer") + if not positive and value < 0: + raise _bad_request(f"'{name}' must be a non-negative integer") + return value + + +def _require_key(body: Dict[str, Any], name: str) -> str: + value = body.get(name) + if not isinstance(value, str) or not value.strip(): + raise _bad_request(f"'{name}' must be a non-empty string") + return value.strip() + + +def _optional_str(body: Dict[str, Any], name: str) -> Optional[str]: + value = body.get(name) + if value is None or value == "": + return None + if not isinstance(value, str): + raise _bad_request(f"'{name}' must be a string") + return value + + +def _json_body() -> Dict[str, Any]: + body = request.get_json(silent=True) + if body is None or not isinstance(body, dict): + raise _bad_request("request body must be a JSON object") + return body + + +def _client_out(client: Dict[str, Any]) -> Dict[str, Any]: + """Normalise a real-core client payload for the wire. + + The real ``BillingCore`` returns two shapes: + * flat from ``create_client``: ``{client_id, name, tier, status, + meta, created_at}`` + * nested from ``get_client``: ``{"client": {...}, "balance": N}`` + + This handles both and always exposes the API's ``id`` field (the core + calls it ``client_id``) plus the balance when present. Unknown keys are + passed through rather than dropped, so a contract mismatch becomes a + visible test failure instead of silent data loss. + """ + if not isinstance(client, dict): + return client + # Unwrap the nested get_client shape. + inner = client.get("client") + if isinstance(inner, dict): + out = dict(inner) + if "balance" in client: + out["balance"] = client["balance"] + client = out + out = dict(client) + if "client_id" in out and "id" not in out: + out["id"] = out.pop("client_id") + return out + + +# -------------------------------------------------------------------------- +# App factory +# -------------------------------------------------------------------------- + +def create_app(core: Any, api_key: str) -> Flask: + """Build the Flask app around a billing ``core`` instance. + + ``api_key`` is matched against the ``X-API-Key`` header on every route + except ``/health``. Missing or wrong keys get ``401``. + """ + app = Flask(__name__) + app.config["CORE"] = core + app.config["API_KEY"] = api_key or "" # empty -> deny all (fail-closed) + + @app.before_request + def _require_api_key(): + if request.path.rstrip("/") == "/health": + return None + sent = request.headers.get(API_KEY_HEADER, "") + expected = app.config["API_KEY"] + if not expected or not isinstance(sent, str) or not hmac.compare_digest( + sent, expected): + return _error(401, "unauthorized", "missing or invalid API key") + return None + + @app.errorhandler(ApiError) + def _handle_api_error(exc: ApiError): + return _error(exc.status, exc.code, exc.message) + + @app.errorhandler(404) + def _handle_404(exc): + return _error(404, "not_found", "resource not found") + + # -- public ---------------------------------------------------------- + @app.get("/health") + def health(): + return jsonify({"ok": True}) + + # -- clients --------------------------------------------------------- + @app.post("/api/clients") + def create_client(): + body = _json_body() + name = _require_key(body, "name") + tier = _optional_str(body, "tier") + meta = body.get("meta") + kwargs: Dict[str, Any] = {"name": name} + if tier is not None: + kwargs["tier"] = tier + if meta is not None: + kwargs["meta"] = meta + created = _call(app, "create_client", **kwargs) + client_id = created.get("client_id") or created.get("id") + # Re-fetch through the real core so the response carries the API's + # `id` field plus the current balance (the real create_client does + # not return a balance). + client = _call(app, "get_client", client_id=client_id, include_balance=True) + resp = jsonify(_client_out(client)) + resp.status_code = 201 + return resp + + @app.get("/api/clients/") + def get_client(client_id: int): + client = _call(app, "get_client", client_id=client_id, include_balance=True) + return jsonify(_client_out(client)) + + # -- money movement -------------------------------------------------- + @app.post("/api/clients//credits") + def add_credit(client_id: int): + body = _json_body() + return jsonify(_call(app, "credit", + client_id=client_id, + amount_minor=_require_int_field(body, "amount_minor"), + idempotency_key=_require_key(body, "idempotency_key"), + ref_type=_optional_str(body, "ref_type"), + ref_id=_optional_str(body, "ref_id"))) + + @app.post("/api/clients//debits") + def add_debit(client_id: int): + body = _json_body() + return jsonify(_call(app, "debit", + client_id=client_id, + amount_minor=_require_int_field(body, "amount_minor"), + idempotency_key=_require_key(body, "idempotency_key"), + ref_type=_optional_str(body, "ref_type"), + ref_id=_optional_str(body, "ref_id"))) + + # -- holds ----------------------------------------------------------- + @app.post("/api/clients//holds") + def reserve_hold(client_id: int): + body = _json_body() + kwargs: Dict[str, Any] = { + "client_id": client_id, + "amount_minor": _require_int_field(body, "amount_minor"), + "idempotency_key": _require_key(body, "idempotency_key"), + "ref_type": _optional_str(body, "ref_type"), + "ref_id": _optional_str(body, "ref_id"), + } + ttl = body.get("ttl_s") + if ttl is not None: + if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or ttl <= 0: + raise _bad_request("'ttl_s' must be a positive number (seconds)") + # Real core takes expires_in_minutes (a float, minutes). + kwargs["expires_in_minutes"] = ttl / 60.0 + held = _call(app, "reserve_hold", **kwargs) + resp = jsonify(held) + resp.status_code = 201 + return resp + + @app.post("/api/holds//release") + def release_hold(hold_id: int): + body = _json_body() + return jsonify(_call(app, "release_hold", + hold_id=hold_id, + idempotency_key=_require_key(body, "idempotency_key"))) + + # -- ledger ---------------------------------------------------------- + @app.get("/api/clients//ledger") + def ledger(client_id: int): + from_ = request.args.get("from") + to_ = request.args.get("to") + result = _call(app, "list_ledger", client_id=client_id, + from_=from_ or None, to=to_ or None) + entries = result.get("entries", []) if isinstance(result, dict) else [] + return jsonify({"rows": entries, "count": len(list(entries))}) + + return app + + +def _build_core(cfg): + """Construct the real billing core from config (issue #1 module).""" + from gitea_saas import billing_core # lazy import: pure-server may omit it + db_path = cfg.db_path or Path(":memory:") + for ctor_name in ("BillingCore", "Core", "LedgerCore"): + ctor = getattr(billing_core, ctor_name, None) + if ctor is not None: + return ctor(db_path) + raise RuntimeError( + "gitea_saas.billing_core has no known constructor " + "(expected BillingCore/Core/LedgerCore)" + ) + + +def main() -> None: + """Entrypoint for ``python -m gitea_saas.server``.""" + cfg = load_config() + if not cfg.api_key: + sys.exit("SAAS_API_KEY is unset; refusing to start (fail-closed).") + core = _build_core(cfg) + app = create_app(core, cfg.api_key) + # Deployed behind a reverse proxy in practice; still default to loopback. + app.run(host="127.0.0.1", port=cfg.port) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 3f0f5f0..2053737 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -# Runtime: pure stdlib (sqlite3, pathlib, threading, json). No third-party deps. +# Runtime: Flask for the billing HTTP API (#2). Core (#1) is pure stdlib. # Test/dev dependency: +flask>=3.0 pytest>=8.0 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..b67466f --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,325 @@ +"""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"