dotb_ai-platform/tests/conftest.py
hungtran24 d5e096b952 chore: simplify dev port lookup and isolate settings tests
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.
2026-10-02 11:58:19 +07:00

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