83 lines
5.4 KiB
Python
83 lines
5.4 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_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()
|