feat(#2): Billing HTTP API (Flask, token-auth, fail-closed) #13

Merged
pbwraith merged 2 commits from issue-2 into main 2026-08-26 09:08:40 +02:00
4 changed files with 750 additions and 1 deletions

58
gitea_saas/config.py Normal file
View File

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

365
gitea_saas/server.py Normal file
View File

@ -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/<int:client_id>")
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/<int:client_id>/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/<int:client_id>/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/<int:client_id>/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/<int:hold_id>/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/<int:client_id>/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()

View File

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

325
tests/test_server.py Normal file
View File

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