from types import SimpleNamespace import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.api.router import api_router from app.api.ai_pmo_api import routes_core from app.api.rag_api import routes_query from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id def _build_test_app() -> FastAPI: app = FastAPI() app.include_router(routes_query.router) app.include_router(routes_core.router) async def _fake_get_db(): yield object() 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, name="测试用户", username="tester", ) return app @pytest.mark.asyncio async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "intent": "query.read_only", "direct_reply_required": True, "direct_reply_text": "当前主链状态正常。", "rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"}, } monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) app = _build_test_app() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/rag/ask", json={ "question": "当前主链状态?", "asked_via": "pmflow_manual", "action_mode": "read", }, ) assert response.status_code == 200 payload = response.json() assert payload["code"] == 0 assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "") ingress_kwargs = captured["ingress_kwargs"] assert isinstance(ingress_kwargs, dict) assert ingress_kwargs["source_system"] == "pmflow_manual" rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] assert rag_request_context["asked_via"] == "pmflow_manual" assert rag_request_context["action_mode"] == "read" assert rag_request_context["message_intent"] is None @pytest.mark.asyncio async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "intent": "query.read_only", "direct_reply_required": True, "direct_reply_text": "[回答] 进展稳定\n[已收到需求] 新增任务确认流", "rag_result": {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"}, } monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) app = _build_test_app() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/rag/ask", json={ "question": "顺便问进展并新增任务确认流", "asked_via": "pmflow_manual", "action_mode": "read", "message_intent": "mixed", "mixed_signal_part": "新增任务确认流", }, ) assert response.status_code == 200 payload = response.json() assert payload["code"] == 0 assert "[回答]" in str((payload.get("data") or {}).get("answer") or "") ingress_kwargs = captured["ingress_kwargs"] assert isinstance(ingress_kwargs, dict) rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] assert rag_request_context["message_intent"] == "mixed" assert rag_request_context["mixed_signal_part"] == "新增任务确认流" @pytest.mark.asyncio async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "draft_pending_confirm", "classification": "command", "direct_reply_required": True, "direct_reply_text": "草稿已生成,请确认。", "draft_version": 2, } monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) app = _build_test_app() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/ai-pmo/process-text", json={ "message_text": "请创建任务并先给草稿", "sender": "产品经理A", "source_message_id": "manual-msg-100", "project_id": 12, }, ) assert response.status_code == 200 payload = response.json() assert payload["code"] == 0 data = payload["data"] assert data["status"] == "draft_pending_confirm" kwargs = captured["ingress_kwargs"] assert isinstance(kwargs, dict) assert kwargs["source_system"] == "pmflow_manual" assert kwargs["message_text"] == "请创建任务并先给草稿" assert kwargs["project_hint_id"] == 12 @pytest.mark.asyncio async def test_http_ai_pmo_work_items_list_smoke(monkeypatch) -> None: class FakeResult: def all(self): return [] class FakeDB: async def execute(self, *_args, **_kwargs): return FakeResult() async def _fake_get_db(): yield FakeDB() app = _build_test_app() app.dependency_overrides[get_db] = _fake_get_db transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/ai-pmo/work-items", params={"limit": 18}) assert response.status_code == 200 payload = response.json() assert payload["code"] == 0 assert payload["data"]["items"] == [] def test_ai_pmo_api_router_uses_single_prefix() -> None: route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes} assert "/ai-pmo/work-items" in route_paths assert "/ai-pmo/process-text" in route_paths assert "/ai-pmo/ai-pmo/work-items" not in route_paths assert "/ai-pmo/ai-pmo/process-text" not in route_paths