Files
wecom_it_smart_desk/backend/tests/test_routing_service.py
T

1576 lines
63 KiB
Python
Raw Normal View History

# =============================================================================
# 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}' 缺少类别映射"