Add app credentials, account-scoped idempotency and per-app terminal notifications. Consolidate the unreleased migration baseline, preserve rule descriptions as AI context, and regenerate provider contracts with regression coverage.
163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
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
|