dotb_ai-platform/tests/conftest.py
hungtran24 6577bb74be refactor: simplify scoring service, AI profile revisions and test suite
Phase 2 and 3 of the platform rework.

- Neutral request/response contract: external_id, metadata, X-AI-Key,
  run_id in the response; client-specific fields removed.
- AI profile is a revision table: saving creates an inactive revision,
  activation is separate, try-run does not store a run. One
  chat-completions gateway serves AIHub and OpenAI-compatible profiles.
- Add between and in rule operators.
- Contracts moved to contracts/lead-scoring/ with a score-response fixture.
- Admin Web updated for revisions, runs and the neutral fixture.
- Consolidate tests; remove rewrite_architect.md and the stale handoff doc;
  rewrite spec, CONTEXT and ARCHITECTURE for the current design.

Not verified: live AIHub/OpenAI behavior, Omni/EMS integration.
2026-10-09 08:23:21 +07:00

117 lines
4.2 KiB
Python

import asyncio
import json
import os
from collections.abc import Callable, Iterator
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
import pytest
from fastapi import FastAPI
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, AppCredential, ScoringRun
from ai_platform.platform.security import digest
FIXTURE = Path(__file__).parents[1] / "contracts/lead-scoring/fixtures/run-request.json"
@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 canonical_request() -> dict[str, Any]:
"""The shared contract fixture: rules only, rule score 50, 12 messages."""
return deepcopy(json.loads(FIXTURE.read_text()))
@pytest.fixture
def score_env() -> Iterator[dict[str, Any]]:
"""An app key plus an active AIHub profile in ai_platform_test; AI calls use a mock transport."""
loaded = Settings.load()
database_name = urlsplit(loaded.database_url).path.rstrip("/").rsplit("/", 1)[-1]
if database_name != "ai_platform_test":
raise RuntimeError("Scoring integration tests require /ai_platform_test")
settings = Settings(database_url=loaded.database_url, environment="development")
factory = make_session_factory(settings.database_url)
app_id = f"test-{uuid4().hex}"
app_key = f"test-only-app-key-{app_id}"
state: dict[str, Any] = {"revisions": [], "previously_active": []}
async def run(action: Callable[..., Any]) -> Any:
async with factory() as db:
result = await action(db)
await db.commit()
await factory.kw["bind"].dispose()
return result
async def setup(db) -> None:
profiles = (await db.scalars(select(AIProfile))).all()
state["previously_active"] = [p.revision for p in profiles if p.active]
for profile in profiles:
profile.active = False
db.add(AppCredential(app_id=app_id, verifier=digest(app_key)))
row = _profile_row(active=True)
db.add(row)
await db.flush()
state["revisions"].append(row.revision)
async def cleanup(db) -> None:
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.revision.in_(state["revisions"])))
for profile in (await db.scalars(select(AIProfile))).all():
profile.active = profile.revision in state["previously_active"]
def app_for(handler: Callable[[httpx.Request], httpx.Response]) -> FastAPI:
return create_app(settings, ai_transport=httpx.MockTransport(handler))
def read(query):
return asyncio.run(run(lambda db: query(db)))
asyncio.run(run(setup))
try:
yield {
"app_for": app_for,
"app_id": app_id,
"app_key": app_key,
"ai_key": "test-only-ai-key-do-not-leak",
"headers": lambda key=None: {
"Authorization": f"Bearer {app_key}",
"Idempotency-Key": key or str(uuid4()),
},
"read": read,
}
finally:
asyncio.run(run(cleanup))
def _profile_row(*, active: bool) -> AIProfile:
return AIProfile(
provider="aihub",
base_url="http://localhost:8080/v1",
model="test-model",
timeout_seconds=4,
reasoning_effort=None,
instructions="Chấm điểm lead giáo dục.",
include_reference_date=True,
timezone="Asia/Ho_Chi_Minh",
suggested_action_enabled=True,
created_by="test@example.invalid",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
active=active,
)