Files

113 lines
7.9 KiB
Python

import json
import os
import sqlite3
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.ai_assistance import build_local_suggestions, generate
from app.main import create_server
from app.main import hash_password
class Phase13ApiTests(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.old = {key: os.environ.get(key) for key in ("AI_PROVIDER", "BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")}
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "ai-owner@example.test"
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "ai-password"
os.environ["AI_PROVIDER"] = "local"
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/ai.db")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3); self.cookie = None
self.request("POST", "/api/v1/auth/login", {"email": "ai-owner@example.test", "password": "ai-password"})
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2); self.tmp.cleanup()
for key, value in self.old.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
def request(self, method, path, payload=None):
body = json.dumps(payload).encode() if payload is not None else None; headers = {"Content-Type": "application/json"}
if self.cookie: headers["Cookie"] = self.cookie
self.conn.request(method, path, body, headers); response = self.conn.getresponse(); cookie = response.getheader("Set-Cookie")
if cookie: self.cookie = cookie.split(";", 1)[0]
return response.status, json.loads(response.read() or b"{}")
def test_local_fallback_is_deterministic_and_evidence_bounded(self):
args = ({"id": 4, "name": "Acme", "score": 10}, [], [], [{"id": 2, "kind": "source", "url": "https://source.test", "claim": "Makes widgets"}])
first = build_local_suggestions(*args); second = build_local_suggestions(*args)
self.assertEqual(first, second)
self.assertIn("[evidence:2]", first["suggestions"][0]["text"])
self.assertNotIn("customers", json.dumps(first).lower())
def test_no_provider_returns_not_configured_without_output(self):
os.environ.pop("AI_PROVIDER", None)
status, provider, version, metadata = generate({"name": "Acme"}, [], [], [])
self.assertEqual(status, "not_configured"); self.assertEqual(provider, ""); self.assertEqual(version, "")
self.assertNotIn("output", metadata)
def test_structured_enrichment_contract_has_classification_priority_confidence_uncertainty_and_provenance(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Structured Co", "website": "https://structured.test"}); self.assertEqual(status, 201)
bid = business["id"]
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Provides solar installation"})
status, run = self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {}); self.assertEqual(status, 201)
output = run["output"]
for key in ("classification", "summary", "priority_recommendation", "confidence", "uncertainty", "conflicts", "citations", "policy"):
self.assertIn(key, output)
self.assertTrue(output["citations"][0]["provenance"])
self.assertTrue(output["citations"][0]["hash"])
self.assertEqual(output["policy"]["claim_policy"], "stored_evidence_only")
def test_approval_rejects_stale_source_hashes(self):
_, business = self.request("POST", "/api/v1/businesses", {"name": "Stale Co"})
bid = business["id"]
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Original claim"})
_, run = self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {})
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Changed claim"})
status, result = self.request("POST", f"/api/v1/ai-runs/{run['id']}/approve", {})
self.assertEqual(status, 409); self.assertEqual(result["error"], "ai_run_stale")
def test_remote_provider_is_not_configured_and_status_never_makes_network_call(self):
os.environ["AI_PROVIDER"] = "openai"
status, provider, version, metadata = generate({"name": "Remote"}, [], [], [])
self.assertEqual(status, "not_configured"); self.assertEqual(provider, "openai"); self.assertNotIn("output", metadata)
status, result = self.request("GET", "/api/v1/ai/provider-config")
self.assertEqual(status, 200); self.assertEqual(result["status"], "not_configured"); self.assertFalse(result["network_enabled"])
status, result = self.request("POST", "/api/v1/ai/provider-config", {"provider": "openai", "enabled": True, "reviewed": True})
self.assertEqual(status, 409); self.assertFalse(result["network_enabled"])
def test_endpoint_persists_citations_and_approval_without_crm_write(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Evidence Co", "website": "https://evidence.test"}); self.assertEqual(status, 201)
bid = business["id"]
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Serves Cape Town"})
status, run = self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {}); self.assertEqual(status, 201)
self.assertTrue(all(s["citations"] for s in run["suggestions"]))
self.assertEqual(self.request("POST", f"/api/v1/ai-runs/{run['id']}/approve", {})[1]["approval_state"], "approved")
self.assertEqual(self.request("POST", f"/api/v1/ai-runs/{run['id']}/reject", {})[0], 409)
db = sqlite3.connect(self.tmp.name + "/ai.db")
self.assertEqual(db.execute("SELECT COUNT(*) FROM pipeline_entries").fetchone()[0], 0); self.assertEqual(db.execute("SELECT COUNT(*) FROM interactions").fetchone()[0], 0); db.close()
def test_limits_and_suppressed_business_are_safe(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Safe Co"}); self.assertEqual(status, 201)
bid = business["id"]
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {"max_items": 101})[0], 400)
self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "safe.test"})
# Directly mark the business with the suppressed domain to exercise the AI guard.
db = sqlite3.connect(self.tmp.name + "/ai.db"); db.execute("UPDATE businesses SET website_domain='safe.test' WHERE id=?", (bid,)); db.commit(); db.close()
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {})[0], 409)
def test_tenant_isolation_applies_to_ai_runs_and_business_suggestions(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Tenant A"}); self.assertEqual(status, 201)
ph, salt = hash_password("other-password")
db = sqlite3.connect(self.tmp.name + "/ai.db")
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other-ai@example.test", ph, salt, "owner")); db.commit(); db.close()
self.cookie = None; self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other-ai@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/ai/suggest", {})[0], 404)
self.assertEqual(self.request("GET", "/api/v1/ai-runs")[1]["items"], [])
if __name__ == "__main__": unittest.main()