dotb_ai-platform/tests/test_api_surface.py
hungtran24 b1f6643796 refactor: score synchronously and drop webhook/background execution
POST /v1/lead-scoring/score now evaluates in the request, applies an overall
AI deadline, and stores the completed run only after scoring. Idempotency is
kept by (app_id, account_id, request_id) with a body fingerprint.

Removes the webhook outbox, scheduler/recovery loop, app lead_scoring
permission, credential audit events, seed_demo and generate_secret scripts,
and the tests that covered them. Trims tests to behavior of the new flow.
2026-10-09 02:44:24 +07:00

153 lines
6.0 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",
}
operation = paths["/v1/lead-scoring/score"]["post"]
assert operation["security"] == [{"HTTPBearer": []}]
assert {"200", "401", "413", "422", "503"} <= set(operation["responses"])
assert {"202", "403", "409"}.isdisjoint(operation["responses"])
assert operation["responses"]["200"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/RunDetail"
}
assert operation["responses"]["422"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/ScoringError"
}
assert "/v1/lead-scoring/runs/{run_id}" not in paths
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/score",
} <= paths