Files
wecom_it_smart_desk/backend/tests/test_message_router.py

286 lines
11 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智能服务台 — MessageRouter 消息路由测试
# =============================================================================
# 测试覆盖:
# 1. 查找或创建会话(新员工创建 / 已有会话复用)
# 2. VIP 检测(总监/CEO/普通员工)
# 3. 举手标记检测集成
# 4. 情绪标记检测集成
# 5. 需介入标记检测集成
# 6. 紧急度评分集成(验证 Bug 1 修复:await calculate_urgency
# 7. 消息记录创建
# 8. VIP 检测失败不阻塞流程
# =============================================================================
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.system_config import SystemConfig
from app.services.message_router import MessageRouter
from app.services.scoring_service import ScoringService
from app.services.wecom_service import WecomService
from tests.conftest import create_test_conversation, MockRedis
@pytest_asyncio.fixture
async def setup_router_db(db_session):
"""初始化路由器所需的数据库配置。"""
configs = [
SystemConfig(config_key="hand_raise_keywords", config_value='["转人工","人工","真人"]'),
SystemConfig(config_key="emotion_keywords_angry", config_value='["崩溃","愤怒","投诉"]'),
SystemConfig(config_key="emotion_keywords_urgent", config_value='["","紧急","马上"]'),
SystemConfig(config_key="emotion_keywords_worried", config_value='["担心","害怕"]'),
SystemConfig(config_key="intervene_round_threshold", config_value="3"),
SystemConfig(config_key="urgency_base_keyword_score", config_value="1"),
SystemConfig(config_key="urgency_emotion_bonus", config_value="1"),
SystemConfig(config_key="urgency_vip_bonus", config_value="1"),
SystemConfig(config_key="urgency_repeat_bonus", config_value="1"),
]
db_session.add_all(configs)
await db_session.flush()
def _create_mock_wecom_service():
"""创建模拟的 WecomService。"""
mock = AsyncMock(spec=WecomService)
mock.get_user_info = AsyncMock(return_value={
"name": "张三",
"department": "[1, 2]",
"position": "工程师",
})
mock.send_text_message = AsyncMock(return_value={"errcode": 0})
mock.close = AsyncMock()
return mock
@pytest_asyncio.fixture
def mock_wecom_service():
return _create_mock_wecom_service()
@pytest_asyncio.fixture
def router(db_session, mock_wecom_service, setup_router_db):
"""创建消息路由器实例。"""
scoring_service = ScoringService(db_session)
return MessageRouter(
db=db_session,
wecom_service=mock_wecom_service,
scoring_service=scoring_service,
)
class TestFindOrCreateConversation:
"""测试查找或创建会话。"""
@pytest.mark.asyncio
async def test_create_new_conversation(self, router, db_session):
"""验证新员工首次发消息时创建新会话。"""
conv = await router._find_or_create_conversation("new_employee_001", "帮我重置密码")
assert conv is not None
assert conv.employee_id == "new_employee_001"
assert conv.status == "queued"
assert conv.urgency_score == 1
assert conv.last_message_summary == "帮我重置密码"
@pytest.mark.asyncio
async def test_reuse_existing_queued_conversation(self, router, db_session):
"""验证已有 queued 状态的会话会被复用。"""
# 先创建一个会话
existing = create_test_conversation(employee_id="reuse_user", status="queued")
db_session.add(existing)
await db_session.flush()
existing_id = existing.id
# 再次查找应复用
conv = await router._find_or_create_conversation("reuse_user", "新消息")
assert conv.id == existing_id
@pytest.mark.asyncio
async def test_reuse_existing_serving_conversation(self, router, db_session):
"""验证已有 serving 状态的会话会被复用。"""
existing = create_test_conversation(employee_id="serving_user", status="serving")
db_session.add(existing)
await db_session.flush()
existing_id = existing.id
conv = await router._find_or_create_conversation("serving_user", "追加消息")
assert conv.id == existing_id
@pytest.mark.asyncio
async def test_create_new_when_resolved(self, router, db_session):
"""验证 resolved 状态的会话不会被复用,会创建新会话。"""
existing = create_test_conversation(employee_id="resolved_user", status="resolved")
db_session.add(existing)
await db_session.flush()
conv = await router._find_or_create_conversation("resolved_user", "新咨询")
assert conv.id != existing.id
assert conv.status == "queued"
@pytest.mark.asyncio
async def test_summary_truncated_to_256(self, router, db_session):
"""验证消息摘要截取前 256 字符。"""
long_content = "A" * 300
conv = await router._find_or_create_conversation("trunc_user", long_content)
assert len(conv.last_message_summary) == 256
class TestCheckVip:
"""测试 VIP 检测。"""
@pytest.mark.asyncio
async def test_vip_detection_for_director(self, router, db_session, mock_wecom_service):
"""验证总监级别被识别为 VIP。"""
mock_wecom_service.get_user_info.return_value = {
"name": "王总监",
"department": "[1]",
"position": "技术总监",
}
conv = create_test_conversation(employee_id="vip_director")
db_session.add(conv)
await db_session.flush()
await router._check_vip(conv)
assert conv.is_vip is True
assert conv.employee_name == "王总监"
assert conv.position == "技术总监"
@pytest.mark.asyncio
async def test_vip_detection_for_ceo(self, router, db_session, mock_wecom_service):
"""验证 CEO 被识别为 VIP。"""
mock_wecom_service.get_user_info.return_value = {
"name": "李CEO",
"department": "[1]",
"position": "CEO",
}
conv = create_test_conversation(employee_id="vip_ceo")
db_session.add(conv)
await db_session.flush()
await router._check_vip(conv)
assert conv.is_vip is True
@pytest.mark.asyncio
async def test_no_vip_for_regular_engineer(self, router, db_session, mock_wecom_service):
"""验证普通工程师不被识别为 VIP。"""
mock_wecom_service.get_user_info.return_value = {
"name": "张三",
"department": "[1]",
"position": "工程师",
}
conv = create_test_conversation(employee_id="regular_engineer")
db_session.add(conv)
await db_session.flush()
await router._check_vip(conv)
assert conv.is_vip is False
@pytest.mark.asyncio
async def test_vip_check_failure_does_not_block(self, router, db_session, mock_wecom_service):
"""验证 VIP 检测 API 失败时不阻塞消息路由。"""
mock_wecom_service.get_user_info.side_effect = Exception("API 调用失败")
conv = create_test_conversation(employee_id="api_fail_user")
db_session.add(conv)
await db_session.flush()
# 不应抛出异常
await router._check_vip(conv)
assert conv.is_vip is False # 保持默认值
@pytest.mark.asyncio
async def test_vip_check_skipped_if_already_detected(self, router, db_session, mock_wecom_service):
"""验证已检测过 VIP 的会话不再重复检测。"""
conv = create_test_conversation(employee_id="already_vip", is_vip=True)
db_session.add(conv)
await db_session.flush()
await router._check_vip(conv)
# get_user_info 不应被调用(因为 is_vip 已经为 True
mock_wecom_service.get_user_info.assert_not_called()
class TestRouteMessage:
"""测试完整的消息路由流程。"""
@pytest.mark.asyncio
async def test_route_normal_message(self, router, db_session, mock_wecom_service):
"""验证普通消息的路由流程。"""
conv = await router.route_message("normal_user", "帮我重置密码")
assert conv is not None
assert conv.employee_id == "normal_user"
assert conv.status == "queued"
assert conv.urgency_score >= 1
@pytest.mark.asyncio
async def test_route_message_with_hand_raise(self, router, db_session, mock_wecom_service):
"""验证举手关键词触发举手标记。"""
conv = await router.route_message("hand_raise_user", "我要转人工")
assert conv.tags.get("hand_raise") is True
@pytest.mark.asyncio
async def test_route_message_with_emotion(self, router, db_session, mock_wecom_service):
"""验证情绪关键词触发情绪标记。"""
conv = await router.route_message("angry_user", "太崩溃了!系统太差了")
assert conv.tags.get("emotion") == "angry"
assert "崩溃" in conv.tags.get("emotion_keywords", [])
@pytest.mark.asyncio
async def test_route_message_creates_message_record(self, router, db_session, mock_wecom_service):
"""验证路由消息时创建消息记录。"""
conv = await router.route_message("msg_record_user", "测试消息内容")
# 查询消息记录
stmt = select(Message).where(Message.conversation_id == conv.id)
result = await db_session.execute(stmt)
messages = list(result.scalars().all())
assert len(messages) >= 1
msg = messages[0]
assert msg.sender_type == "employee"
assert msg.content == "测试消息内容"
@pytest.mark.asyncio
async def test_route_message_urgency_is_int_not_coroutine(self, router, db_session, mock_wecom_service):
"""验证 Bug 1 修复后 urgency_score 是整数而非协程对象。"""
conv = await router.route_message("bug1_test_user", "转人工,很急")
# Bug 1 修复前:urgency_score 会是 coroutine 对象
# 修复后:urgency_score 应该是整数
assert isinstance(conv.urgency_score, int)
assert 1 <= conv.urgency_score <= 5
@pytest.mark.asyncio
async def test_route_message_repeat_count_increments(self, router, db_session, mock_wecom_service):
"""验证追问轮次计数递增。"""
# 第一次消息
conv1 = await router.route_message("repeat_user", "第一条消息")
assert conv1.tags.get("repeat_count") == 1
# 第二次消息(同一会话)
conv2 = await router.route_message("repeat_user", "第二条消息")
assert conv2.tags.get("repeat_count") == 2
@pytest.mark.asyncio
async def test_route_message_updates_last_message_summary(self, router, db_session, mock_wecom_service):
"""验证路由消息时更新最后消息摘要。"""
conv = await router.route_message("summary_user", "VPN连接不上怎么办")
assert conv.last_message_summary == "VPN连接不上怎么办"