"""Offline tests for the FastAPI backend (`api.py`). Exercises every endpoint EXCEPT a live triage call: the billed `/api/triage` path is verified by injecting a synthetic `GatedDecision` into the in-memory cache, so this runs with no API key and no spend. The override-governance rule (a contradiction needs a written reason) is checked here because it is enforced server-side. python -m scripts.test_api """ from __future__ import annotations import os import sys import tempfile # Point the audit log at a throwaway file BEFORE importing anything that reads CONFIG. _TMP = tempfile.mkdtemp() os.environ["AUDIT_LOG_PATH"] = os.path.join(_TMP, "audit.jsonl") # Force the JSONL backend even if a DATABASE_URL is set in .env — these are offline tests and must # not touch a real Postgres. (load_dotenv() won't override an env var that's already present.) os.environ["DATABASE_URL"] = "" from fastapi.testclient import TestClient # noqa: E402 import api # noqa: E402 from src.schemas import GatedDecision, RuleViolation, TriageDecision # noqa: E402 client = TestClient(api.app) results: list[tuple[str, str, str]] = [] PASS, FAIL = "PASS", "FAIL" def check(name, fn): try: fn() results.append((PASS, name, "")) except Exception as e: results.append((FAIL, name, f"{type(e).__name__}: {e}")) def t_stats(): r = client.get("/api/stats") assert r.status_code == 200, r.status_code body = r.json() assert "policy_rules" in body and "precedent_cases" in body assert body["provider"] in body["providers"] assert isinstance(body["public_demo"], bool) # drives the UI's provider-toggle visibility def t_list_campaigns(): r = client.get("/api/campaigns") assert r.status_code == 200 body = r.json() assert len(body) == 18, f"expected 18 campaigns, got {len(body)}" assert all("id" in c and "decided" in c for c in body) def t_get_campaign_and_404(): r = client.get("/api/campaigns/camp-001") assert r.status_code == 200 and r.json()["story"], "camp-001 should load with a story" assert client.get("/api/campaigns/camp-999").status_code == 404 def t_policy_sections(): from src.policy import valid_rule_ids r = client.get("/api/policy") assert r.status_code == 200 sections = r.json() prefixes = [s["prefix"] for s in sections] assert prefixes == ["ELIG", "PROH", "COMP", "CONT", "DEC"], prefixes assert all(s["name"] and s["description"] for s in sections), "each section needs a name + blurb" n_rules = sum(len(s["rules"]) for s in sections) assert n_rules == len(valid_rule_ids()), f"policy drawer should expose every rule, got {n_rules}" def t_decision_requires_triage_first(): # No cached triage for this id => 409. r = client.post("/api/decisions", json={"campaign_id": "camp-018", "human_decision": "APPROVE", "moderator": "Mod"}) assert r.status_code == 409, f"expected 409 without triage, got {r.status_code}" def t_decision_requires_moderator(): _seed_cache("camp-005") # A decision with no moderator name is rejected server-side (a human must own it). r = client.post("/api/decisions", json={"campaign_id": "camp-005", "human_decision": "REJECT"}) assert r.status_code == 400, f"missing moderator should 400, got {r.status_code}" def _seed_cache(cid="camp-005"): td = TriageDecision( recommendation="REJECT", confidence="high", rule_violations=[RuleViolation(rule_id="PROH-3", severity="hard", evidence="12% guaranteed return")], risk_signals=[], rationale="riba investment", questions_for_submitter=[], manipulation_detected=False, ) api._triage_cache[cid] = {"provider": "anthropic", "gated": GatedDecision(decision=td, llm_recommendation="REJECT", overrides=[])} def t_triage_returns_enriched_rule_text(): _seed_cache("camp-005") # Cached + same provider + not force => returns cache without a live call; rule text enriched. r = client.post("/api/triage", json={"campaign_id": "camp-005", "provider": "anthropic"}) assert r.status_code == 200 rv = r.json()["decision"]["rule_violations"][0] assert rv["rule_id"] == "PROH-3" assert rv["rule_text"] and "interest" in rv["rule_text"].lower(), "PROH-3 rule text should be enriched in" def t_public_demo_caps_spend(): # With PUBLIC_DEMO on, the billed endpoint must neutralize the two spend faucets: force-rerun # (cache bypass) and a non-Anthropic provider. A force+ollama request should therefore be served # from the seeded cache — a live call would 502 here (no key), so a 200 proves both were ignored. _seed_cache("camp-005") # provider "anthropic" object.__setattr__(api.CONFIG, "public_demo", True) try: r = client.post("/api/triage", json={"campaign_id": "camp-005", "provider": "ollama", "force": True}) assert r.status_code == 200, f"lockdown should serve cache, got {r.status_code}: {r.text}" body = r.json() assert body["provider"] == "anthropic", "lockdown forces the Anthropic provider" assert body["decision"]["rule_violations"][0]["rule_id"] == "PROH-3", "served the cached decision" finally: object.__setattr__(api.CONFIG, "public_demo", False) def t_decision_agree_and_override_governance(): _seed_cache("camp-005") # Agreeing with the AI (REJECT) needs no reason, but still needs a moderator. r = client.post("/api/decisions", json={"campaign_id": "camp-005", "human_decision": "REJECT", "moderator": "Mod"}) assert r.status_code == 200 and r.json()["is_override"] is False assert r.json()["moderator"] == "Mod", "the moderator should be recorded" # Overriding (APPROVE) WITHOUT a reason is rejected server-side. r = client.post("/api/decisions", json={"campaign_id": "camp-005", "human_decision": "APPROVE", "moderator": "Mod"}) assert r.status_code == 400, f"unreasoned override should 400, got {r.status_code}" # Overriding WITH a reason is accepted and flagged. r = client.post("/api/decisions", json={"campaign_id": "camp-005", "human_decision": "APPROVE", "moderator": "Mod", "reason": "manual check cleared it"}) assert r.status_code == 200 and r.json()["is_override"] is True and r.json()["reason"] def t_decisions_log_roundtrip(): r = client.get("/api/decisions") assert r.status_code == 200 assert any(rec["campaign_id"] == "camp-005" for rec in r.json()), "logged decisions should load back" def main() -> int: for name, fn in [ ("GET /api/stats", t_stats), ("GET /api/campaigns (18)", t_list_campaigns), ("GET /api/campaigns/{id} + 404", t_get_campaign_and_404), ("GET /api/policy (5 sections, all rules)", t_policy_sections), ("POST /api/decisions 409 before triage", t_decision_requires_triage_first), ("POST /api/decisions requires moderator", t_decision_requires_moderator), ("POST /api/triage enriches rule text", t_triage_returns_enriched_rule_text), ("POST /api/triage PUBLIC_DEMO caps spend", t_public_demo_caps_spend), ("POST /api/decisions override governance", t_decision_agree_and_override_governance), ("GET /api/decisions round-trip", t_decisions_log_roundtrip), ]: check(name, fn) print("\n" + "=" * 60) for status, name, msg in results: print(f" {status} {name}" + (f" — {msg}" if msg else "")) n_fail = sum(1 for s, _, _ in results if s == FAIL) print("=" * 60) print(f" {len(results) - n_fail} passed, {n_fail} failed") return 1 if n_fail else 0 if __name__ == "__main__": sys.exit(main())