# ============================================================================= # 企微IT智能服务台 — Wingman API 端点测试 # ============================================================================= # 测试覆盖: # 1. POST /api/conversations/{id}/wingman/draft — 正常/认证/404/降级 # 2. POST /api/conversations/{id}/wingman/summary — 正常/认证/404/降级 # 3. POST /api/conversations/{id}/wingman/tags — 正常/认证/404/降级 # ============================================================================= import uuid from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.models.agent import Agent from app.models.conversation import Conversation from app.models.message import Message from app.services.wingman_service import WingmanService from app.dependencies import dep_wingman_service from app.database import get_db from tests.conftest import create_test_conversation, create_test_agent, MockRedis # ============================================================================= # Fixtures # ============================================================================= @pytest_asyncio.fixture async def wingman_client(db_session: AsyncSession, mock_redis: MockRedis): """提供配置了 Wingman 路由和 mock 服务的 FastAPI 测试客户端。""" # 创建 mock WingmanService mock_wingman = MagicMock(spec=WingmanService) mock_wingman.close = AsyncMock() async def _override_get_db(): yield db_session async def _override_dep_wingman(): return mock_wingman from app.main import create_app app = create_app() # 覆盖数据库依赖 app.dependency_overrides[get_db] = _override_get_db # 覆盖 Wingman 服务依赖 app.dependency_overrides[dep_wingman_service] = _override_dep_wingman # 模拟 Redis(认证依赖需要) # 注意:h5.py 已重构移除 _get_redis,不再需要 patch 它 with patch("app.api.agents._get_redis", return_value=mock_redis): with patch("redis.asyncio.from_url", return_value=mock_redis): transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as ac: # 将 mock_wingman 附加到 client 上,方便测试中配置行为 ac._mock_wingman = mock_wingman yield ac app.dependency_overrides.clear() @pytest_asyncio.fixture async def authed_agent(db_session: AsyncSession, mock_redis: MockRedis): """创建一个已登录的坐席并返回(agent, token)。""" agent = create_test_agent(user_id="wingman_test_agent", name="测试坐席") db_session.add(agent) await db_session.flush() # 在 mock Redis 中存储 token token = "test-wingman-token-001" await mock_redis.setex( f"agent:token:{token}", 28800, "wingman_test_agent" ) return agent, token @pytest_asyncio.fixture async def test_conversation(db_session: AsyncSession, authed_agent): """创建一个测试会话并返回。""" agent, _ = authed_agent conv = create_test_conversation( status="serving", ) conv.assigned_agent_id = agent.user_id db_session.add(conv) await db_session.flush() # 添加一些消息 msg1 = Message( conversation_id=conv.id, sender_type="employee", sender_id="emp001", sender_name="员工张三", content="VPN连不上怎么办", msg_type="text", ) msg2 = Message( conversation_id=conv.id, sender_type="agent", sender_id=agent.user_id, sender_name=agent.name, content="请问报什么错误", msg_type="text", ) db_session.add_all([msg1, msg2]) await db_session.flush() return conv # ============================================================================= # Draft API 测试 # ============================================================================= class TestDraftAPI: """测试 POST /api/conversations/{id}/wingman/draft 端点。""" @pytest.mark.asyncio async def test_draft_success( self, wingman_client, authed_agent, test_conversation ): """正常路径:已认证坐席为存在的会话生成草稿。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman # 配置 mock WingmanService mock_wingman.generate_draft = AsyncMock( return_value={ "content": "请尝试重启VPN客户端", "confidence": 0.85, "reasoning": "基于最近 2 条对话上下文生成", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/draft", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["content"] == "请尝试重启VPN客户端" assert data["data"]["confidence"] == 0.85 @pytest.mark.asyncio async def test_draft_unauthorized(self, wingman_client, test_conversation): """认证验证:未登录坐席访问应返回错误码 1002。""" conv = test_conversation response = await wingman_client.post( f"/conversations/{conv.id}/wingman/draft", ) data = response.json() assert data["code"] == 1002 # ERR_UNAUTHORIZED @pytest.mark.asyncio async def test_draft_conversation_not_found( self, wingman_client, authed_agent ): """会话不存在:传入不存在的 conversation_id 应返回错误码 1003。""" _, token = authed_agent fake_id = str(uuid.uuid4()) response = await wingman_client.post( f"/conversations/{fake_id}/wingman/draft", headers={"Authorization": f"Bearer {token}"}, ) data = response.json() assert data["code"] == 1003 # ERR_NOT_FOUND @pytest.mark.asyncio async def test_draft_wingman_degradation( self, wingman_client, authed_agent, test_conversation ): """Wingman 降级:WingmanService 返回降级结果时,API 不应 500。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman # 配置 mock 返回降级结果 mock_wingman.generate_draft = AsyncMock( return_value={ "content": "", "confidence": 0.0, "reasoning": "Wingman 服务暂不可用", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/draft", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["content"] == "" assert data["data"]["confidence"] == 0.0 # ============================================================================= # Summary API 测试 # ============================================================================= class TestSummaryAPI: """测试 POST /api/conversations/{id}/wingman/summary 端点。""" @pytest.mark.asyncio async def test_summary_success( self, wingman_client, authed_agent, test_conversation ): """正常路径:已认证坐席为存在的会话生成摘要。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman mock_wingman.generate_summary = AsyncMock( return_value={ "problem": "VPN连接失败", "cause": "证书过期", "solution": "更新VPN证书并重启客户端", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/summary", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["problem"] == "VPN连接失败" assert data["data"]["cause"] == "证书过期" @pytest.mark.asyncio async def test_summary_unauthorized(self, wingman_client, test_conversation): """认证验证:未登录坐席访问应返回错误码 1002。""" conv = test_conversation response = await wingman_client.post( f"/conversations/{conv.id}/wingman/summary", ) data = response.json() assert data["code"] == 1002 @pytest.mark.asyncio async def test_summary_conversation_not_found( self, wingman_client, authed_agent ): """会话不存在:传入不存在的 conversation_id 应返回错误码 1003。""" _, token = authed_agent fake_id = str(uuid.uuid4()) response = await wingman_client.post( f"/conversations/{fake_id}/wingman/summary", headers={"Authorization": f"Bearer {token}"}, ) data = response.json() assert data["code"] == 1003 @pytest.mark.asyncio async def test_summary_wingman_degradation( self, wingman_client, authed_agent, test_conversation ): """Wingman 降级:WingmanService 返回降级摘要时,API 不应 500。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman mock_wingman.generate_summary = AsyncMock( return_value={ "problem": "无法自动生成摘要", "cause": "", "solution": "", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/summary", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["problem"] == "无法自动生成摘要" # ============================================================================= # Tags API 测试 # ============================================================================= class TestTagsAPI: """测试 POST /api/conversations/{id}/wingman/tags 端点。""" @pytest.mark.asyncio async def test_tags_success( self, wingman_client, authed_agent, test_conversation ): """正常路径:已认证坐席为存在的会话生成标签建议。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman mock_wingman.suggest_tags = AsyncMock( return_value={ "suggested_tags": ["VPN", "网络"], "category": "网络", "priority": "high", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/tags", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["suggested_tags"] == ["VPN", "网络"] assert data["data"]["priority"] == "high" @pytest.mark.asyncio async def test_tags_unauthorized(self, wingman_client, test_conversation): """认证验证:未登录坐席访问应返回错误码 1002。""" conv = test_conversation response = await wingman_client.post( f"/conversations/{conv.id}/wingman/tags", ) data = response.json() assert data["code"] == 1002 @pytest.mark.asyncio async def test_tags_conversation_not_found( self, wingman_client, authed_agent ): """会话不存在:传入不存在的 conversation_id 应返回错误码 1003。""" _, token = authed_agent fake_id = str(uuid.uuid4()) response = await wingman_client.post( f"/conversations/{fake_id}/wingman/tags", headers={"Authorization": f"Bearer {token}"}, ) data = response.json() assert data["code"] == 1003 @pytest.mark.asyncio async def test_tags_wingman_degradation( self, wingman_client, authed_agent, test_conversation ): """Wingman 降级:WingmanService 返回降级标签时,API 不应 500。""" agent, token = authed_agent conv = test_conversation mock_wingman = wingman_client._mock_wingman mock_wingman.suggest_tags = AsyncMock( return_value={ "suggested_tags": [], "category": "", "priority": "medium", } ) response = await wingman_client.post( f"/conversations/{conv.id}/wingman/tags", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 assert data["data"]["suggested_tags"] == [] assert data["data"]["priority"] == "medium"