from __future__ import annotations import ast from pathlib import Path from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api import ai_routing from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id from app.models.ai_call_log import AICallLog from app.models.base import Base from app.models.integration import Integration from app.services.ai_routing_governance_service import AIRoutingGovernanceService def _use_sqlite_json_types(*tables) -> None: for table in tables: for column in table.columns: if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") tables = [Integration.__table__, AICallLog.__table__] _use_sqlite_json_types(*tables) async with engine.begin() as conn: await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables)) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _engine_bundle() -> dict: return { "active_engine_id": "low-1", "settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"}, "engines": [ { "id": "low-1", "name": "Qwen 低阶引擎", "provider": "glm", "model": "qwen-plus", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "db-low-key", }, { "id": "high-1", "name": "OpenAI 高阶引擎", "provider": "openai", "model": "GLM-5", "base_url": "https://api.edgefn.net/v1", "api_key": "db-high-key", }, ], } @pytest.mark.asyncio async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypatch) -> None: monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder") db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) await db_session.commit() data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_overview() assert data["config_version"] assert data["slots"][0]["label"] == "快速槽" assert data["slots"][0]["api_key_masked"] == "db-l...-key" assert any(item["code"] == "env_key_override" for item in data["diagnostics"]) assert "api_env" not in str(data) assert "local-placeholder" not in str(data) assert "db-low-key" not in str(data) @pytest.mark.asyncio async def test_admin_console_reports_protected_downgrade_and_structured_failures(db_session) -> None: db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True)) db_session.add( Integration( tenant_id=1, type="model_routing", config={"mappings": {"adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}}}, is_active=True, ) ) for idx in range(3): db_session.add( AICallLog( tenant_id=1, function_name="pmflow_launch_draft_v1", prompt=f"prompt {idx}", response="", tokens_used=1, cost=0, status="structured_empty", planned_tier="low", executed_tier="low", routing_decision="rule_match", route_source="default", route_rule_name="engine_low_fallback", routing_config_version="v3", routing_config_updated_at="2026-03-26T21:30:00+08:00", ) ) await db_session.commit() data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console() codes = {item["code"] for item in data["diagnostics"]} assert "protected_function_downgraded" in codes assert "structured_failure_rate" in codes assert data["recent_failures"][0]["status_label"] == "模型返回为空" assert data["scene_routes"][0]["label"] == "项目启动草案" @pytest.mark.asyncio async def test_team_policy_persists_integration_without_runtime_route_changes(db_session) -> None: service = AIRoutingGovernanceService(db_session, tenant_id=1) payload = { "strategy": "quality_first", "scenes": [{"key": "project_launch", "policy": "深度起草并进入确认", "mode": "深度优先"}], "automation_boundary": "所有写操作进入待确认。", } saved = await service.update_team_policy(payload=payload, updated_by=7) await db_session.commit() loaded = await service.get_team_policy() assert saved["strategy_label"] == "质量优先" assert loaded["scenes"][0]["policy"] == "深度起草并进入确认" assert loaded["automation_boundary"] == "所有写操作进入待确认。" def _build_app(*, role: str = "REVIEWER") -> FastAPI: app = FastAPI() app.include_router(ai_routing.router) class FakeDb: async def commit(self) -> None: return None async def _fake_get_db(): yield FakeDb() app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_tenant_id] = lambda: 9 app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( id=101, tenant_id=9, username="tester", role=role, ) return app def test_api_router_exposes_ai_routing_routes() -> None: router_source = (Path(__file__).parents[1] / "app" / "api" / "router.py").read_text() router_tree = ast.parse(router_source) route_assignments = {} for node in router_tree.body: if not isinstance(node, ast.Assign): continue for target in node.targets: if isinstance(target, ast.Name) and target.id in {"CORE_ROUTE_MODULES", "OPTIONAL_ROUTE_GROUPS"}: route_assignments[target.id] = ast.literal_eval(node.value) assert "ai_routing" in route_assignments["CORE_ROUTE_MODULES"] assert "model_routing" in route_assignments["CORE_ROUTE_MODULES"] assert "ai_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] assert "model_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"] route_paths = {str(getattr(route, "path", "")).strip() for route in ai_routing.router.routes} assert "/ai-routing/overview" in route_paths assert "/ai-routing/team-policy" in route_paths assert "/ai-routing/admin-console" in route_paths @pytest.mark.asyncio async def test_ai_routing_overview_requires_governance_role() -> None: app = _build_app(role="BUILDER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/ai-routing/overview") assert response.status_code == 403 assert response.json()["code"] == 403 @pytest.mark.asyncio async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None: class FakeService: def __init__(self, db, *, tenant_id: int): assert tenant_id == 9 _ = db async def get_overview(self): return {"slots": [], "diagnostics": []} async def get_team_policy(self): return {"strategy": "balanced", "scenes": []} async def update_team_policy(self, *, payload, updated_by: int): assert updated_by == 101 return {**payload, "updated_by": updated_by} async def get_admin_console(self): return {"providers": [], "scene_routes": []} async def run_diagnostics(self): return {"items": [], "summary": {"ok": 1}} async def fake_log_operation(*args, **kwargs): _ = args, kwargs monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService) monkeypatch.setattr(ai_routing, "log_operation", fake_log_operation) app = _build_app(role="OWNER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: overview = await client.get("/ai-routing/overview") policy = await client.get("/ai-routing/team-policy") saved = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []}) console = await client.get("/ai-routing/admin-console") diagnostics = await client.post("/ai-routing/diagnostics/run") assert overview.json()["code"] == 0 assert policy.json()["data"]["strategy"] == "balanced" assert saved.json()["data"]["strategy"] == "quality_first" assert console.json()["data"]["providers"] == [] assert diagnostics.json()["data"]["summary"]["ok"] == 1 @pytest.mark.asyncio async def test_reviewer_cannot_save_team_policy(monkeypatch) -> None: class FakeService: pass monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService) app = _build_app(role="REVIEWER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []}) assert response.status_code == 403 assert response.json()["message"] == "仅 OWNER 可保存团队 AI 策略"