2026-09-03 12:16:12 +02:00
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 )
2026-09-03 19:55:48 +02:00
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" ])
2026-09-03 12:16:12 +02:00
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 ()