gitea-saas-core#4: factory intake — client repo provisioning + unfunded gating

GiteaClient (shared seam): create_org_repo/repo_exists/create_issue plus
the review-gate surface (get_issue/get_pr/get_pr_diff/post_comment).
IntakeFactory: provision_client (idempotent), submit_issue (funded ships,
unfunded/locked queues), unlock (flush with p1), lock, client_unfunded.
Factory tables (client_repos, pending_issues, factory_locks) are created
idempotently against the billing SQLite file. register_factory_routes
mounts the /api/factory/* endpoints on the issue #2 Flask app.

Gates live in the service only: no dispatcher/lane-script coupling.
This commit is contained in:
Claude 2026-08-25 05:42:22 -05:00
parent 59c3d035b1
commit 41a9ba78c3
3 changed files with 1133 additions and 0 deletions

162
gitea_saas/gitea_client.py Normal file
View File

@ -0,0 +1,162 @@
"""Gitea API client (mockable seam, env token).
Shared by the factory front-end (issue #4) and the review gate (issue #5).
Real HTTP only when a token is available; the token is read from
``GITEA_TOKEN`` (or passed in) and is never printed or placed on a command
line. Tests use an in-memory fake, so this module is never exercised over
the network in CI.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any, Optional
class GiteaClient:
"""Thin urllib client for the subset of the Gitea API the SaaS needs.
``owner`` defaults to the ``GITEA_OWNER`` environment variable and then
to ``pbwraith`` (the self-hosted instance's user). Repo-scoped calls
build ``/api/v1/repos/{owner}/{repo}/...``; org-repo creation uses
``/api/v1/orgs/{owner}/repos`` and falls back to the authenticated
user's repos when ``owner`` is a user account (orgs endpoint 404s).
"""
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
owner: Optional[str] = None):
self.base_url = (base_url or os.environ.get(
"GITEA_BASE_URL", "https://gitea.185.214.135.234.nip.io")).rstrip("/")
self.token = token if token is not None else os.environ.get("GITEA_TOKEN", "")
self.owner = (owner or os.environ.get("GITEA_OWNER") or "pbwraith").strip() or "pbwraith"
# -- low-level ---------------------------------------------------------
def _api(self, method: str, api_path: str, body: Optional[dict] = None) -> Any:
"""Perform an API call under ``/api/v1/{api_path}`` and parse JSON."""
data = json.dumps(body).encode() if body is not None else None
url = f"{self.base_url}/api/v1/{api_path}"
req = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "token " + self.token,
"Accept": "application/json",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
"""Repo-scoped call: ``path`` = ``<repo>/...`` under the owner."""
return self._api(method, f"repos/{self.owner}/{path}", body)
# -- API surface used by the factory front-end (issue #4) --------------
def repo_exists(self, name: str) -> bool:
"""True when ``owner/name`` already exists on the instance.
A 404 means absent; any other HTTP error propagates (fail-closed:
we never claim a repo is missing when the check itself failed).
"""
try:
self._request("GET", f"{name}")
except urllib.error.HTTPError as exc:
if exc.code == 404:
return False
raise
return True
def create_org_repo(self, name: str) -> dict:
"""Create ``owner/name`` if missing; return the repo record.
Idempotent by construction: when the repo already exists the result
of ``repo_exists`` is returned immediately (no duplicate creation).
Uses ``orgs/{owner}/repos`` when ``owner`` is an organization and
falls back to the authenticated user's repos (``user/repos``) when
it is a user account -- the self-hosted default.
"""
name = (name or "").strip()
if not name:
raise ValueError("repo name must be a non-empty string")
if self.repo_exists(name):
# Reuse: fetch the record so callers see the canonical shape.
return self._request("GET", f"{name}") or {}
payload: dict = {"name": name, "auto_init": False, "private": True}
try:
return self._api("POST", f"orgs/{self.owner}/repos", payload)
except urllib.error.HTTPError as exc:
if exc.code not in (404, 422):
raise
# ``owner`` is a user account (or lacks org-create rights): fall back
# to creating the repo under the authenticated token user.
return self._api("POST", "user/repos", payload) or {}
def list_labels(self, repo: str) -> list:
"""List ``owner/repo``'s labels: [{id, name, ...}, ...]."""
return self._request("GET", f"{repo}/labels") or []
def label_id(self, repo: str, name: str) -> Optional[int]:
"""Resolve a label name to its id in ``owner/repo`` (None if absent)."""
name = (name or "").strip()
for label in self.list_labels(repo):
if label.get("name") == name:
return label.get("id")
return None
def create_issue(self, repo: str, title: str, body: str,
labels: Optional[list] = None) -> int:
"""Create an issue in ``owner/repo``; return its number.
``labels`` is a list of label NAMES; each is resolved to the repo's
label id and unknown names are skipped (the issue is still created).
"""
title = (title or "").strip()
if not title:
raise ValueError("issue title must be a non-empty string")
label_ids = []
for name in labels or []:
if not isinstance(name, str) or not name.strip():
continue
label_id = self.label_id(repo, name)
if label_id is not None and label_id not in label_ids:
label_ids.append(label_id)
payload = {"title": title, "body": body or ""}
if label_ids:
payload["labels"] = label_ids
created = self._request("POST", f"{repo}/issues", payload) or {}
number = created.get("number")
if not isinstance(number, int):
raise RuntimeError(f"Gitea create_issue returned no issue number: {created!r}")
return number
# -- API surface used by the review gate (issue #5) --------------------
def get_issue(self, repo: str, number: int) -> dict:
"""Fetch an issue: {title, body, state, labels: [...]}."""
return self._request("GET", f"{repo}/issues/{number}") or {}
def get_pr(self, repo: str, number: int) -> dict:
"""Fetch a PR: {head_sha, changed_files: [filename, ...]}."""
pr = self._request("GET", f"{repo}/pulls/{number}") or {}
files = self._request("GET", f"{repo}/pulls/{number}/files") or []
return {
"head_sha": ((pr.get("head") or {}).get("sha")),
"changed_files": [f.get("filename") for f in files if f.get("filename")],
}
def get_pr_diff(self, repo: str, number: int) -> str:
"""Return the PR's diff text reconstructed from per-file patches."""
files = self._request("GET", f"{repo}/pulls/{number}/files") or []
chunks = []
for f in files:
filename = f.get("filename") or "?"
chunks.append(f"diff --git a/{filename} b/{filename}\n"
f"--- a/{filename}\n+++ b/{filename}\n"
+ (f.get("patch") or ""))
return "\n".join(chunks)
def post_comment(self, repo: str, number: int, body: str) -> dict:
"""Post a comment on an issue or PR conversation."""
return self._request("POST", f"{repo}/issues/{number}/comments", {"body": body}) or {}

View File

@ -0,0 +1,443 @@
"""Factory front-end: client repo provisioning + unfunded issue gating.
Option-1 front-end from ARCHITECTURE.md. All gates live HERE, inside the
SaaS service -- the lane survey/dispatcher scripts are never touched for
SaaS concepts (owner rule 2026-08-25): unfunded client work is held in a
local ``pending_issues`` queue and only becomes a Gitea issue after payment.
Owns its schema additions against the SAME SQLite file as the billing core.
``schema.sql`` / ``db.py`` stay owned by issue #1; the factory tables are
created idempotently (``CREATE TABLE IF NOT EXISTS ...``) from this module
so the feature can land without touching the core's files:
client_repos (client_id, repo, created_at) -- repo->client map
pending_issues (id, client_id, repo, title, body_json, labels_json,
created_at, status) -- queued|shipped
factory_locks (client_id, locked_at) -- explicit gating
Money rule (fail-closed): a client is funded iff its available balance is
>= the debit floor and no lock is set. Any uncertainty (unknown client,
core error) is treated as UNFUNDED so work is queued, never silently
shipped without payment.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
# Default floor for "funded": a client must hold at least this many minor
# units (default = 5.00 USD) before intake will ship issues.
DEFAULT_DEBIT_FLOOR_MINOR = 500
# Label applied to every issue flushed by :meth:`IntakeFactory.unlock`.
P1_LABEL = "p1"
_STATUS_QUEUED = "queued"
_STATUS_SHIPPED = "shipped"
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
# Idempotent by construction (same convention as the billing schema).
_FACTORY_DDL = """
CREATE TABLE IF NOT EXISTS client_repos (
client_id INTEGER NOT NULL,
repo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (client_id, repo)
);
CREATE TABLE IF NOT EXISTS pending_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
repo TEXT NOT NULL,
title TEXT NOT NULL,
body_json TEXT NOT NULL,
labels_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'shipped'))
);
CREATE TABLE IF NOT EXISTS factory_locks (
client_id INTEGER PRIMARY KEY,
locked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_pending_client_status ON pending_issues (client_id, status);
"""
def _now_iso() -> str:
"""Current UTC time as an ISO-8601 string (matches billing core format)."""
return datetime.now(timezone.utc).isoformat()
class FactoryError(Exception):
"""Base class for factory domain errors."""
class UnknownClientError(FactoryError):
"""Raised when a client_id does not exist in the billing core."""
class InvalidRequestError(FactoryError):
"""Raised on malformed inputs (empty names, bad repo names, ...)."""
class CoreContractError(FactoryError):
"""Raised when the billing core violates its (ok, details) contract."""
class IntakeFactory:
"""Provision client repos and gate issue intake on funding.
Parameters
----------
core:
The billing core (issue #1 contract): ``get_client(client_id,
include_balance=...) -> (ok, details)``. Duck-typed; never
imported directly so the module stays composable.
gitea:
A :class:`gitea_saas.gitea_client.GiteaClient` (or in-memory mock):
``create_org_repo(name)``, ``repo_exists(name)``,
``create_issue(repo, title, body, labels)``.
config:
Configuration object exposing ``db_path`` (a ``pathlib.Path`` to the
billing SQLite file). Optionally ``debit_floor_minor`` and/or the
``SAAS_DEBIT_FLOOR_MINOR`` env var override the default floor.
"""
def __init__(self, core: Any, gitea: Any, config: Any):
db_path = getattr(config, "db_path", None)
if db_path is None:
raw = os.environ.get("SAAS_DB_PATH", "").strip()
db_path = Path(raw).expanduser() if raw else None
if db_path is None:
raise InvalidRequestError(
"IntakeFactory requires config.db_path (or SAAS_DB_PATH): "
"the factory persists its queue in the billing SQLite file")
self._core = core
self._gitea = gitea
self._config = config
self._conn = sqlite3.connect(str(Path(db_path)), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._conn.executescript(_FACTORY_DDL)
self._lock = threading.RLock()
# ------------------------------------------------------------------
# Config
# ------------------------------------------------------------------
@property
def debit_floor_minor(self) -> int:
"""The funded threshold in minor units (config attr > env > default)."""
value = getattr(self._config, "debit_floor_minor", None)
if value is None:
raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip()
value = int(raw) if raw else DEFAULT_DEBIT_FLOOR_MINOR
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise InvalidRequestError(f"invalid debit floor: {value!r}")
return value
# ------------------------------------------------------------------
# Billing-core helpers (fail-closed)
# ------------------------------------------------------------------
def _client_record(self, client_id: int, include_balance: bool = False) -> Dict[str, Any]:
"""Fetch a client record from the core; raise when unknown/broken."""
ok, details = self._core.get_client(client_id, include_balance=include_balance)
if not ok:
code = (details or {}).get("error", {}).get("code") if isinstance(details, dict) else None
if code and "UNKNOWN" in str(code).upper():
raise UnknownClientError(f"no client with id {client_id}")
raise CoreContractError(f"core.get_client({client_id}) failed: {details!r}")
if not isinstance(details, dict) or (include_balance and not isinstance(
details.get("balance"), int)):
raise CoreContractError(f"core.get_client({client_id}) violated its contract: {details!r}")
return details
def _client_balance(self, client_id: int) -> Optional[int]:
"""Available balance, or None when funding cannot be confirmed."""
try:
details = self._client_record(client_id, include_balance=True)
except (UnknownClientError, CoreContractError):
return None
return details.get("balance")
def client_unfunded(self, client_id: int) -> bool:
"""True when the client's balance is below the debit floor.
Fail-closed: an unknown client or a core error also reports
``True`` -- intake queues instead of shipping unverified work.
"""
balance = self._client_balance(client_id)
if balance is None:
return True
return balance < self.debit_floor_minor
def _require_client(self, client_id: int) -> Dict[str, Any]:
"""Validate existence (no balance needed) and return the record."""
if isinstance(client_id, bool) or not isinstance(client_id, int) or client_id <= 0:
raise InvalidRequestError("client_id must be a positive integer")
return self._client_record(client_id, include_balance=False)
def _is_locked(self, client_id: int) -> bool:
row = self._conn.execute(
"SELECT 1 FROM factory_locks WHERE client_id = ?", (client_id,)).fetchone()
return row is not None
# ------------------------------------------------------------------
# Repo provisioning
# ------------------------------------------------------------------
@staticmethod
def _default_repo_name(client_record: Dict[str, Any]) -> str:
"""Derive a repo name from the client record (client name slug)."""
name = (client_record.get("client") or {}).get("name") or ""
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip().lower()).strip("-")
return slug[:60] or f"client-{client_record.get('client_id')}"
def provision_client(self, client_id: int, repo_name: Optional[str] = None) -> str:
"""Provision ``owner/<repo>`` for a client; returns the repo name.
Idempotent: a second call for the same client reuses the existing
mapping and never creates the repo twice. When ``repo_name`` is
omitted it is derived from the client's name.
"""
record = self._require_client(client_id)
if repo_name is not None:
repo_name = (repo_name or "").strip()
if not _REPO_NAME_RE.match(repo_name):
raise InvalidRequestError(
f"invalid repo name: {repo_name!r} (letters, digits, . _ -; 1-100 chars)")
else:
repo_name = self._default_repo_name(record)
with self._lock:
mapped = self._conn.execute(
"SELECT repo FROM client_repos WHERE client_id = ? AND repo = ?",
(client_id, repo_name)).fetchone()
if mapped is None:
self._gitea.create_org_repo(repo_name)
self._conn.execute(
"INSERT OR IGNORE INTO client_repos (client_id, repo, created_at)"
" VALUES (?, ?, ?)", (client_id, repo_name, _now_iso()))
self._conn.commit()
return repo_name
# ------------------------------------------------------------------
# Issue intake (funded shipping vs unfunded queue)
# ------------------------------------------------------------------
def submit_issue(self, client_id: int, repo: str, title: str,
acceptance_body: str, labels: Optional[List[str]] = None) -> Dict[str, Any]:
"""Submit an issue for a client.
Funded + unlocked -> created in Gitea immediately,
``{delivered: True, issue_number: N}``.
Unfunded or locked -> queued in ``pending_issues``,
``{delivered: False, pending_id: N, reason: 'unfunded'|'locked'}``.
"""
self._require_client(client_id)
repo = (repo or "").strip()
title = (title or "").strip()
if not _REPO_NAME_RE.match(repo):
raise InvalidRequestError(f"invalid repo name: {repo!r}")
if not title:
raise InvalidRequestError("issue title must be a non-empty string")
reason = "locked" if self._is_locked(client_id) else (
"unfunded" if self.client_unfunded(client_id) else None)
if reason is not None:
with self._lock:
cur = self._conn.execute(
"INSERT INTO pending_issues (client_id, repo, title, body_json,"
" labels_json, created_at, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
(client_id, repo, title,
json.dumps(acceptance_body or ""),
json.dumps([str(l) for l in (labels or [])]),
_now_iso(), _STATUS_QUEUED))
self._conn.commit()
pending_id = cur.lastrowid
return {"delivered": False, "pending_id": pending_id, "reason": reason}
issue_number = self._gitea.create_issue(repo, title, acceptance_body or "", labels)
return {"delivered": True, "issue_number": issue_number}
# ------------------------------------------------------------------
# Lock / unlock
# ------------------------------------------------------------------
def lock(self, client_id: int) -> Dict[str, Any]:
"""Stop shipping: future submits queue instead of creating issues.
Idempotent. No label games and no dispatcher coupling -- the gate
is this service's local state.
"""
self._require_client(client_id)
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO factory_locks (client_id, locked_at)"
" VALUES (?, ?)", (client_id, _now_iso()))
self._conn.commit()
return {"client_id": client_id, "locked": True}
def unlock(self, client_id: int) -> Dict[str, Any]:
"""Flush ALL queued issues for the client into Gitea (with ``p1``).
Each flushed issue is created with its original labels plus ``p1``
and marked ``shipped``. Unlocking also clears an active lock so the
client ships again. A second unlock with nothing queued is a
no-op: ``{flushed: 0, issues: []}``.
"""
self._require_client(client_id)
with self._lock:
rows = self._conn.execute(
"SELECT * FROM pending_issues WHERE client_id = ? AND status = ?"
" ORDER BY id", (client_id, _STATUS_QUEUED)).fetchall()
numbers: List[int] = []
for row in rows:
labels = [str(l) for l in json.loads(row["labels_json"] or "[]")]
if P1_LABEL not in labels:
labels.append(P1_LABEL)
number = self._gitea.create_issue(
row["repo"], row["title"], json.loads(row["body_json"] or '""'), labels)
self._conn.execute(
"UPDATE pending_issues SET status = ? WHERE id = ?",
(_STATUS_SHIPPED, row["id"]))
numbers.append(number)
cleared = self._conn.execute(
"DELETE FROM factory_locks WHERE client_id = ?", (client_id,)).rowcount
self._conn.commit()
return {"flushed": len(numbers), "issues": numbers, "lock_cleared": cleared > 0}
# ---------------------------------------------------------------------------
# HTTP surface (mounted by the issue #2 Flask app; see register_factory_routes)
# ---------------------------------------------------------------------------
#
# Routes (same auth discipline as gitea_saas.server -- X-API-Key, fail-closed):
# POST /api/factory/provision {"client_id": int, "repo_name"?: str}
# POST /api/factory/<client_id>/issues {"repo", "title", "acceptance_body",
# "labels"?: [...]}
# POST /api/factory/<client_id>/unlock {}
# POST /api/factory/<client_id>/lock {}
#
# server.py is owned by issues #2/#6, so the mount point itself stays out of
# this module; creating the app just calls::
_HOOK_NOTES = """\
from gitea_saas.intake_factory import register_factory_routes
app = create_app(core, api_key)
register_factory_routes(app, core, gitea_client, cfg)
"""
def register_factory_routes(app: Any, core: Any, gitea: Any, config: Any) -> None:
"""Mount the factory endpoints on a Flask app (idempotent).
The app keeps its own ``CORE``/``API_KEY`` config keys; this function
only adds routes that build an :class:`IntakeFactory` per request (the
factory is stateless apart from its own SQLite connection, which is
cheap to open and thread-safe via WAL + a per-instance lock).
"""
from flask import jsonify, request
api_key = app.config.get("API_KEY") or getattr(config, "api_key", None) or ""
def _factory() -> IntakeFactory:
return IntakeFactory(core, gitea, config)
def _authorized() -> bool:
sent = request.headers.get("X-API-Key", "")
return bool(api_key) and sent == api_key
def _require_auth():
if not _authorized():
resp = jsonify({"error": "missing or invalid API key", "code": "unauthorized"})
resp.status_code = 401
return resp
return None
@app.get("/api/factory/health")
def factory_health():
return jsonify({"ok": True})
@app.post("/api/factory/provision")
def factory_provision():
if (denied := _require_auth()) is not None:
return denied
body = request.get_json(silent=True) or {}
raw_id = body.get("client_id")
if isinstance(raw_id, bool) or not isinstance(raw_id, int) or raw_id <= 0:
return _json_error(400, "invalid_request", "client_id must be a positive integer")
client_id = raw_id
repo_name = body.get("repo_name")
try:
repo = _factory().provision_client(client_id, repo_name or None)
except Exception as exc:
return _map_factory_error(exc)
return jsonify({"repo": repo})
@app.post("/api/factory/<int:client_id>/issues")
def factory_submit(client_id: int):
if (denied := _require_auth()) is not None:
return denied
body = request.get_json(silent=True) or {}
repo = body.get("repo")
title = body.get("title")
acceptance_body = body.get("acceptance_body") or ""
labels = body.get("labels")
if not isinstance(labels, list):
labels = None
try:
result = _factory().submit_issue(
client_id, repo or "", title or "", acceptance_body,
[str(l) for l in labels] if labels else None)
except Exception as exc:
return _map_factory_error(exc)
resp = jsonify(result)
resp.status_code = 202 if not result["delivered"] else 201
return resp
@app.post("/api/factory/<int:client_id>/unlock")
def factory_unlock(client_id: int):
if (denied := _require_auth()) is not None:
return denied
try:
return jsonify(_factory().unlock(client_id))
except Exception as exc:
return _map_factory_error(exc)
@app.post("/api/factory/<int:client_id>/lock")
def factory_lock(client_id: int):
if (denied := _require_auth()) is not None:
return denied
try:
return jsonify(_factory().lock(client_id))
except Exception as exc:
return _map_factory_error(exc)
def _json_error(status: int, code: str, message: str):
from flask import jsonify
resp = jsonify({"error": message, "code": code})
resp.status_code = status
return resp
def _map_factory_error(exc: Exception):
"""Map factory domain errors to HTTP responses (fail-closed on unknown)."""
if isinstance(exc, UnknownClientError):
return _json_error(404, "unknown_client", str(exc))
if isinstance(exc, (InvalidRequestError, ValueError)):
return _json_error(400, "invalid_request", str(exc))
if isinstance(exc, CoreContractError):
return _json_error(500, "core_contract", str(exc))
return _json_error(500, "factory_error", f"{type(exc).__name__}: {exc}")

View File

@ -0,0 +1,528 @@
"""Tests for ``gitea_saas.intake_factory`` (factory intake, issue #4).
Hermetic by design: an in-memory ``MockGitea`` + a reference billing core
over a temp SQLite DB. No network, no Stripe, no Gitea, no dispatcher
files.
Covers the issue #4 acceptance checklist:
1. provision creates repo once; second call reuses (idempotent)
2. funded submit creates Gitea issue immediately, delivered=true
3. unfunded submit queues (no Gitea issue created), delivered=false
4. unlock flushes queued -> Gitea with p1 label, marks shipped, second
unlock is a no-op
5. lock stops shipping: subsequent submits queue again
6. client with balance below floor is ``client_unfunded() == True``
7. all tests against MockGitea + tmp DB; no network
"""
from __future__ import annotations
import json
import sqlite3
import types
from datetime import datetime, timezone
from pathlib import Path
import pytest
from gitea_saas.intake_factory import (
DEFAULT_DEBIT_FLOOR_MINOR,
IntakeFactory,
UnknownClientError,
register_factory_routes,
)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Reference billing core (issue #1 contract subset: (ok, details) tuples)
# ---------------------------------------------------------------------------
class ReferenceCore:
"""Minimal billing core honouring the documented ``(ok, details)``
contract over a temp SQLite DB (subset used by the factory)."""
SCHEMA = """
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
tier TEXT NOT NULL DEFAULT 'standard',
status TEXT NOT NULL DEFAULT 'active',
meta_json TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id),
kind TEXT NOT NULL CHECK (kind IN ('credit','debit','hold','release','refund')),
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
idempotency_key TEXT UNIQUE NOT NULL,
ref_type TEXT,
ref_id TEXT,
created_at TEXT NOT NULL
);
"""
def __init__(self, db_path: Path):
self._conn = sqlite3.connect(str(db_path))
self._conn.row_factory = sqlite3.Row
self._conn.executescript(self.SCHEMA)
self._seq = 0
def _key(self) -> str:
self._seq += 1
return f"ref-{self._seq}"
def create_client(self, name: str, meta: dict | None = None):
try:
cur = self._conn.execute(
"INSERT INTO clients (name, meta_json, created_at) VALUES (?, ?, ?)",
(name, json.dumps(meta or {}), _now_iso()))
except sqlite3.IntegrityError:
return False, {"error": {"code": "DUPLICATE_CLIENT", "message": name}}
self._conn.commit()
return True, {"client_id": cur.lastrowid, "name": name}
def credit(self, client_id: int, amount_minor: int):
self._conn.execute(
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
" idempotency_key, created_at) VALUES (?, 'credit', ?, 'USD', ?, ?)",
(client_id, amount_minor, self._key(), _now_iso()))
self._conn.commit()
return True, {"client_id": client_id, "balance": self._balance(client_id)}
def _balance(self, client_id: int) -> int:
row = self._conn.execute(
"SELECT COALESCE(SUM(CASE WHEN kind='credit' THEN amount_minor"
" ELSE -amount_minor END), 0) FROM ledger WHERE client_id = ?",
(client_id,)).fetchone()
return int(row[0])
def get_client(self, client_id: int, include_balance: bool = True):
row = self._conn.execute(
"SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()
if row is None:
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
client = dict(row)
client["meta"] = json.loads(client.pop("meta_json") or "{}")
details = {"client": client}
if include_balance:
details["balance"] = self._balance(client_id)
return True, details
def get_balance(self, client_id: int):
row = self._conn.execute(
"SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone()
if row is None:
return False, {"error": {"code": "UNKNOWN_CLIENT", "message": "no client"}}
return True, {"client_id": client_id, "balance": self._balance(client_id)}
# ---------------------------------------------------------------------------
# MockGitea (in-memory fake of gitea_saas.gitea_client.GiteaClient)
# ---------------------------------------------------------------------------
class MockGitea:
"""In-memory Gitea: repos, per-repo labels (names -> ids) and issues."""
def __init__(self):
self.repos: dict = {}
self.labels: dict = {}
self.created_repo_calls: list = []
self._label_seq = 0
# -- repo provisioning -------------------------------------------------
def repo_exists(self, name: str) -> bool:
return name in self.repos
def create_org_repo(self, name: str) -> dict:
self.created_repo_calls.append(name)
if name not in self.repos:
self.repos[name] = {"name": name, "issues": []}
self.labels[name] = {"p1": self._next_label_id()}
return {"name": name}
# -- labels ------------------------------------------------------------
def _next_label_id(self) -> int:
self._label_seq += 1
return self._label_seq
def add_label(self, repo: str, name: str) -> int:
if name not in self.labels.setdefault(repo, {}):
self.labels[repo][name] = self._next_label_id()
return self.labels[repo][name]
def list_labels(self, repo: str) -> list:
return [{"id": i, "name": n} for n, i in self.labels.get(repo, {}).items()]
def label_id(self, repo: str, name: str) -> int | None:
return self.labels.get(repo, {}).get(name)
# -- issues ------------------------------------------------------------
def create_issue(self, repo: str, title: str, body: str,
labels: list | None = None) -> int:
rec = self.repos[repo]
number = len(rec["issues"]) + 1
label_ids = []
for name in labels or []:
lid = self.label_id(repo, name)
if lid is not None and lid not in label_ids:
label_ids.append(lid)
rec["issues"].append({
"number": number, "title": title, "body": body, "labels": label_ids,
})
return number
def issue(self, repo: str, number: int) -> dict:
return self.repos[repo]["issues"][number - 1]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def env(tmp_path: Path, monkeypatch):
core = ReferenceCore(tmp_path / "billing.db")
gitea = MockGitea()
cfg = types.SimpleNamespace(db_path=tmp_path / "billing.db",
api_key="test-api-key",
debit_floor_minor=DEFAULT_DEBIT_FLOOR_MINOR)
factory = IntakeFactory(core, gitea, cfg)
return types.SimpleNamespace(core=core, gitea=gitea, cfg=cfg, factory=factory,
db_path=tmp_path / "billing.db")
def _client(env, name: str = "Acme Corp", amount_minor: int = 0) -> int:
ok, details = env.core.create_client(name)
assert ok, details
client_id = details["client_id"]
if amount_minor:
ok, _ = env.core.credit(client_id, amount_minor)
assert ok
return client_id
def _queued_rows(db_path: Path, client_id: int) -> list:
conn = sqlite3.connect(str(db_path))
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM pending_issues WHERE client_id = ? ORDER BY id",
(client_id,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def _mapping_rows(db_path: Path, client_id: int) -> list:
conn = sqlite3.connect(str(db_path))
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM client_repos WHERE client_id = ?", (client_id,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------------------------------------------------------------------------
# 1. provision: creates once, reuses on second call (idempotent)
# ---------------------------------------------------------------------------
def test_provision_creates_repo_once_and_reuses(env):
cid = _client(env, "Acme Corp")
repo = env.factory.provision_client(cid)
assert repo == "acme-corp"
assert env.gitea.repo_exists("acme-corp")
assert env.gitea.created_repo_calls == ["acme-corp"]
# Second provision: same repo, no duplicate creation, one mapping row.
again = env.factory.provision_client(cid)
assert again == "acme-corp"
assert env.gitea.created_repo_calls == ["acme-corp"]
assert len(_mapping_rows(env.db_path, cid)) == 1
def test_provision_explicit_repo_name_and_cross_client_isolation(env):
cid = _client(env, "Globex")
repo = env.factory.provision_client(cid, "globex-main")
assert repo == "globex-main"
assert env.gitea.repo_exists("globex-main")
# A different client gets its own mapping and repo.
cid2 = _client(env, "Initech")
repo2 = env.factory.provision_client(cid2)
assert repo2 == "initech"
assert len(env.gitea.created_repo_calls) == 2
assert len(_mapping_rows(env.db_path, cid)) == 1
assert len(_mapping_rows(env.db_path, cid2)) == 1
def test_provision_rejects_bad_repo_name_and_unknown_client(env):
cid = _client(env)
with pytest.raises(Exception):
env.factory.provision_client(cid, "bad name with spaces")
with pytest.raises(UnknownClientError):
env.factory.provision_client(9999, "whatever-repo")
# ---------------------------------------------------------------------------
# 2. funded submit: Gitea issue immediately, delivered=true
# ---------------------------------------------------------------------------
def test_funded_submit_delivers_immediately(env):
cid = _client(env, "Acme Corp", amount_minor=10_000)
env.factory.provision_client(cid)
env.gitea.add_label("acme-corp", "p2")
result = env.factory.submit_issue(
cid, "acme-corp", "Fix the bug",
"## Acceptance\n- [ ] it works", labels=["p2"])
assert result == {"delivered": True, "issue_number": 1}
issue = env.gitea.issue("acme-corp", 1)
assert issue["title"] == "Fix the bug"
assert issue["body"] == "## Acceptance\n- [ ] it works"
assert issue["labels"] == [env.gitea.label_id("acme-corp", "p2")]
# Nothing queued for a funded direct delivery.
assert _queued_rows(env.db_path, cid) == []
def test_funded_submit_without_labels(env):
cid = _client(env, "Acme Corp", amount_minor=10_000)
env.factory.provision_client(cid)
result = env.factory.submit_issue(cid, "acme-corp", "No labels", "")
assert result["delivered"] is True
assert env.gitea.issue("acme-corp", 1)["labels"] == []
# ---------------------------------------------------------------------------
# 3. unfunded submit: queued, no Gitea issue, delivered=false
# ---------------------------------------------------------------------------
def test_unfunded_submit_queues(env):
cid = _client(env, "Acme Corp", amount_minor=0)
env.factory.provision_client(cid)
result = env.factory.submit_issue(
cid, "acme-corp", "Queued work", "## Acceptance\n- [ ] later")
assert result["delivered"] is False
assert result["reason"] == "unfunded"
assert isinstance(result["pending_id"], int)
assert env.gitea.repos["acme-corp"]["issues"] == []
rows = _queued_rows(env.db_path, cid)
assert len(rows) == 1
row = rows[0]
assert row["status"] == "queued"
assert row["title"] == "Queued work"
assert json.loads(row["body_json"]) == "## Acceptance\n- [ ] later"
assert json.loads(row["labels_json"]) == []
# ---------------------------------------------------------------------------
# 4. unlock: flush queued -> Gitea with p1 label, shipped, second no-op
# ---------------------------------------------------------------------------
def test_unlock_flushes_queued_with_p1_and_is_idempotent(env):
cid = _client(env, "Acme Corp", amount_minor=0)
env.factory.provision_client(cid)
env.gitea.add_label("acme-corp", "p3")
r1 = env.factory.submit_issue(cid, "acme-corp", "Task A", "body A", labels=["p3"])
r2 = env.factory.submit_issue(cid, "acme-corp", "Task B", "body B")
assert r1["delivered"] is False and r2["delivered"] is False
flushed = env.factory.unlock(cid)
assert flushed["flushed"] == 2
assert flushed["issues"] == [1, 2]
p1 = env.gitea.label_id("acme-corp", "p1")
p3 = env.gitea.label_id("acme-corp", "p3")
a = env.gitea.issue("acme-corp", 1)
b = env.gitea.issue("acme-corp", 2)
assert a["title"] == "Task A" and a["body"] == "body A"
assert p1 in a["labels"] and p3 in a["labels"]
assert b["labels"] == [p1]
# Rows marked shipped.
rows = _queued_rows(env.db_path, cid)
assert len(rows) == 2 and all(r["status"] == "shipped" for r in rows)
# Second unlock: nothing queued -> no-op.
again = env.factory.unlock(cid)
assert again == {"flushed": 0, "issues": [], "lock_cleared": False}
assert len(env.gitea.repos["acme-corp"]["issues"]) == 2
# ---------------------------------------------------------------------------
# 5. lock: future submits queue even when funded; unlock clears + flushes
# ---------------------------------------------------------------------------
def test_lock_queues_funded_submits_and_unlock_resumes(env):
cid = _client(env, "Acme Corp", amount_minor=10_000)
env.factory.provision_client(cid)
assert env.factory.lock(cid) == {"client_id": cid, "locked": True}
# Funded but locked -> queued, no Gitea issue.
result = env.factory.submit_issue(cid, "acme-corp", "Blocked by lock", "")
assert result == {"delivered": False, "pending_id": 1, "reason": "locked"}
assert env.gitea.repos["acme-corp"]["issues"] == []
# unlock flushes the queued issue AND clears the lock.
flushed = env.factory.unlock(cid)
assert flushed["flushed"] == 1 and flushed["lock_cleared"] is True
assert env.gitea.repos["acme-corp"]["issues"][0]["title"] == "Blocked by lock"
# Lock cleared -> shipping resumes.
again = env.factory.submit_issue(cid, "acme-corp", "Now shipping", "")
assert again == {"delivered": True, "issue_number": 2}
# lock() twice is idempotent; lock() on unknown client raises.
assert env.factory.lock(cid)["locked"] is True
with pytest.raises(UnknownClientError):
env.factory.lock(9999)
# ---------------------------------------------------------------------------
# 6. client_unfunded: below the debit floor (and fail-closed)
# ---------------------------------------------------------------------------
def test_client_unfunded_boundary(env):
cid = _client(env, "Acme Corp", amount_minor=0)
assert env.factory.client_unfunded(cid) is True
env.core.credit(cid, DEFAULT_DEBIT_FLOOR_MINOR - 1) # 499
assert env.factory.client_unfunded(cid) is True
env.core.credit(cid, 1) # 500 == floor
assert env.factory.client_unfunded(cid) is False
env.core.credit(cid, 1000) # 1500
assert env.factory.client_unfunded(cid) is False
def test_client_unfunded_fail_closed_on_unknown_client(env):
# Unknown client -> cannot confirm funding -> unfunded (fail-closed).
assert env.factory.client_unfunded(9999) is True
with pytest.raises(UnknownClientError):
env.factory.submit_issue(9999, "acme-corp", "Nope", "")
def test_floor_override_via_config(env):
cid = _client(env, "Acme Corp", amount_minor=200)
env.cfg.debit_floor_minor = 100
assert env.factory.client_unfunded(cid) is False # 200 >= 100
env.cfg.debit_floor_minor = 300
assert env.factory.client_unfunded(cid) is True # 200 < 300
# ---------------------------------------------------------------------------
# HTTP surface (register_factory_routes), same X-API-Key discipline
# ---------------------------------------------------------------------------
@pytest.fixture()
def app_client(env):
from flask import Flask
app = Flask(__name__)
app.config["API_KEY"] = env.cfg.api_key
register_factory_routes(app, env.core, env.gitea, env.cfg)
return app.test_client()
def _post(app, path, payload=None, key="test-api-key"):
headers = {"X-API-Key": key} if key else {}
return app.post(path, json=payload or {}, headers=headers)
def test_routes_require_api_key(app_client):
resp = app_client.post("/api/factory/provision", json={"client_id": 1})
assert resp.status_code == 401
resp = app_client.post("/api/factory/1/unlock")
assert resp.status_code == 401
def test_route_provision_submit_unlock_lock_flow(env, app_client):
cid = _client(env, "Acme Corp", amount_minor=0)
resp = _post(app_client, "/api/factory/provision", {"client_id": cid})
assert resp.status_code == 200
assert resp.get_json() == {"repo": "acme-corp"}
# Unfunded submit -> 202 queued.
resp = _post(app_client, f"/api/factory/{cid}/issues",
{"repo": "acme-corp", "title": "Queued via API",
"acceptance_body": "## Acceptance"})
assert resp.status_code == 202
body = resp.get_json()
assert body["delivered"] is False and body["reason"] == "unfunded"
# Unlock -> 200 flushed with p1.
resp = _post(app_client, f"/api/factory/{cid}/unlock")
assert resp.status_code == 200
assert resp.get_json()["flushed"] == 1
# Lock -> 200; funded submit afterwards still queues.
env.core.credit(cid, 10_000)
resp = _post(app_client, f"/api/factory/{cid}/lock")
assert resp.get_json() == {"client_id": cid, "locked": True}
resp = _post(app_client, f"/api/factory/{cid}/issues",
{"repo": "acme-corp", "title": "Locked via API"})
assert resp.get_json()["reason"] == "locked"
def test_route_unknown_client_404(env, app_client):
resp = _post(app_client, "/api/factory/provision", {"client_id": 9999})
assert resp.status_code == 404
assert resp.get_json()["code"] == "unknown_client"
def test_route_validation_400(app_client):
resp = _post(app_client, "/api/factory/provision", {"client_id": -1})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# 7. Hermeticity guard
# ---------------------------------------------------------------------------
def test_hermetic_no_real_gitea_or_network_used(env):
"""The whole suite runs against mocks; a real GiteaClient would need a
token and a network. This test pins the factory's seams: every gitea
interaction goes through the injected mock object."""
assert isinstance(env.gitea, MockGitea)
assert isinstance(env.factory._gitea, MockGitea)
def test_real_billing_core_composition(tmp_path: Path):
"""The factory composes with the REAL billing core (issue #1, merged).
Uses the actual ``gitea_saas.billing_core.BillingCore`` over a temp DB
-- still no network: Gitea stays mocked.
"""
from gitea_saas.billing_core import BillingCore
core = BillingCore(tmp_path / "real.db")
gitea = MockGitea()
cfg = types.SimpleNamespace(db_path=tmp_path / "real.db", api_key="k",
debit_floor_minor=DEFAULT_DEBIT_FLOOR_MINOR)
factory = IntakeFactory(core, gitea, cfg)
ok, details = core.create_client("Acme Corp")
assert ok
cid = details["client_id"]
assert core.credit(cid, 10_000, "idem-1")[0] is True
assert factory.provision_client(cid) == "acme-corp"
result = factory.submit_issue(cid, "acme-corp", "Funded with real core", "body")
assert result == {"delivered": True, "issue_number": 1}
ok, details2 = core.create_client("Poor Corp")
assert ok
cid2 = details2["client_id"]
assert factory.provision_client(cid2) == "poor-corp"
queued = factory.submit_issue(cid2, "poor-corp", "Queued", "body")
assert queued["delivered"] is False and queued["reason"] == "unfunded"
flushed = factory.unlock(cid2)
assert flushed["flushed"] == 1
p1 = gitea.label_id("poor-corp", "p1")
assert p1 in gitea.issue("poor-corp", 1)["labels"]