dotb_ai-platform/tests/test_api_surface.py
hungtran24 2268b6a77a feat: replace tenant auth with app-scoped scoring access
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.
2026-10-02 10:56:58 +07:00

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