171 lines
9.5 KiB
Python
171 lines
9.5 KiB
Python
import json
|
|||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import threading
|
||
|
|
import unittest
|
||
|
|
from http.client import HTTPConnection
|
||
|
|
from tempfile import TemporaryDirectory
|
||
|
|
from unittest.mock import Mock
|
||
|
|
|
||
|
|
from app.ai_opportunity import normalize_assessment
|
||
|
|
from app.ai_research import configure_db as configure_ai_research_db
|
||
|
|
from app.main import create_server, hash_password
|
||
|
|
|
||
|
|
|
||
|
|
class OpportunityNormalizationTests(unittest.TestCase):
|
||
|
|
def test_normalizes_scores_enums_and_known_evidence_only(self):
|
||
|
|
provider = Mock(return_value={
|
||
|
|
"opportunity_score": 81,
|
||
|
|
"confidence_score": 0.82,
|
||
|
|
"recommendation": "contact",
|
||
|
|
"priority": "high",
|
||
|
|
"reasons": ["Two independent public listings corroborate the business."],
|
||
|
|
"missing_evidence": [],
|
||
|
|
"website_assessment": {"status": "healthy", "broken": False, "outdated": False, "mobile_issue": False, "https_issue": False, "performance_issue": False},
|
||
|
|
"domain_assessment": {"status": "registered"},
|
||
|
|
"contactability": {"public_business_contact_found": True, "contact_type": "general_business"},
|
||
|
|
"recommended_services": ["seo"],
|
||
|
|
"evidence_references": [2, 1, 2],
|
||
|
|
"human_review_required": False,
|
||
|
|
})
|
||
|
|
result = normalize_assessment(provider(), {1, 2})
|
||
|
|
self.assertEqual(result["opportunity_score"], 81)
|
||
|
|
self.assertEqual(result["confidence_score"], 82)
|
||
|
|
self.assertEqual(result["evidence_references"], [1, 2])
|
||
|
|
self.assertFalse(result["human_review_required"])
|
||
|
|
self.assertEqual(result["recommendation"], "contact")
|
||
|
|
self.assertEqual(result["reasons"], ["Two independent public listings corroborate the business."])
|
||
|
|
self.assertEqual(result["missing_evidence"], [])
|
||
|
|
self.assertEqual(result["website_assessment"]["status"], "healthy")
|
||
|
|
self.assertEqual(result["domain_assessment"]["status"], "registered")
|
||
|
|
self.assertEqual(result["contactability"]["contact_type"], "general_business")
|
||
|
|
self.assertEqual(result["recommended_services"], ["seo"])
|
||
|
|
|
||
|
|
def test_contact_recommendation_is_an_internal_no_send_recommendation(self):
|
||
|
|
payload = {"recommendation": "contact", "evidence_references": [1, 2], "confidence_score": 90}
|
||
|
|
self.assertEqual(normalize_assessment(payload, {1, 2})["recommendation"], "contact")
|
||
|
|
|
||
|
|
def test_unknown_values_default_and_weak_evidence_requires_human_review(self):
|
||
|
|
result = normalize_assessment({
|
||
|
|
"opportunity_score": "not-a-number",
|
||
|
|
"confidence_score": 20,
|
||
|
|
"recommendation": "email_them_now",
|
||
|
|
"priority": "urgent",
|
||
|
|
"evidence_references": [],
|
||
|
|
"human_review_required": False,
|
||
|
|
}, set())
|
||
|
|
self.assertEqual(result["opportunity_score"], 0)
|
||
|
|
self.assertEqual(result["confidence_score"], 20)
|
||
|
|
self.assertEqual(result["recommendation"], "insufficient_evidence")
|
||
|
|
self.assertEqual(result["priority"], "low")
|
||
|
|
self.assertTrue(result["human_review_required"])
|
||
|
|
|
||
|
|
def test_unknown_evidence_reference_is_rejected(self):
|
||
|
|
with self.assertRaisesRegex(ValueError, "unknown_evidence_reference"):
|
||
|
|
normalize_assessment({"evidence_references": [99]}, {1})
|
||
|
|
|
||
|
|
def test_suppression_overrides_provider_recommendation(self):
|
||
|
|
result = normalize_assessment({
|
||
|
|
"opportunity_score": 99,
|
||
|
|
"confidence_score": 99,
|
||
|
|
"recommendation": "contact",
|
||
|
|
"priority": "high",
|
||
|
|
"evidence_references": [1, 2],
|
||
|
|
"human_review_required": False,
|
||
|
|
}, {1, 2}, suppressed=True)
|
||
|
|
self.assertEqual(result["recommendation"], "do_not_contact")
|
||
|
|
self.assertTrue(result["human_review_required"])
|
||
|
|
|
||
|
|
|
||
|
|
class OpportunityAssessmentApiTests(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.update({"AI_PROVIDER": "local", "BOOTSTRAP_ADMIN_EMAIL": "opportunity-owner@example.test", "BOOTSTRAP_ADMIN_PASSWORD": "password"})
|
||
|
|
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/opportunity.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": "opportunity-owner@example.test", "password": "password"})
|
||
|
|
|
||
|
|
def tearDown(self):
|
||
|
|
self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2)
|
||
|
|
configure_ai_research_db("")
|
||
|
|
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):
|
||
|
|
headers = {"Content-Type": "application/json"}
|
||
|
|
if self.cookie: headers["Cookie"] = self.cookie
|
||
|
|
self.conn.request(method, path, json.dumps(payload).encode() if payload is not None else None, 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 selected_business_at_threshold(self):
|
||
|
|
status, business = self.request("POST", "/api/v1/businesses", {"name": "Selected Co"})
|
||
|
|
self.assertEqual(status, 201)
|
||
|
|
db = sqlite3.connect(self.tmp.name + "/opportunity.db")
|
||
|
|
db.execute("UPDATE businesses SET score=70 WHERE id=?", (business["id"],)); db.commit(); db.close()
|
||
|
|
return business["id"]
|
||
|
|
|
||
|
|
def test_manual_selected_business_after_threshold_returns_grounded_review_only_assessment(self):
|
||
|
|
bid = self.selected_business_at_threshold()
|
||
|
|
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Needs a modern website"})
|
||
|
|
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
|
||
|
|
self.assertEqual(status, 201)
|
||
|
|
self.assertEqual(result["business_id"], bid)
|
||
|
|
self.assertEqual(result["assessment"]["evidence_references"], [1])
|
||
|
|
self.assertIn("opportunity_score", result["assessment"])
|
||
|
|
self.assertIn("confidence_score", result["assessment"])
|
||
|
|
self.assertTrue(result["assessment"]["human_review_required"])
|
||
|
|
self.assertFalse(result["network_send"])
|
||
|
|
self.assertFalse(result["automatic_outreach"])
|
||
|
|
status, runs = self.request("GET", "/api/v1/ai-runs")
|
||
|
|
self.assertEqual(status, 200)
|
||
|
|
self.assertEqual(runs["items"][0]["output"]["assessment"], result["assessment"])
|
||
|
|
self.assertEqual(len(runs["items"][0]["input_evidence_hashes"]), 1)
|
||
|
|
|
||
|
|
def test_manual_assessment_requires_deterministic_threshold(self):
|
||
|
|
status, business = self.request("POST", "/api/v1/businesses", {"name": "Below threshold"})
|
||
|
|
self.assertEqual(status, 201)
|
||
|
|
status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/ai/opportunity-assessment", {})
|
||
|
|
self.assertEqual(status, 409)
|
||
|
|
self.assertEqual(result["error"], "deterministic_threshold_not_met")
|
||
|
|
|
||
|
|
def test_unconfigured_provider_fails_closed_without_assessment_output(self):
|
||
|
|
bid = self.selected_business_at_threshold()
|
||
|
|
with unittest.mock.patch("app.main.provider_status", return_value={"status": "not_configured", "provider": ""}):
|
||
|
|
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
|
||
|
|
self.assertEqual(status, 409)
|
||
|
|
self.assertEqual(result["error"], "ai_provider_not_configured")
|
||
|
|
self.assertFalse(result["network_send"])
|
||
|
|
self.assertNotIn("assessment", result)
|
||
|
|
|
||
|
|
def test_suppressed_selected_business_assessment_is_do_not_contact(self):
|
||
|
|
bid = self.selected_business_at_threshold()
|
||
|
|
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Evidence"})
|
||
|
|
self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "selected.test"})
|
||
|
|
db = sqlite3.connect(self.tmp.name + "/opportunity.db")
|
||
|
|
db.execute("UPDATE businesses SET website_domain='selected.test' WHERE id=?", (bid,)); db.commit(); db.close()
|
||
|
|
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
|
||
|
|
self.assertEqual(status, 201)
|
||
|
|
self.assertEqual(result["assessment"]["recommendation"], "do_not_contact")
|
||
|
|
self.assertTrue(result["assessment"]["human_review_required"])
|
||
|
|
|
||
|
|
def test_manual_assessment_is_tenant_scoped(self):
|
||
|
|
bid = self.selected_business_at_threshold()
|
||
|
|
password_hash, password_salt = hash_password("other-password")
|
||
|
|
db = sqlite3.connect(self.tmp.name + "/opportunity.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-opportunity@example.test", password_hash, password_salt, "owner"))
|
||
|
|
db.commit(); db.close()
|
||
|
|
self.cookie = None
|
||
|
|
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other-opportunity@example.test", "password": "other-password"})[0], 200)
|
||
|
|
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})[0], 404)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|