Files
wecom_it_smart_desk/backend/tests/test_routing_service.py
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

1576 lines
63 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# IT智能服务台 — 业务路由推荐功能测试
# =============================================================================
# 测试覆盖:
# 1. 关键词预过滤 routing_keyword_prefilter(命中/未命中/空值/精确匹配)
# 2. 降级兜底 _keyword_fallback_category(各业务类别/未命中/空值)
# 3. Dify 调用 detect_routing_intentmock httpx,解析6字段)
# 4. 联系人查询 get_contact_by_category(按类别查询/未找到/停用排除)
# 5. 名片三段式发送 send_contact_cardmock db/ws,验证三消息+双通道)
# 6. 路由事件记录 record_routing_event(正常/异常不中断主流程)
# 7. 审批向后兼容(ApprovalDetectIntentResponse 默认值 + Dify 解析扩展字段)
# 8. 置信度阈值逻辑(0.85触发/0.5不触发/0.7边界)
# =============================================================================
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from app.config import settings
from app.models.business_contact import BusinessContact
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.routing_event import RoutingEvent
from app.services.routing_service import (
ROUTING_KEYWORD_TO_CATEGORY,
ROUTING_PREFILTER_KEYWORDS,
_keyword_fallback_category,
detect_routing_intent,
get_contact_by_category,
record_routing_event,
routing_keyword_prefilter,
send_contact_card,
)
# =============================================================================
# 辅助 fixtures
# =============================================================================
@pytest_asyncio.fixture
async def seeded_contacts(db_session):
"""插入测试用业务联系人数据。"""
contacts = [
BusinessContact(
name="王芳", gender="female", department="行政部",
position="设备管理岗", responsibility="打印机/复印机/扫描仪",
extension="8002", service_area="滨江园区 3-5楼",
wecom_userid="WangFang", avatar_url="",
business_category="行政", is_active=True,
),
BusinessContact(
name="陈伟", gender="male", department="行政部",
position="行政事务岗", responsibility="办公用品/名片印刷/保洁服务",
extension="8003", service_area="滨江园区 1-2楼",
wecom_userid="ChenWei", avatar_url="",
business_category="行政", is_active=True,
),
BusinessContact(
name="李娜", gender="female", department="人力资源部",
position="员工服务岗", responsibility="工牌补办/考勤异常/入职手续",
extension="8005", service_area="滨江园区 A栋3楼",
wecom_userid="LiNa", avatar_url="",
business_category="人力资源", is_active=True,
),
BusinessContact(
name="张磊", gender="male", department="人力资源部",
position="薪酬福利岗", responsibility="社保/公积金/离职手续",
extension="8006", service_area="滨江园区 A栋3楼",
wecom_userid="ZhangLei", avatar_url="",
business_category="人力资源", is_active=True,
),
BusinessContact(
name="刘洋", gender="male", department="财务部",
position="费用报销岗", responsibility="报销/发票/借款/工资条",
extension="8010", service_area="滨江园区 B栋4楼",
wecom_userid="LiuYang", avatar_url="",
business_category="财务", is_active=True,
),
BusinessContact(
name="赵敏", gender="female", department="法务部",
position="法务顾问岗", responsibility="合同/法律咨询/知识产权",
extension="8015", service_area="滨江园区 C栋5楼",
wecom_userid="ZhaoMin", avatar_url="",
business_category="法务", is_active=True,
),
BusinessContact(
name="孙强", gender="male", department="行政部",
position="物业管理岗", responsibility="空调/电梯/门禁/停车",
extension="8008", service_area="滨江园区 全园区",
wecom_userid="SunQiang", avatar_url="",
business_category="行政-物业", is_active=True,
),
# 停用的联系人(不应被查询到)
BusinessContact(
name="停用联系人", gender="male", department="行政部",
position="已停用岗", responsibility="测试停用",
extension="9999", service_area="",
wecom_userid="Disabled", avatar_url="",
business_category="行政", is_active=False,
),
]
db_session.add_all(contacts)
await db_session.flush()
return contacts
@pytest_asyncio.fixture
async def seeded_conversation(db_session):
"""插入测试用会话数据。"""
conv = Conversation(
employee_id="test_emp_001",
employee_name="测试员工",
department="技术部",
position="工程师",
level="",
status="ai_handling",
is_vip=False,
is_pinned=False,
is_todo=False,
urgency_score=1,
tags={},
last_message_at=datetime.now(),
last_message_summary="测试消息",
ai_substantive_reply_count=0,
)
db_session.add(conv)
await db_session.flush()
return conv
# =============================================================================
# 单元测试:routing_keyword_prefilter
# =============================================================================
class TestRoutingKeywordPrefilter:
"""测试路由关键词预过滤函数。"""
def test_hit_printer(self):
"""包含 '打印机' 关键词时返回 True。"""
assert routing_keyword_prefilter("打印机坏了") is True
def test_hit_air_conditioner(self):
"""包含 '空调' 关键词时返回 True。"""
assert routing_keyword_prefilter("空调不制冷了") is True
def test_hit_badge(self):
"""包含 '工牌' 关键词时返回 True。"""
assert routing_keyword_prefilter("工牌丢了补办找谁") is True
def test_hit_reimbursement(self):
"""包含 '报销' 关键词时返回 True。"""
assert routing_keyword_prefilter("报销流程是什么") is True
def test_miss_normal_message(self):
"""普通对话消息不包含路由关键词时返回 False。"""
assert routing_keyword_prefilter("你好") is False
assert routing_keyword_prefilter("谢谢,问题解决了") is False
def test_miss_empty_string(self):
"""空字符串返回 False。"""
assert routing_keyword_prefilter("") is False
def test_miss_none(self):
"""None 返回 False。"""
assert routing_keyword_prefilter(None) is False
def test_miss_it_message(self):
"""IT 相关但非路由关键词的消息返回 False。"""
assert routing_keyword_prefilter("VPN连不上了") is False
assert routing_keyword_prefilter("电脑蓝屏了") is False
assert routing_keyword_prefilter("我要申请一台笔记本电脑") is False
def test_exact_keyword_matching_print_vs_printer(self):
"""验证精确匹配:'打印' 不在关键词列表中,但 '打印机' 是。
'我要打印一份文件' 包含 '打印' 但不包含 '打印机',应返回 False。
'打印机卡纸了' 包含 '打印机',应返回 True。
"""
# "打印" 不是关键词,只有 "打印机" 是
assert routing_keyword_prefilter("我要打印一份文件") is False
assert routing_keyword_prefilter("打印机卡纸了") is True
def test_all_prefilter_keywords_work(self):
"""验证 ROUTING_PREFILTER_KEYWORDS 中的每个关键词都能被命中。"""
for kw in ROUTING_PREFILTER_KEYWORDS:
text = f"测试文本包含{kw}关键词"
assert routing_keyword_prefilter(text) is True, f"关键词 '{kw}' 未被命中"
# =============================================================================
# 单元测试:_keyword_fallback_category
# =============================================================================
class TestKeywordFallbackCategory:
"""测试关键词降级兜底函数。"""
def test_fallback_printer_to_admin(self):
"""'打印机坏了''行政'"""
assert _keyword_fallback_category("打印机坏了") == "行政"
def test_fallback_badge_to_hr(self):
"""'工牌丢了''人力资源'"""
assert _keyword_fallback_category("工牌丢了") == "人力资源"
def test_fallback_reimbursement_to_finance(self):
"""'报销流程是什么''财务'"""
assert _keyword_fallback_category("报销流程是什么") == "财务"
def test_fallback_aircon_to_property(self):
"""'空调不制冷''行政-物业'"""
assert _keyword_fallback_category("空调不制冷") == "行政-物业"
def test_fallback_elevator_to_property(self):
"""'电梯故障''行政-物业'"""
assert _keyword_fallback_category("电梯故障") == "行政-物业"
def test_fallback_contract_to_legal(self):
"""'合同问题''法务'"""
assert _keyword_fallback_category("合同问题") == "法务"
def test_fallback_no_match_returns_none(self):
"""无路由关键词时返回 None。"""
assert _keyword_fallback_category("电脑蓝屏了") is None
def test_fallback_empty_string_returns_none(self):
"""空字符串返回 None。"""
assert _keyword_fallback_category("") is None
def test_fallback_none_returns_none(self):
"""None 返回 None。"""
assert _keyword_fallback_category(None) is None
def test_fallback_normal_message_returns_none(self):
"""普通消息返回 None。"""
assert _keyword_fallback_category("你好") is None
def test_fallback_first_match_wins(self):
"""多个关键词命中时,返回第一个匹配的类别(字典有序遍历)。"""
# "打印机" 在字典中排第一,映射到 "行政"
result = _keyword_fallback_category("打印机和空调都坏了")
assert result is not None
assert result in ROUTING_KEYWORD_TO_CATEGORY.values()
# =============================================================================
# 单元测试:detect_routing_intentmock httpx
# =============================================================================
class TestDetectRoutingIntent:
"""测试 Dify 统一意图识别调用(mock httpx)。"""
@pytest.mark.asyncio
async def test_parse_all_six_fields(self, monkeypatch):
"""Dify 返回完整6字段时,正确解析所有字段。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_dify_timeout", 15)
dify_response = {
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "non_it_routing",
"business_category": "行政",
"routing_confidence": 0.85,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
result = await detect_routing_intent("打印机坏了", "test_emp_001")
assert result["intent_type"] == "non_it_routing"
assert result["business_category"] == "行政"
assert result["routing_confidence"] == 0.85
assert result["is_approval_request"] is False
assert result["confidence"] == 0.1
assert result["approval_type"] is None
@pytest.mark.asyncio
async def test_parse_approval_intent(self, monkeypatch):
"""Dify 返回审批意图时,正确解析。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
dify_response = {
"is_approval_request": True,
"confidence": 0.95,
"approval_type": "设备申请",
"intent_type": "approval",
"business_category": None,
"routing_confidence": 0.0,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
result = await detect_routing_intent("我要申请笔记本电脑", "test_emp_001")
assert result["intent_type"] == "approval"
assert result["is_approval_request"] is True
assert result["confidence"] == 0.95
assert result["approval_type"] == "设备申请"
assert result["business_category"] is None
assert result["routing_confidence"] == 0.0
@pytest.mark.asyncio
async def test_parse_chitchat_intent(self, monkeypatch):
"""Dify 返回闲聊意图时,正确解析。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
dify_response = {
"is_approval_request": False,
"confidence": 0.05,
"approval_type": None,
"intent_type": "chitchat",
"business_category": None,
"routing_confidence": 0.05,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
result = await detect_routing_intent("你好", "test_emp_001")
assert result["intent_type"] == "chitchat"
assert result["routing_confidence"] == 0.05
@pytest.mark.asyncio
async def test_not_configured_raises_value_error(self, monkeypatch):
"""Dify 未配置时抛出 ValueError。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "")
monkeypatch.setattr(settings, "approval_dify_api_key", "")
with pytest.raises(ValueError, match="Dify"):
await detect_routing_intent("打印机坏了")
@pytest.mark.asyncio
async def test_http_error_raises_exception(self, monkeypatch):
"""Dify HTTP 调用失败时抛出异常。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(Exception):
await detect_routing_intent("打印机坏了")
@pytest.mark.asyncio
async def test_invalid_json_raises_exception(self, monkeypatch):
"""Dify 返回非 JSON 时抛出异常。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_response = MagicMock()
mock_response.json.return_value = {"answer": "not a json string"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(json.JSONDecodeError):
await detect_routing_intent("打印机坏了")
@pytest.mark.asyncio
async def test_missing_fields_use_defaults(self, monkeypatch):
"""Dify 返回缺少新字段时,使用默认值(向后兼容)。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
# 旧版 Dify Prompt 只返回原3字段
dify_response = {
"is_approval_request": True,
"confidence": 0.9,
"approval_type": "设备申请",
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
result = await detect_routing_intent("申请设备", "test_emp_001")
# 新字段使用默认值
assert result["intent_type"] == "chitchat" # 默认值
assert result["business_category"] is None # 默认值
assert result["routing_confidence"] == 0.0 # 默认值
# 原字段正常解析
assert result["is_approval_request"] is True
assert result["confidence"] == 0.9
@pytest.mark.asyncio
async def test_employee_id_passed_to_dify(self, monkeypatch):
"""验证 employee_id 被传递给 Dify 的 user 字段。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
dify_response = {
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "non_it_routing",
"business_category": "行政",
"routing_confidence": 0.85,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
await detect_routing_intent("打印机坏了", "test_emp_001")
# 验证 post 调用的 body 中 user 字段
call_args = mock_client.post.call_args
body = call_args.kwargs["json"]
assert body["user"] == "test_emp_001"
@pytest.mark.asyncio
async def test_empty_employee_id_uses_default(self, monkeypatch):
"""未提供 employee_id 时,Dify user 字段使用默认值。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
dify_response = {
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "chitchat",
"business_category": None,
"routing_confidence": 0.0,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.services.routing_service.httpx.AsyncClient", return_value=mock_client):
await detect_routing_intent("你好")
call_args = mock_client.post.call_args
body = call_args.kwargs["json"]
assert body["user"] == "routing_detection"
# =============================================================================
# 单元测试:get_contact_by_category
# =============================================================================
class TestGetContactByCategory:
"""测试按业务类别查询联系人。"""
@pytest.mark.asyncio
async def test_query_admin_returns_first_contact(self, db_session, seeded_contacts):
"""business_category='行政' → 返回第一个有效联系人(王芳,id最小)。"""
result = await get_contact_by_category(db_session, "行政")
assert result is not None
assert result.name == "王芳"
assert result.business_category == "行政"
assert result.is_active is True
@pytest.mark.asyncio
async def test_query_hr_returns_first_contact(self, db_session, seeded_contacts):
"""business_category='人力资源' → 返回第一个有效联系人(李娜)。"""
result = await get_contact_by_category(db_session, "人力资源")
assert result is not None
assert result.name == "李娜"
assert result.business_category == "人力资源"
@pytest.mark.asyncio
async def test_query_finance_returns_contact(self, db_session, seeded_contacts):
"""business_category='财务' → 返回联系人(刘洋)。"""
result = await get_contact_by_category(db_session, "财务")
assert result is not None
assert result.name == "刘洋"
@pytest.mark.asyncio
async def test_query_legal_returns_contact(self, db_session, seeded_contacts):
"""business_category='法务' → 返回联系人(赵敏)。"""
result = await get_contact_by_category(db_session, "法务")
assert result is not None
assert result.name == "赵敏"
@pytest.mark.asyncio
async def test_query_property_returns_contact(self, db_session, seeded_contacts):
"""business_category='行政-物业' → 返回联系人(孙强)。"""
result = await get_contact_by_category(db_session, "行政-物业")
assert result is not None
assert result.name == "孙强"
@pytest.mark.asyncio
async def test_query_nonexistent_category_returns_none(self, db_session, seeded_contacts):
"""business_category='不存在' → 返回 None。"""
result = await get_contact_by_category(db_session, "不存在的类别")
assert result is None
@pytest.mark.asyncio
async def test_inactive_contacts_excluded(self, db_session, seeded_contacts):
"""is_active=False 的联系人不返回。"""
# 行政类别有3个联系人(王芳、陈伟 active,停用联系人 inactive
# 查询应返回 active 的第一个
result = await get_contact_by_category(db_session, "行政")
assert result is not None
assert result.is_active is True
assert result.name != "停用联系人"
@pytest.mark.asyncio
async def test_returns_first_by_id_order(self, db_session, seeded_contacts):
"""返回 id 最小的有效联系人(order_by id)。"""
result = await get_contact_by_category(db_session, "行政")
assert result is not None
# 王芳 id 最小(先插入的)
assert result.name == "王芳"
# =============================================================================
# 单元测试:send_contact_cardmock ws_manager
# =============================================================================
class TestSendContactCard:
"""测试名片三段式发送。"""
@pytest.mark.asyncio
async def test_sends_three_messages(self, db_session, seeded_contacts, seeded_conversation):
"""发送名片后,数据库中新增3条消息(路由文本 + contact_card + 系统提示)。"""
contact = seeded_contacts[0] # 王芳
conv = seeded_conversation
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="打印机问题属于行政设备范畴",
business_category="行政",
routing_confidence=0.85,
)
# 查询数据库中的消息
result = await db_session.execute(
select(Message).where(Message.conversation_id == conv.id)
)
messages = result.scalars().all()
assert len(messages) == 3
# 第1条:路由说明文本
assert messages[0].msg_type == "text"
assert messages[0].sender_type == "ai"
assert "打印机" in messages[0].content
# 第2条:contact_card 名片
assert messages[1].msg_type == "contact_card"
assert messages[1].sender_type == "ai"
assert messages[1].extra_data is not None
assert messages[1].extra_data["contact"]["name"] == "王芳"
assert messages[1].extra_data["routing_reason"] == "打印机问题属于行政设备范畴"
assert messages[1].extra_data["business_category"] == "行政"
assert messages[1].extra_data["routing_confidence"] == 0.85
# 第3条:系统提示
assert messages[2].msg_type == "system"
assert messages[2].sender_type == "system"
@pytest.mark.asyncio
async def test_ws_dual_channel_push(self, db_session, seeded_contacts, seeded_conversation):
"""每条消息都通过 WS 双通道推送(broadcast_to_employees + broadcast)。"""
contact = seeded_contacts[0]
conv = seeded_conversation
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="路由说明",
business_category="行政",
routing_confidence=0.85,
)
# 每条消息推送2次(H5 + 坐席),3条消息共6次
assert mock_ws.broadcast_to_employees.call_count == 3
assert mock_ws.broadcast.call_count == 3
@pytest.mark.asyncio
async def test_ws_push_contains_contact_card_type(self, db_session, seeded_contacts, seeded_conversation):
"""WS 推送中 contact_card 消息的 msg_type 为 'contact_card'"""
contact = seeded_contacts[0]
conv = seeded_conversation
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="路由说明",
business_category="行政",
routing_confidence=0.85,
)
# 检查第2次推送(contact_card)的 H5 推送数据
second_call = mock_ws.broadcast_to_employees.call_args_list[1]
push_data = second_call.args[1]
assert push_data["type"] == "ai_reply"
assert push_data["data"]["msg_type"] == "contact_card"
assert push_data["data"]["extra_data"]["contact"]["name"] == "王芳"
@pytest.mark.asyncio
async def test_updates_conversation_reply_count(self, db_session, seeded_contacts, seeded_conversation):
"""发送名片后会话的 ai_substantive_reply_count +1。"""
contact = seeded_contacts[0]
conv = seeded_conversation
original_count = conv.ai_substantive_reply_count
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="路由说明",
business_category="行政",
routing_confidence=0.85,
)
assert conv.ai_substantive_reply_count == original_count + 1
@pytest.mark.asyncio
async def test_ws_broadcast_failure_does_not_raise(self, db_session, seeded_contacts, seeded_conversation):
"""坐席端 WS 广播失败时不影响主流程(仅 warning)。"""
contact = seeded_contacts[0]
conv = seeded_conversation
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock(side_effect=Exception("WS 不可达"))
# 不应抛出异常
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="路由说明",
business_category="行政",
routing_confidence=0.85,
)
# 消息仍然落库
result = await db_session.execute(
select(Message).where(Message.conversation_id == conv.id)
)
messages = result.scalars().all()
assert len(messages) == 3
@pytest.mark.asyncio
async def test_extra_data_structure(self, db_session, seeded_contacts, seeded_conversation):
"""contact_card 消息的 extra_data 结构符合架构设计规范。"""
contact = seeded_contacts[0]
conv = seeded_conversation
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
await send_contact_card(
db=db_session,
conversation=conv,
employee_id="test_emp_001",
contact=contact,
reason="路由原因文本",
business_category="行政",
routing_confidence=0.85,
)
result = await db_session.execute(
select(Message).where(
Message.conversation_id == conv.id,
Message.msg_type == "contact_card",
)
)
card_msg = result.scalar_one()
extra = card_msg.extra_data
# 架构设计 3.3 节 extra_data 结构规范
assert "contact" in extra
assert "routing_reason" in extra
assert "business_category" in extra
assert "routing_confidence" in extra
# contact 子结构
contact_data = extra["contact"]
assert contact_data["name"] == "王芳"
assert contact_data["department"] == "行政部"
assert contact_data["wecom_userid"] == "WangFang"
assert contact_data["business_category"] == "行政"
# =============================================================================
# 单元测试:record_routing_event
# =============================================================================
class TestRecordRoutingEvent:
"""测试路由事件记录。"""
@pytest.mark.asyncio
async def test_record_event_success(self, db_session, seeded_contacts, seeded_conversation):
"""正常记录路由事件到 routing_events 表。"""
contact = seeded_contacts[0]
conv = seeded_conversation
await record_routing_event(
db=db_session,
conversation_id=conv.id,
employee_id="test_emp_001",
message_content="打印机坏了",
business_category="行政",
routing_confidence=0.85,
contact=contact,
)
result = await db_session.execute(select(RoutingEvent))
events = result.scalars().all()
assert len(events) == 1
event = events[0]
assert event.conversation_id == conv.id
assert event.employee_id == "test_emp_001"
assert event.message_content == "打印机坏了"
assert event.business_category == "行政"
assert event.routing_confidence == 0.85
assert event.contact_id == contact.id
assert event.contact_name == "王芳"
assert event.is_clicked is False
@pytest.mark.asyncio
async def test_record_event_with_none_contact(self, db_session, seeded_conversation):
"""联系人为 None 时,contact_id=None, contact_name=''"""
conv = seeded_conversation
await record_routing_event(
db=db_session,
conversation_id=conv.id,
employee_id="test_emp_001",
message_content="未知问题",
business_category="未知类别",
routing_confidence=0.5,
contact=None,
)
result = await db_session.execute(select(RoutingEvent))
event = result.scalar_one()
assert event.contact_id is None
assert event.contact_name == ""
@pytest.mark.asyncio
async def test_record_event_truncates_long_message(self, db_session, seeded_conversation):
"""超长消息内容截断至500字。"""
conv = seeded_conversation
long_message = "A" * 600
await record_routing_event(
db=db_session,
conversation_id=conv.id,
employee_id="test_emp_001",
message_content=long_message,
business_category="行政",
routing_confidence=0.85,
contact=None,
)
result = await db_session.execute(select(RoutingEvent))
event = result.scalar_one()
assert len(event.message_content) == 500
@pytest.mark.asyncio
async def test_record_event_failure_does_not_raise(self, db_session, seeded_conversation):
"""路由事件记录失败时不抛出异常(不影响主流程)。"""
conv = seeded_conversation
# 使用一个会触发异常的 mock db
mock_db = AsyncMock()
mock_db.add.side_effect = Exception("DB 错误")
# 不应抛出异常
await record_routing_event(
db=mock_db,
conversation_id=conv.id,
employee_id="test_emp_001",
message_content="打印机坏了",
business_category="行政",
routing_confidence=0.85,
contact=None,
)
# =============================================================================
# 审批向后兼容性测试
# =============================================================================
class TestApprovalBackwardCompat:
"""测试审批意图检测的向后兼容性。"""
def test_response_has_new_fields_with_defaults(self):
"""ApprovalDetectIntentResponse 新增字段有默认值。"""
from app.api.approval import ApprovalDetectIntentResponse
# 只提供原4个必填字段,新字段应使用默认值
resp = ApprovalDetectIntentResponse(
is_approval_request=False,
confidence=0.0,
source="keyword_prefilter",
)
assert resp.intent_type == "chitchat"
assert resp.business_category is None
assert resp.routing_confidence == 0.0
def test_response_accepts_new_fields(self):
"""ApprovalDetectIntentResponse 接受新字段赋值。"""
from app.api.approval import ApprovalDetectIntentResponse
resp = ApprovalDetectIntentResponse(
is_approval_request=False,
confidence=0.1,
source="dify",
intent_type="non_it_routing",
business_category="行政",
routing_confidence=0.85,
)
assert resp.intent_type == "non_it_routing"
assert resp.business_category == "行政"
assert resp.routing_confidence == 0.85
@pytest.mark.asyncio
async def test_dify_returns_extended_fields(self, monkeypatch):
"""_call_dify_approval_intent 正确解析 Dify 返回的扩展字段。"""
from app.api.approval import _call_dify_approval_intent
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_dify_timeout", 15)
dify_response = {
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "non_it_routing",
"business_category": "行政",
"routing_confidence": 0.85,
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.api.approval.httpx.AsyncClient", return_value=mock_client):
result = await _call_dify_approval_intent("打印机坏了", "test_emp_001")
assert result["intent_type"] == "non_it_routing"
assert result["business_category"] == "行政"
assert result["routing_confidence"] == 0.85
# 原字段也正确解析
assert result["is_approval_request"] is False
assert result["confidence"] == 0.1
assert result["approval_type"] is None
@pytest.mark.asyncio
async def test_dify_returns_old_format_compatible(self, monkeypatch):
"""Dify 返回旧格式(无新字段)时,_call_dify_approval_intent 使用默认值。"""
from app.api.approval import _call_dify_approval_intent
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
# 旧版 Dify Prompt 只返回原3字段
dify_response = {
"is_approval_request": True,
"confidence": 0.95,
"approval_type": "设备申请",
}
mock_response = MagicMock()
mock_response.json.return_value = {"answer": json.dumps(dify_response)}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.api.approval.httpx.AsyncClient", return_value=mock_client):
result = await _call_dify_approval_intent("我要申请设备", "test_emp_001")
# 新字段使用默认值
assert result["intent_type"] == "chitchat"
assert result["business_category"] is None
assert result["routing_confidence"] == 0.0
# 原字段正常解析
assert result["is_approval_request"] is True
assert result["confidence"] == 0.95
assert result["approval_type"] == "设备申请"
@pytest.mark.asyncio
async def test_detect_intent_returns_extended_fields(self, client, monkeypatch):
"""审批意图检测端点返回扩展字段。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "non_it_routing",
"business_category": "行政",
"routing_confidence": 0.85,
})
# 注意:审批端点使用 APPROVAL_PREFILTER_KEYWORDS 做预过滤,
# "打印机" 不在审批关键词中,但 "电脑" 是。使用包含审批关键词的文本
# 才能通过预过滤并触发 Dify 调用。
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "电脑连不上打印机了"},
)
data = response.json()
inner = data["data"]
# 原字段
assert inner["is_approval_request"] is False
assert inner["source"] == "dify"
# 新字段
assert inner["intent_type"] == "non_it_routing"
assert inner["business_category"] == "行政"
assert inner["routing_confidence"] == 0.85
@pytest.mark.asyncio
async def test_detect_intent_chitchat_keyword_miss(self, client):
"""'你好' 不含审批关键词 → intent_type=chitchat, source=keyword_prefilter。"""
response = await client.post(
"/approval/detect-intent",
json={"text": "你好"},
)
data = response.json()
inner = data["data"]
assert inner["is_approval_request"] is False
assert inner["source"] == "keyword_prefilter"
assert inner["intent_type"] == "chitchat"
assert inner["routing_confidence"] == 0.0
# =============================================================================
# 置信度阈值逻辑测试
# =============================================================================
class TestConfidenceThreshold:
"""测试路由置信度阈值逻辑。"""
def test_threshold_default_is_0_7(self):
"""routing_confidence_threshold 配置默认值为 0.7。"""
# config.py 中定义的默认值
assert settings.routing_confidence_threshold == 0.7
def test_high_confidence_triggers_routing(self):
"""routing_confidence=0.85 ≥ 0.7 → 应触发名片推荐。"""
confidence = 0.85
threshold = settings.routing_confidence_threshold
assert confidence >= threshold # 触发条件满足
def test_low_confidence_no_routing(self):
"""routing_confidence=0.5 < 0.7 → 不触发,走正常AI流程。"""
confidence = 0.5
threshold = settings.routing_confidence_threshold
assert confidence < threshold # 不触发
def test_boundary_confidence_triggers(self):
"""routing_confidence=0.7 = 0.7 → 触发(边界值,>= 判断)。"""
confidence = 0.7
threshold = settings.routing_confidence_threshold
assert confidence >= threshold # 边界值触发
def test_zero_confidence_no_routing(self):
"""routing_confidence=0.0 < 0.7 → 不触发。"""
confidence = 0.0
threshold = settings.routing_confidence_threshold
assert confidence < threshold
def test_max_confidence_triggers(self):
"""routing_confidence=1.0 ≥ 0.7 → 触发。"""
confidence = 1.0
threshold = settings.routing_confidence_threshold
assert confidence >= threshold
# =============================================================================
# PRD 5.5 回归测试要点验证
# =============================================================================
class TestPRDRegression:
"""PRD 第5.5节回归测试要点验证(mock Dify 响应)。"""
@pytest.mark.asyncio
async def test_approval_regression(self, client, monkeypatch):
"""'我要申请一台笔记本电脑' → intent_type=approval, is_approval_request=true。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.95,
"approval_type": "设备申请",
"intent_type": "approval",
"business_category": None,
"routing_confidence": 0.0,
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "我要申请一台笔记本电脑"},
)
data = response.json()
inner = data["data"]
assert inner["intent_type"] == "approval"
assert inner["is_approval_request"] is True
assert inner["approval_type"] == "设备申请"
@pytest.mark.asyncio
async def test_it_consult_regression(self, client, monkeypatch):
"""'VPN连不上了' → intent_type=it_consult, is_approval_request=false。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "it_consult",
"business_category": None,
"routing_confidence": 0.1,
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "VPN连不上了"},
)
data = response.json()
inner = data["data"]
assert inner["intent_type"] == "it_consult"
assert inner["is_approval_request"] is False
@pytest.mark.asyncio
async def test_non_it_routing_regression(self, client, monkeypatch):
"""'电脑连不上打印机了' → intent_type=non_it_routing, business_category=行政。
注意:PRD 5.5 原文测试用例为 "电脑连不上打印机了"(含审批关键词"电脑"),
而非 "打印机坏了""打印机"不在审批预过滤关键词中,会被直接过滤)。
"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": False,
"confidence": 0.1,
"approval_type": None,
"intent_type": "non_it_routing",
"business_category": "行政",
"routing_confidence": 0.85,
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "电脑连不上打印机了"},
)
data = response.json()
inner = data["data"]
assert inner["intent_type"] == "non_it_routing"
assert inner["business_category"] == "行政"
assert inner["routing_confidence"] >= 0.8
@pytest.mark.asyncio
async def test_chitchat_regression(self, client):
"""'你好' → intent_type=chitchat, routing_confidence ≤ 0.1。"""
response = await client.post(
"/approval/detect-intent",
json={"text": "你好"},
)
data = response.json()
inner = data["data"]
assert inner["intent_type"] == "chitchat"
assert inner["routing_confidence"] <= 0.1
# =============================================================================
# API 端点测试:GET /h5/routing/contact
# =============================================================================
class TestRoutingAPIContactEndpoint:
"""测试 H5 端查询联系人 API。"""
@pytest.mark.asyncio
async def test_get_contact_admin(self, client, db_session, seeded_contacts):
"""查询行政类别联系人 → 返回王芳信息。"""
# 需要让 routing API 的 get_db 使用 db_session
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get(
"/h5/routing/contact",
params={"business_category": "行政"},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
contact = data["data"]
assert contact is not None
assert contact["name"] == "王芳"
assert contact["business_category"] == "行政"
assert contact["wecom_userid"] == "WangFang"
@pytest.mark.asyncio
async def test_get_contact_not_found(self, client, db_session, seeded_contacts):
"""查询不存在的类别 → data 为 None。"""
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get(
"/h5/routing/contact",
params={"business_category": "不存在的类别"},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"] is None
# =============================================================================
# API 端点测试:GET /routing/contacts
# =============================================================================
class TestRoutingAPIContactsListEndpoint:
"""测试坐席端联系人列表 API。"""
@pytest.mark.asyncio
async def test_list_all_active_contacts(self, client, db_session, seeded_contacts):
"""无筛选条件 → 返回所有启用的联系人。"""
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get("/routing/contacts")
finally:
app.dependency_overrides.pop(routing_get_db, None)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
items = data["data"]["items"]
# 7个启用的联系人(不含停用的)
assert len(items) == 7
@pytest.mark.asyncio
async def test_list_filter_by_category(self, client, db_session, seeded_contacts):
"""按业务类别筛选 → 返回对应类别的联系人。"""
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get(
"/routing/contacts",
params={"business_category": "人力资源"},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
items = data["data"]["items"]
assert len(items) == 2 # 李娜 + 张磊
for item in items:
assert item["business_category"] == "人力资源"
@pytest.mark.asyncio
async def test_list_search_by_keyword(self, client, db_session, seeded_contacts):
"""按关键词搜索 → 返回匹配的联系人。"""
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get(
"/routing/contacts",
params={"keyword": ""},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
items = data["data"]["items"]
assert len(items) == 1
assert items[0]["name"] == "王芳"
@pytest.mark.asyncio
async def test_list_excludes_inactive(self, client, db_session, seeded_contacts):
"""列表不包含停用的联系人。"""
from app.api.routing import get_db as routing_get_db
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
response = await client.get("/routing/contacts")
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
items = data["data"]["items"]
names = [item["name"] for item in items]
assert "停用联系人" not in names
# =============================================================================
# API 端点测试:POST /conversations/{id}/send-contact-card
# =============================================================================
class TestRoutingAPISendCardEndpoint:
"""测试坐席手动发名片 API。"""
@pytest.mark.asyncio
async def test_send_card_success(self, client, db_session, seeded_contacts, seeded_conversation):
"""坐席手动发名片 → 成功返回。"""
from app.api.routing import get_db as routing_get_db
contact = seeded_contacts[0] # 王芳
conv = seeded_conversation
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
response = await client.post(
f"/conversations/{conv.id}/send-contact-card",
json={"contact_id": contact.id, "reason": "坐席手动推荐"},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
result = data["data"]
assert result["contact_name"] == "王芳"
assert result["business_category"] == "行政"
@pytest.mark.asyncio
async def test_send_card_conversation_not_found(self, client, db_session, seeded_contacts):
"""会话不存在 → data 为 None。"""
from app.api.routing import get_db as routing_get_db
contact = seeded_contacts[0]
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
response = await client.post(
"/conversations/nonexistent-conv-id/send-contact-card",
json={"contact_id": contact.id},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
assert data["code"] == 0
assert data["data"] is None
@pytest.mark.asyncio
async def test_send_card_contact_not_found(self, client, db_session, seeded_conversation):
"""联系人不存在 → data 为 None。"""
from app.api.routing import get_db as routing_get_db
conv = seeded_conversation
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
response = await client.post(
f"/conversations/{conv.id}/send-contact-card",
json={"contact_id": 99999},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
assert data["code"] == 0
assert data["data"] is None
@pytest.mark.asyncio
async def test_send_card_inactive_contact(self, client, db_session, seeded_contacts, seeded_conversation):
"""停用的联系人 → data 为 None。"""
from app.api.routing import get_db as routing_get_db
# 找到停用的联系人
inactive_contact = next(c for c in seeded_contacts if not c.is_active)
conv = seeded_conversation
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
response = await client.post(
f"/conversations/{conv.id}/send-contact-card",
json={"contact_id": inactive_contact.id},
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
data = response.json()
assert data["code"] == 0
assert data["data"] is None
@pytest.mark.asyncio
async def test_send_card_default_reason(self, client, db_session, seeded_contacts, seeded_conversation):
"""未提供 reason 时使用默认路由说明文本。"""
from app.api.routing import get_db as routing_get_db
contact = seeded_contacts[0]
conv = seeded_conversation
async def _override_db():
yield db_session
app = client._transport.app
app.dependency_overrides[routing_get_db] = _override_db
try:
with patch("app.services.routing_service.ws_manager") as mock_ws:
mock_ws.broadcast_to_employees = AsyncMock()
mock_ws.broadcast = AsyncMock()
response = await client.post(
f"/conversations/{conv.id}/send-contact-card",
json={"contact_id": contact.id}, # 不提供 reason
)
finally:
app.dependency_overrides.pop(routing_get_db, None)
assert response.status_code == 200
# 验证消息落库(使用默认 reason)
result = await db_session.execute(
select(Message).where(
Message.conversation_id == conv.id,
Message.msg_type == "text",
)
)
text_msg = result.scalar_one()
assert "行政" in text_msg.content
assert "王芳" in text_msg.content
# =============================================================================
# 数据模型测试
# =============================================================================
class TestBusinessContactModel:
"""测试 BusinessContact 模型。"""
def test_to_dict_returns_complete_data(self):
"""to_dict() 返回完整的联系人信息。"""
contact = BusinessContact(
id=1,
name="王芳",
gender="female",
department="行政部",
position="设备管理岗",
responsibility="打印机/复印机/扫描仪",
extension="8002",
service_area="滨江园区 3-5楼",
wecom_userid="WangFang",
avatar_url=None,
business_category="行政",
is_active=True,
)
data = contact.to_dict()
assert data["id"] == 1
assert data["name"] == "王芳"
assert data["gender"] == "female"
assert data["department"] == "行政部"
assert data["position"] == "设备管理岗"
assert data["responsibility"] == "打印机/复印机/扫描仪"
assert data["extension"] == "8002"
assert data["service_area"] == "滨江园区 3-5楼"
assert data["wecom_userid"] == "WangFang"
assert data["avatar_url"] == ""
assert data["business_category"] == "行政"
def test_to_dict_handles_none_fields(self):
"""to_dict() 正确处理 None 字段(转为空字符串)。"""
contact = BusinessContact(
id=2,
name="测试",
gender="male",
department="测试部",
position="测试岗",
responsibility="测试",
extension=None,
service_area=None,
wecom_userid="Test",
avatar_url=None,
business_category="行政",
is_active=True,
)
data = contact.to_dict()
assert data["extension"] == ""
assert data["service_area"] == ""
assert data["avatar_url"] == ""
# =============================================================================
# 配置测试
# =============================================================================
class TestRoutingConfig:
"""测试路由推荐相关配置。"""
def test_routing_confidence_threshold_exists(self):
"""routing_confidence_threshold 配置项存在。"""
assert hasattr(settings, "routing_confidence_threshold")
def test_routing_confidence_threshold_default(self):
"""routing_confidence_threshold 默认值为 0.7。"""
assert settings.routing_confidence_threshold == 0.7
def test_routing_keywords_cover_all_categories(self):
"""路由关键词覆盖所有5个业务类别。"""
categories = set(ROUTING_KEYWORD_TO_CATEGORY.values())
expected = {"行政", "人力资源", "财务", "法务", "行政-物业"}
assert categories == expected
def test_all_prefilter_keywords_have_category_mapping(self):
"""每个预过滤关键词都有对应的类别映射。"""
for kw in ROUTING_PREFILTER_KEYWORDS:
assert kw in ROUTING_KEYWORD_TO_CATEGORY, f"关键词 '{kw}' 缺少类别映射"