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()