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, )