import json from pathlib import Path from types import SimpleNamespace from typing import cast from uuid import uuid4 import pytest from pydantic import ValidationError from ai_platform.bootstrap.app import create_app from ai_platform.infrastructure.config.settings import Settings from ai_platform.lead_scoring.application.repository import ScoringRunRecord from ai_platform.lead_scoring.application.run_use_cases import to_run_detail from ai_platform.presentation.http.routes import to_detail_json from ai_platform.presentation.http.run_responses import RunDetail, RunMetrics def test_committed_openapi_matches_provider_routes(): path = Path(__file__).resolve().parents[1] / "contracts/lead-scoring/v2/openapi.json" assert json.loads(path.read_text()) == create_app(Settings()).openapi() def test_run_detail_and_summary_responses_are_documented(): openapi = create_app(Settings()).openapi() paths = openapi["paths"] schemas = openapi["components"]["schemas"] assert openapi["components"]["securitySchemes"]["HTTPBearer"] == { "type": "http", "scheme": "bearer", } for path, method, statuses in ( ("/v1/lead-scoring/runs", "post", {"200", "202", "401", "403", "409", "413", "422", "503"}), ("/v1/lead-scoring/runs/{run_id}", "get", {"200", "401", "403", "404", "422", "503"}), ): operation = paths[path][method] assert operation["security"] == [{"HTTPBearer": []}] assert statuses <= set(operation["responses"]) assert operation["responses"]["422"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/ScoringError" } assert paths["/v1/lead-scoring/runs"]["post"]["responses"]["202"]["content"][ "application/json" ]["schema"] == {"$ref": "#/components/schemas/RunSummary"} assert paths["/v1/lead-scoring/runs"]["post"]["responses"]["200"]["content"][ "application/json" ]["schema"] == {"$ref": "#/components/schemas/RunSummary"} assert paths["/v1/lead-scoring/runs/{run_id}"]["get"]["responses"]["200"]["content"][ "application/json" ]["schema"] == {"$ref": "#/components/schemas/RunDetail"} assert paths["/v1/admin/runs"]["get"]["responses"]["200"]["content"]["application/json"][ "schema" ] == {"$ref": "#/components/schemas/RunList"} assert paths["/v1/admin/runs/{run_id}"]["get"]["responses"]["200"]["content"][ "application/json" ]["schema"] == {"$ref": "#/components/schemas/RunDetail"} assert "snapshot" not in schemas["RunSummary"]["properties"] assert "metrics" not in schemas["RunSummary"]["properties"] assert "snapshot" in schemas["RunDetail"]["properties"] assert "metrics" in schemas["RunDetail"]["properties"] assert "ai_evidence" in schemas["RunDetail"]["properties"] assert "profile_field_count" in schemas["RunMetrics"]["properties"] assert "conversation_turn_count" in schemas["RunMetrics"]["properties"] assert "contact_started_conversation_count" in schemas["RunMetrics"]["properties"] assert "contact_message_recency_hours" in schemas["RunMetrics"]["properties"] assert "contact_message_recency_hours" in schemas["RunMetrics"]["required"] assert schemas["RunMetrics"]["properties"]["customer_initiated_return"]["type"] == "boolean" assert ( "original snapshot position" in schemas["RunMetrics"]["properties"]["customer_initiated_return"]["description"] ) assert schemas["RunDetail"]["properties"]["ai_evidence"]["anyOf"][0]["maxItems"] == 20 assert schemas["RunList"]["properties"]["runs"]["items"] == { "$ref": "#/components/schemas/RunSummary" } assert paths["/v1/admin/apps"]["get"]["responses"]["200"]["content"]["application/json"][ "schema" ] == {"$ref": "#/components/schemas/AppList"} assert "verifier" not in schemas["AppSummary"]["properties"] assert "app_api_key" not in schemas["AppSummary"]["properties"] assert not any("tenant-key" in path for path in paths) def test_run_detail_serializes_new_metrics_and_requires_boolean_values(): snapshot = { "captured_at": "2025-09-25T10:00:00Z", "lead_profile": {"ems_lead_id": "lead-1"}, "conversations": [ {"conversation_id": "empty", "messages": []}, { "conversation_id": "first", "messages": [ { "message_id": "m1", "sender_type": "agent", "content": "Hello", "sent_at": "2025-09-25T08:00:00Z", } ], }, { "conversation_id": "second", "messages": [ { "message_id": "m2", "sender_type": "contact", "content": "Thanks", "sent_at": "2025-09-25T09:00:00Z", } ], }, ], } row = SimpleNamespace( id=str(uuid4()), request_id=str(uuid4()), app_id="omni-local", account_id="3", status="completed", profile_version="profile-v1", rule_score=80, ai_score=None, final_score=80, degraded=False, result_mode="rules_only", error_code=None, breakdown=[], created_at=None, completed_at=None, request={"snapshot": snapshot}, ai_confidence=None, ai_reasoning=None, ai_evidence=None, ) detail = RunDetail.model_validate(to_detail_json(to_run_detail(cast(ScoringRunRecord, row)))) assert detail.metrics.conversation_count == 3 assert detail.metrics.contact_message_recency_days == 0.04 assert detail.metrics.contact_message_recency_hours == 1.0 assert detail.metrics.contact_started_conversation_count == 1 assert detail.metrics.customer_initiated_return is True with pytest.raises(ValidationError): RunMetrics.model_validate({**detail.metrics.model_dump(), "customer_initiated_return": 1}) def test_required_routes_are_registered(): app = create_app(Settings()) paths = set(app.openapi()["paths"]) assert { "/v1/admin/login", "/v1/admin/logout", "/v1/admin/me", "/v1/admin/ai-profiles", "/v1/admin/runs", "/v1/admin/runs/{run_id}", "/v1/admin/apps", "/v1/admin/apps/{app_id}", "/v1/lead-scoring/runs", "/v1/lead-scoring/runs/{run_id}", } <= paths