File size: 7,624 Bytes
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e98f03
 
 
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d641c27
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
063bf3a
 
 
 
 
 
 
 
 
 
 
 
 
250666a
 
8e98f03
250666a
 
 
8e98f03
 
 
 
 
 
 
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
09213ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250666a
 
8e98f03
 
250666a
8e98f03
250666a
 
8e98f03
250666a
 
 
8e98f03
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
063bf3a
250666a
8e98f03
250666a
09213ce
250666a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""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())