feat(#2): Billing HTTP API (Flask, token-auth, fail-closed) #13
58
gitea_saas/config.py
Normal file
58
gitea_saas/config.py
Normal 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
365
gitea_saas/server.py
Normal 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()
|
||||
@ -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
325
tests/test_server.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user