gitea-saas-core/tests/test_intake_factory.py
Claude 59eceb6b48 gitea-saas-core#4: adapt to billing core client id rename (#16) after rebase
main (#16) flattened get_client records and renamed create_client's
returned identifier to 'id'. Read create_client(...)['id'] in the
real-core composition test, and make _default_repo_name accept the flat
record (top-level name / id) as well as the historical nested shape.
46 tests pass on top of origin/main.
2026-08-25 11:15:40 -05:00

529 lines
20 KiB
Python

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