Resolve the shared backend port once for make dev and reuse it in the child make. Keep pure configuration tests independent of local credentials and URLs without altering integration database settings.
189 lines
6.7 KiB
Python
189 lines
6.7 KiB
Python
import asyncio
|
|
import os
|
|
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 pydantic import SecretStr
|
|
from sqlalchemy import delete, select
|
|
|
|
from ai_platform.bootstrap.app import create_app
|
|
from ai_platform.infrastructure.config.settings import AppWebhook, Settings
|
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
|
from ai_platform.infrastructure.persistence.models import AIProfile, AppCredential, ScoringRun
|
|
from ai_platform.platform.security import digest
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Keep pure configuration tests independent of operator credentials and local URLs."""
|
|
monkeypatch.setitem(Settings.model_config, "env_file", None)
|
|
for name in tuple(os.environ):
|
|
if name.startswith("AI_PLATFORM_"):
|
|
monkeypatch.delenv(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_run_payload():
|
|
return {
|
|
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
|
"account_id": "3",
|
|
"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",
|
|
aihub_base_url="http://localhost:8080",
|
|
)
|
|
factory = make_session_factory(settings.database_url)
|
|
suffix = uuid4().hex
|
|
app_id = f"aihub-test-{suffix}"
|
|
profile_version = f"aihub-test-{suffix}"
|
|
app_key = f"test-only-app-key-{suffix}"
|
|
settings.app_webhooks[app_id] = AppWebhook(
|
|
url="https://omni.example.invalid/api/v1/internal/lead-scoring/notifications",
|
|
signing_secret=SecretStr("test-only-signing-secret-at-least-32-characters"),
|
|
)
|
|
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(AppCredential(app_id=app_id, verifier=digest(app_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.app_id == app_id))
|
|
await db.execute(delete(AppCredential).where(AppCredential.app_id == app_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),
|
|
webhook_transport=httpx.MockTransport(lambda _request: httpx.Response(202)),
|
|
)
|
|
|
|
try:
|
|
yield {
|
|
"app_for": app_for,
|
|
"factory": factory,
|
|
"settings": settings,
|
|
"profile_version": profile_version,
|
|
"app_id": app_id,
|
|
"app_key": app_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
|