169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
import asyncio
|
|
import time
|
|
from collections.abc import Callable, Iterator
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import delete, select
|
|
|
|
from ai_platform.bootstrap.app import create_app
|
|
from ai_platform.infrastructure.config.settings import Settings
|
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
|
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
|
from ai_platform.infrastructure.security import digest
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_run_payload():
|
|
return {
|
|
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
|
"subject_ref": "lead-1",
|
|
"snapshot": {
|
|
"version": "1.0.0",
|
|
"scope": "linked_contact_history",
|
|
"captured_at": "2026-01-01T12:00:00Z",
|
|
"lead_profile": {
|
|
"ems_lead_id": "lead-1",
|
|
"name": "A. Example",
|
|
"custom_fields": {"region": "north"},
|
|
},
|
|
"conversations": [
|
|
{
|
|
"conversation_id": "conversation-2",
|
|
"channel": "webchat",
|
|
"assigned_agent_id": "agent-1",
|
|
"messages": [
|
|
{
|
|
"message_id": "message-2",
|
|
"sender_type": "agent",
|
|
"content": "Thanks for contacting us.",
|
|
"sent_at": "2025-12-31T11:00:00+00:00",
|
|
},
|
|
{
|
|
"message_id": "message-1",
|
|
"sender_type": "contact",
|
|
"content": "I am interested.",
|
|
"sent_at": "2025-12-31T10:00:00+00:00",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
"identity_context": {
|
|
"match_quality": "phone_only",
|
|
"contact_id": "contact-1",
|
|
"details": {"source": "phone"},
|
|
},
|
|
"trigger_metadata": {
|
|
"trigger_type": "manual_omni",
|
|
"trigger_conversation_id": "conversation-2",
|
|
"actor_id": "user-1",
|
|
},
|
|
},
|
|
"scoring_config": {
|
|
"version": "omni-v1",
|
|
"rules": [
|
|
{
|
|
"id": "region",
|
|
"field": "custom_fields.region",
|
|
"operator": "eq",
|
|
"value": "north",
|
|
"points": 25,
|
|
}
|
|
],
|
|
"rule_weight": 1,
|
|
"ai_weight": 0,
|
|
},
|
|
"ai_profile": "light",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def aihub_test_environment() -> Iterator[dict[str, Any]]:
|
|
loaded = Settings.load()
|
|
database_name = urlsplit(loaded.database_url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
if database_name != "ai_platform_test":
|
|
raise RuntimeError("AIHub scoring integration tests require /ai_platform_test")
|
|
settings = Settings(
|
|
database_url=loaded.database_url,
|
|
environment="development",
|
|
tenant_provisioning_secret=loaded.tenant_provisioning_secret,
|
|
aihub_base_url="http://localhost:8080",
|
|
)
|
|
factory = make_session_factory(settings.database_url)
|
|
suffix = uuid4().hex
|
|
tenant_id = f"aihub-test-{suffix}"
|
|
profile_version = f"aihub-test-{suffix}"
|
|
tenant_key = "test-only-tenant-key"
|
|
vkey = "test-only-vkey-do-not-leak"
|
|
original_active: set[str] = set()
|
|
|
|
async def setup() -> None:
|
|
nonlocal original_active
|
|
async with factory() as db:
|
|
profiles = (await db.scalars(select(AIProfile))).all()
|
|
original_active = {profile.version for profile in profiles if profile.active}
|
|
for profile in profiles:
|
|
profile.active = False
|
|
db.add(
|
|
AIProfile(
|
|
version=profile_version,
|
|
model="openai/gpt-4o-mini",
|
|
system_prompt="Dùng tiếng Việt. Chỉ phân tích dữ liệu được cung cấp.",
|
|
timeout_seconds=4,
|
|
limits={},
|
|
active=True,
|
|
)
|
|
)
|
|
db.add(TenantKey(tenant_id=tenant_id, verifier=digest(tenant_key)))
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
|
|
async def cleanup() -> None:
|
|
async with factory() as db:
|
|
await db.execute(delete(ScoringRun).where(ScoringRun.tenant_id == tenant_id))
|
|
await db.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_id))
|
|
await db.execute(delete(AIProfile).where(AIProfile.version == profile_version))
|
|
profiles = (await db.scalars(select(AIProfile))).all()
|
|
for profile in profiles:
|
|
profile.active = profile.version in original_active
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
|
|
asyncio.run(setup())
|
|
|
|
def app_for(handler: Callable[[httpx.Request], httpx.Response]) -> FastAPI:
|
|
return create_app(settings, aihub_transport=httpx.MockTransport(handler))
|
|
|
|
try:
|
|
yield {
|
|
"app_for": app_for,
|
|
"factory": factory,
|
|
"profile_version": profile_version,
|
|
"tenant_id": tenant_id,
|
|
"tenant_key": tenant_key,
|
|
"vkey": vkey,
|
|
}
|
|
finally:
|
|
asyncio.run(cleanup())
|
|
|
|
|
|
def _wait_for_terminal(client: TestClient, run_id: str, headers: dict[str, str]) -> dict:
|
|
for _ in range(100):
|
|
response = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=headers)
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
if result["status"] in {"completed", "failed"}:
|
|
return result
|
|
time.sleep(0.02)
|
|
pytest.fail("Scoring run did not reach a terminal state")
|
|
|
|
|
|
@pytest.fixture
|
|
def wait_for_terminal():
|
|
return _wait_for_terminal
|