# -*- coding: utf-8 -*- """分诊交互模块回归测试 — T02 测试范围: H5端接口(7个): start_success, start_timeout, start_dify_unavailable, submit_step, skip_step, transfer, complete 坐席端接口(6个): list_pending, get_stats, get_detail, route_session, get_history, exclude_options_ws_push Service层(2个): determine_urgency_keywords, determine_urgency_confidence 测试依赖: conftest.py 提供的 client / db_session fixtures """ import asyncio import uuid from datetime import datetime from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.triage_session import TriageSession from app.services.triage_service import TriageService # ============================================================================ # 辅助函数 # ============================================================================ async def _login(client: AsyncClient, db_session: AsyncSession, role: str = "agent") -> str: """登录并返回 Bearer token。 Args: client: 测试客户端 db_session: 数据库会话 role: 角色(agent/admin) Returns: str: Bearer token """ from app.models.role import Role from app.models.user_role import UserRole user_id = f"test_{role}_{uuid.uuid4().hex[:8]}" # 确保角色存在 stmt = select(Role).where(Role.name == role) result = await db_session.execute(stmt) db_role = result.scalars().first() if not db_role: display = "坐席" if role == "agent" else "管理员" db_role = Role( name=role, display_name=display, description=f"{display}角色", permissions=[], ) db_session.add(db_role) await db_session.flush() # 创建 UserRole 关联 db_session.add(UserRole( employee_id=user_id, role_id=db_role.id, source="manual", assigned_by="test_fixture", )) await db_session.flush() # 登录 resp = await client.post("/agents/login", json={ "user_id": user_id, "name": f"测试{role}", }) return resp.json()["data"]["token"] async def _create_triage_session(db_session: AsyncSession, **kwargs) -> TriageSession: """在数据库中创建分诊会话记录。 Args: db_session: 数据库会话 **kwargs: 覆盖默认字段值 Returns: TriageSession: 创建的会话对象 """ defaults = { "conversation_id": f"conv-{uuid.uuid4().hex[:8]}", "user_id": "test_user_001", "user_name": "测试用户", "user_dept": "技术部", "request_title": "测试问题标题", "request_content": "测试问题内容", "source": "wecom_h5", "status": "pending", "urgency": "medium", } defaults.update(kwargs) session = TriageSession(**defaults) db_session.add(session) await db_session.flush() return session # ============================================================================ # Fixture: Mock Dify 分诊服务 # ============================================================================ @pytest.fixture def mock_dify_triage(): """Mock Dify triage service on the singleton TriageService。 TriageService 是单例,dify_service 在 __init__ 中赋值。 此 fixture 替换 dify_service 为 AsyncMock,测试后恢复原值。 """ from app.services.triage_service import get_triage_service service = get_triage_service() original = service.dify_service mock = AsyncMock() service.dify_service = mock yield mock service.dify_service = original # ============================================================================ # Section A — H5 端接口测试(7个) # ============================================================================ class TestH5Triage: """H5 端分诊交互接口测试。""" @pytest.mark.asyncio async def test_start_triage_success(self, client, db_session, mock_dify_triage): """正常发起分诊 — code=0, 返回 triage_id/steps/confidence/urgency。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.85, "urgency": "medium", "suggested_route": "ai_self", "problem_type": "软件", "problem_category": "Outlook", "matched_knowledge": "FAQ-001", "match_score": 0.92, "context_tags": ["email"], "triage_steps": [ { "question": "您遇到的问题是?", "options": [ {"label": "无法登录", "probability": 0.7}, {"label": "邮件发不出", "probability": 0.3}, ], } ], } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-test-001", "question": "我的Outlook打不开了", }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 result = data["data"] assert "triage_id" in result assert len(result["steps"]) == 1 assert result["confidence"] == 0.85 # "打不开" 是中级关键词 → medium assert result["urgency"] == "medium" @pytest.mark.asyncio async def test_start_triage_timeout(self, client, db_session, mock_dify_triage): """Dify 超时 — status=timeout, 自动转人工。""" token = await _login(client, db_session) # 模拟 Dify 超时(asyncio.wait_for 捕获 TimeoutError) mock_dify_triage.analyze.side_effect = asyncio.TimeoutError() resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-test-002", "question": "密码过期了怎么办", }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["status"] == "timeout" assert "triage_id" in data["data"] @pytest.mark.asyncio async def test_start_triage_dify_unavailable(self, client, db_session, mock_dify_triage): """Dify 不可用 — 降级转人工。""" token = await _login(client, db_session) # 模拟 Dify 不可用(RuntimeError 触发降级转人工) mock_dify_triage.analyze.side_effect = RuntimeError("Dify unavailable") resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-test-003", "question": "VPN连不上了", }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["status"] == "timeout" assert "triage_id" in data["data"] @pytest.mark.asyncio async def test_submit_step_success(self, client, db_session, mock_dify_triage): """提交步骤选择 — 返回 next_step 和 collected_context。""" token = await _login(client, db_session) # 先发起分诊 mock_dify_triage.analyze.return_value = { "confidence": 0.8, "urgency": "low", "triage_steps": [ {"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]}, {"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]}, ], } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-step-001", "question": "打印机问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] # 提交步骤0的选择 resp = await client.post("/h5/triage/step", json={ "triage_id": triage_id, "step_index": 0, "selected_label": "选项A", }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["next_step"] is not None assert data["data"]["next_step"]["question"] == "问题2" assert "选项A" in data["data"]["collected_context"] @pytest.mark.asyncio async def test_skip_step_success(self, client, db_session, mock_dify_triage): """跳过步骤 — 返回 next_step。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.7, "urgency": "low", "triage_steps": [ {"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]}, {"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]}, ], } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-skip-001", "question": "网络问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] resp = await client.post("/h5/triage/skip", json={ "triage_id": triage_id, "step_index": 0, }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["next_step"] is not None assert data["data"]["next_step"]["question"] == "问题2" @pytest.mark.asyncio async def test_transfer_to_human_success(self, client, db_session, mock_dify_triage): """转人工 — status=waiting_agent。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.6, "urgency": "low", "triage_steps": [{"question": "问题1", "options": []}], } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-transfer-001", "question": "硬件问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] resp = await client.post("/h5/triage/transfer", json={ "triage_id": triage_id, "context": ["用户选择的上下文"], }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["status"] == "waiting_agent" assert "conversation_id" in data["data"] @pytest.mark.asyncio async def test_complete_triage_success(self, client, db_session, mock_dify_triage): """分诊完成 — 返回 reply 和 confidence。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.9, "urgency": "low", "triage_steps": [ {"question": "问题1", "options": [{"label": "选项A", "probability": 0.8}]} ], } mock_dify_triage.generate_reply.return_value = { "reply": "建议您重启Outlook客户端。", "confidence": 0.88, } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-complete-001", "question": "软件使用问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] resp = await client.post("/h5/triage/complete", json={ "triage_id": triage_id, "context": ["选项A"], }, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert "reply" in data["data"] assert data["data"]["confidence"] == 0.88 # ============================================================================ # Section B — 坐席端接口测试(6个) # ============================================================================ class TestAgentTriage: """坐席端分诊看板接口测试。""" @pytest.mark.asyncio async def test_list_pending_sorted_by_urgency(self, client, db_session): """待分诊列表按紧急度排序 high > medium > low。""" token = await _login(client, db_session) # 创建3条不同紧急度的待分诊记录(创建顺序故意打乱) await _create_triage_session(db_session, urgency="low", request_title="低优先级") await _create_triage_session(db_session, urgency="high", request_title="高优先级") await _create_triage_session(db_session, urgency="medium", request_title="中优先级") await db_session.flush() resp = await client.get( "/agent/triage/pending", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 items = data["data"]["items"] assert len(items) == 3 # high 应排在最前 assert items[0]["urgency"] == "high" assert items[1]["urgency"] == "medium" assert items[2]["urgency"] == "low" @pytest.mark.asyncio async def test_get_stats(self, client, db_session): """统计概要返回6项指标。""" token = await _login(client, db_session) # 创建测试数据 await _create_triage_session(db_session, status="pending", urgency="high") await _create_triage_session(db_session, status="triaging", urgency="medium") await _create_triage_session( db_session, status="routed", route_action="ai_self", operated_at=datetime.now(), ) await db_session.flush() resp = await client.get( "/agent/triage/stats", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 stats = data["data"] # 验证6项指标字段都存在 assert "pending_total" in stats assert "today_triaged" in stats assert "ai_self_count" in stats assert "human_count" in stats assert "auto_approval_count" in stats assert "avg_duration_sec" in stats # 验证待分诊数(1 pending + 1 triaging = 2) assert stats["pending_total"] == 2 @pytest.mark.asyncio async def test_get_detail(self, client, db_session, mock_dify_triage): """获取分诊详情。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.85, "urgency": "medium", "triage_steps": [{"question": "问题1", "options": []}], "problem_type": "软件", "problem_category": "Outlook", } # 发起分诊创建会话 resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-detail-001", "question": "Outlook问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] # 获取详情 resp = await client.get( f"/agent/triage/{triage_id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 detail = data["data"] assert detail["id"] == triage_id assert detail["problem_category"] == "Outlook" assert detail["confidence"] == 0.85 @pytest.mark.asyncio async def test_route_session(self, client, db_session, mock_dify_triage): """坐席路由操作覆盖 AI 建议。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.7, "urgency": "low", "triage_steps": [{"question": "问题1", "options": []}], "suggested_route": "ai_self", } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-route-001", "question": "一般问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] # 坐席路由为转人工(覆盖AI建议的ai_self) resp = await client.post( f"/agent/triage/{triage_id}/route", json={ "route_action": "human", "route_note": "需要人工排查", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["route_action"] == "human" assert data["data"]["status"] == "routed" assert data["data"]["route_note"] == "需要人工排查" @pytest.mark.asyncio async def test_get_history(self, client, db_session): """历史列表返回 routed/skipped/timeout 状态的记录。""" token = await _login(client, db_session) # 创建历史记录 await _create_triage_session(db_session, status="routed", route_action="ai_self") await _create_triage_session(db_session, status="routed", route_action="human") await _create_triage_session(db_session, status="skipped") # pending 不应出现在历史中 await _create_triage_session(db_session, status="pending") await db_session.flush() resp = await client.get( "/agent/triage/history", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 items = data["data"]["items"] assert len(items) == 3 # 只有 routed/skipped for item in items: assert item["status"] in ("routed", "skipped", "timeout") @pytest.mark.asyncio async def test_exclude_options_ws_push(self, client, db_session, mock_dify_triage): """排除选项通过 WS 推送到 H5。""" token = await _login(client, db_session) mock_dify_triage.analyze.return_value = { "confidence": 0.8, "urgency": "low", "triage_steps": [{"question": "问题1", "options": [ {"label": "选项A", "probability": 0.5}, {"label": "选项B", "probability": 0.3}, ]}], } resp = await client.post("/h5/triage/start", json={ "conversation_id": "conv-exclude-001", "question": "测试问题", }, headers={"Authorization": f"Bearer {token}"}) triage_id = resp.json()["data"]["triage_id"] # Mock WS manager 的 send_to_employee 方法 with patch( "app.services.ws_manager.manager.send_to_employee", new_callable=AsyncMock, ) as mock_ws: resp = await client.post( f"/agent/triage/{triage_id}/exclude-options", json={ "excluded_labels": ["选项A"], "recommended_label": "选项B", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["code"] == 0 assert data["data"]["excluded"] is True # 验证 WS 推送被调用 mock_ws.assert_called_once() # 验证推送数据格式 call_args = mock_ws.call_args ws_data = call_args[0][1] # 第二个位置参数 assert ws_data["type"] == "triage_exclude" assert "选项A" in ws_data["data"]["excluded_labels"] assert ws_data["data"]["recommended_label"] == "选项B" # ============================================================================ # Section C — Service 层测试(2个) # ============================================================================ class TestTriageService: """TriageService 业务逻辑测试。""" def test_determine_urgency_keywords(self): """紧急度判断关键词规则。 规则: - 高级关键词(紧急/宕机/崩溃等)→ high - 中级关键词(报错/失败/连不上等)→ medium - 无关键词 → low """ # 高级关键词 → high assert TriageService.determine_urgency("系统宕机了") == "high" assert TriageService.determine_urgency("紧急!密码过期") == "high" assert TriageService.determine_urgency("电脑蓝屏了") == "high" assert TriageService.determine_urgency("系统崩溃了") == "high" # 中级关键词 → medium assert TriageService.determine_urgency("VPN连不上") == "medium" assert TriageService.determine_urgency("打印机报错") == "medium" assert TriageService.determine_urgency("登录失败") == "medium" assert TriageService.determine_urgency("页面打不开") == "medium" # 无关键词 → low assert TriageService.determine_urgency("我想查一下工资条") == "low" assert TriageService.determine_urgency("请问年假怎么申请") == "low" def test_determine_urgency_confidence(self): """置信度低于 0.5 为 high。 规则: - confidence < 0.5 → high(即使没有关键词) - 高级关键词始终优先于置信度 - 置信度优先于中级关键词 """ # 置信度 < 0.5 → high(即使没有关键词) assert TriageService.determine_urgency("一般问题", confidence=0.3) == "high" assert TriageService.determine_urgency("普通咨询", confidence=0.49) == "high" # 置信度 >= 0.5 且无关键词 → low assert TriageService.determine_urgency("一般问题", confidence=0.5) == "low" assert TriageService.determine_urgency("普通咨询", confidence=0.9) == "low" # 置信度 < 0.5 但有中级关键词 → high(置信度优先于中级关键词) assert TriageService.determine_urgency("VPN连不上", confidence=0.3) == "high" # 高级关键词始终优先(即使置信度很高) assert TriageService.determine_urgency("宕机", confidence=0.9) == "high" assert TriageService.determine_urgency("宕机", confidence=0.1) == "high"