Files
wecom_it_smart_desk/backend/tests/test_message_dedup.py
T

544 lines
21 KiB
Python
Raw 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. MsgId 重复消息被过滤
# 2. 相同用户 + 内容重复被过滤
# 3. 不同消息正常通过
# 4. TTL 过期后消息可正常处理
# 5. Redis 不可用时降级放行
# 6. CacheService 独立方法测试
# 7. MessageRouter 集成去重测试
# =============================================================================
import asyncio
import hashlib
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
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.cache_service import (
CacheService,
MSG_DEDUP_PREFIX,
CONTENT_DEDUP_PREFIX,
DEFAULT_MSG_DEDUP_TTL,
DEFAULT_CONTENT_DEDUP_TTL,
)
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 MockRedis, create_test_conversation
# =============================================================================
# Fixtures
# =============================================================================
@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.fixture
def mock_wecom_service():
return _create_mock_wecom_service()
@pytest.fixture
def mock_redis_client():
"""提供干净的 MockRedis 实例。"""
return MockRedis()
@pytest.fixture
def cache_service(mock_redis_client):
"""提供带 MockRedis 的 CacheService。"""
return CacheService(mock_redis_client)
@pytest.fixture
def cache_service_no_redis():
"""提供无 Redis 的 CacheService(降级模式)。"""
return CacheService(None)
@pytest.fixture
def router_with_dedup(db_session, mock_wecom_service, mock_redis_client, setup_router_db):
"""创建带去重功能的消息路由器。"""
scoring_service = ScoringService(db_session)
cache_service = CacheService(mock_redis_client)
return MessageRouter(
db=db_session,
wecom_service=mock_wecom_service,
scoring_service=scoring_service,
cache_service=cache_service,
)
@pytest.fixture
def router_no_dedup(db_session, mock_wecom_service, setup_router_db):
"""创建无去重功能的消息路由器(cache_service=None)。"""
scoring_service = ScoringService(db_session)
return MessageRouter(
db=db_session,
wecom_service=mock_wecom_service,
scoring_service=scoring_service,
cache_service=None,
)
# =============================================================================
# CacheService 独立测试
# =============================================================================
class TestCacheServiceIsDuplicate:
"""测试 CacheService.is_duplicate() 方法。"""
@pytest.mark.asyncio
async def test_first_message_not_duplicate(self, cache_service, mock_redis_client):
"""首次消息不应被判定为重复。"""
result = await cache_service.is_duplicate("msg_001")
assert result is False
@pytest.mark.asyncio
async def test_same_msg_id_is_duplicate(self, cache_service, mock_redis_client):
"""相同 MsgId 的第二次调用应被判定为重复。"""
# 第一次:非重复
result1 = await cache_service.is_duplicate("msg_002")
assert result1 is False
# 第二次:重复
result2 = await cache_service.is_duplicate("msg_002")
assert result2 is True
@pytest.mark.asyncio
async def test_different_msg_id_not_duplicate(self, cache_service, mock_redis_client):
"""不同 MsgId 不应互相影响。"""
result1 = await cache_service.is_duplicate("msg_003")
result2 = await cache_service.is_duplicate("msg_004")
assert result1 is False
assert result2 is False
@pytest.mark.asyncio
async def test_empty_msg_id_not_duplicate(self, cache_service):
"""空 MsgId 应放行(不判断为重复)。"""
result = await cache_service.is_duplicate("")
assert result is False
@pytest.mark.asyncio
async def test_no_redis_graceful_degradation(self, cache_service_no_redis):
"""Redis 不可用时应降级放行(返回 False)。"""
result = await cache_service_no_redis.is_duplicate("msg_005")
assert result is False
@pytest.mark.asyncio
async def test_redis_key_format(self, cache_service, mock_redis_client):
"""验证 Redis key 格式为 msg:dedup:{msg_id}"""
await cache_service.is_duplicate("msg_006")
expected_key = f"{MSG_DEDUP_PREFIX}:msg_006"
assert expected_key in mock_redis_client._data
@pytest.mark.asyncio
async def test_redis_key_ttl(self, cache_service, mock_redis_client):
"""验证 Redis key 设置了正确的 TTL。"""
await cache_service.is_duplicate("msg_007")
expected_key = f"{MSG_DEDUP_PREFIX}:msg_007"
assert mock_redis_client._ttl.get(expected_key) == DEFAULT_MSG_DEDUP_TTL
@pytest.mark.asyncio
async def test_custom_ttl(self, cache_service, mock_redis_client):
"""验证自定义 TTL 生效。"""
custom_ttl = 600
await cache_service.is_duplicate("msg_008", ttl=custom_ttl)
expected_key = f"{MSG_DEDUP_PREFIX}:msg_008"
assert mock_redis_client._ttl.get(expected_key) == custom_ttl
class TestCacheServiceIsDuplicateContent:
"""测试 CacheService.is_duplicate_content() 方法。"""
@pytest.mark.asyncio
async def test_first_message_not_duplicate(self, cache_service):
"""首次消息不应被判定为内容重复。"""
result = await cache_service.is_duplicate_content("user_001", "帮我重置密码")
assert result is False
@pytest.mark.asyncio
async def test_same_user_same_content_is_duplicate(self, cache_service):
"""相同用户发送相同内容应被判定为重复。"""
# 第一次:非重复
result1 = await cache_service.is_duplicate_content("user_002", "VPN连不上")
assert result1 is False
# 第二次:重复
result2 = await cache_service.is_duplicate_content("user_002", "VPN连不上")
assert result2 is True
@pytest.mark.asyncio
async def test_same_user_different_content_not_duplicate(self, cache_service):
"""相同用户发送不同内容不应被判定为重复。"""
result1 = await cache_service.is_duplicate_content("user_003", "重置密码")
result2 = await cache_service.is_duplicate_content("user_003", "安装软件")
assert result1 is False
assert result2 is False
@pytest.mark.asyncio
async def test_different_user_same_content_not_duplicate(self, cache_service):
"""不同用户发送相同内容不应被判定为重复。"""
result1 = await cache_service.is_duplicate_content("user_004", "VPN连不上")
result2 = await cache_service.is_duplicate_content("user_005", "VPN连不上")
assert result1 is False
assert result2 is False
@pytest.mark.asyncio
async def test_empty_user_id_not_duplicate(self, cache_service):
"""空 user_id 应放行。"""
result = await cache_service.is_duplicate_content("", "帮我重置密码")
assert result is False
@pytest.mark.asyncio
async def test_empty_content_not_duplicate(self, cache_service):
"""空 content 应放行。"""
result = await cache_service.is_duplicate_content("user_006", "")
assert result is False
@pytest.mark.asyncio
async def test_no_redis_graceful_degradation(self, cache_service_no_redis):
"""Redis 不可用时应降级放行。"""
result = await cache_service_no_redis.is_duplicate_content("user_007", "VPN连不上")
assert result is False
@pytest.mark.asyncio
async def test_redis_key_format(self, cache_service, mock_redis_client):
"""验证 Redis key 包含用户ID和内容哈希。"""
await cache_service.is_duplicate_content("user_008", "帮我重置密码")
content_hash = hashlib.sha256("user_008:帮我重置密码".encode("utf-8")).hexdigest()[:16]
expected_key = f"{CONTENT_DEDUP_PREFIX}:user_008:{content_hash}"
assert expected_key in mock_redis_client._data
@pytest.mark.asyncio
async def test_content_dedup_ttl(self, cache_service, mock_redis_client):
"""验证内容去重的 TTL 默认为 60 秒。"""
await cache_service.is_duplicate_content("user_009", "VPN连不上")
content_hash = hashlib.sha256("user_009:VPN连不上".encode("utf-8")).hexdigest()[:16]
expected_key = f"{CONTENT_DEDUP_PREFIX}:user_009:{content_hash}"
assert mock_redis_client._ttl.get(expected_key) == DEFAULT_CONTENT_DEDUP_TTL
# =============================================================================
# TTL 过期测试
# =============================================================================
class TestTTLExpiry:
"""测试 TTL 过期后消息可正常处理。"""
@pytest.mark.asyncio
async def test_msg_id_dedup_key_expires(self, cache_service, mock_redis_client):
"""验证 MsgId 去重 key 可通过手动删除模拟过期后放行。"""
msg_id = "msg_expire_001"
# 首次:非重复
result1 = await cache_service.is_duplicate(msg_id)
assert result1 is False
# 重复
result2 = await cache_service.is_duplicate(msg_id)
assert result2 is True
# 模拟 TTL 过期:手动删除 key
key = f"{MSG_DEDUP_PREFIX}:{msg_id}"
await mock_redis_client.delete(key)
# 过期后:非重复
result3 = await cache_service.is_duplicate(msg_id)
assert result3 is False
@pytest.mark.asyncio
async def test_content_dedup_key_expires(self, cache_service, mock_redis_client):
"""验证内容去重 key 过期后放行。"""
user_id = "user_expire_001"
content = "VPN连不上"
# 首次:非重复
result1 = await cache_service.is_duplicate_content(user_id, content)
assert result1 is False
# 重复
result2 = await cache_service.is_duplicate_content(user_id, content)
assert result2 is True
# 模拟 TTL 过期
content_hash = hashlib.sha256(f"{user_id}:{content}".encode("utf-8")).hexdigest()[:16]
key = f"{CONTENT_DEDUP_PREFIX}:{user_id}:{content_hash}"
await mock_redis_client.delete(key)
# 过期后:非重复
result3 = await cache_service.is_duplicate_content(user_id, content)
assert result3 is False
# =============================================================================
# MessageRouter 集成去重测试
# =============================================================================
class TestMessageRouterDedup:
"""测试 MessageRouter 集成去重功能。"""
@pytest.mark.asyncio
async def test_duplicate_msg_id_returns_none(self, router_with_dedup, mock_redis_client):
"""相同 MsgId 的重复消息应返回 None(被过滤)。"""
# 首次消息正常处理
result1 = await router_with_dedup.route_message(
from_user_id="dedup_user_001",
content="帮我重置密码",
msg_id="msg_dedup_001",
)
assert result1 is not None
# 相同 MsgId 再次调用,应被去重过滤
result2 = await router_with_dedup.route_message(
from_user_id="dedup_user_001",
content="帮我重置密码",
msg_id="msg_dedup_001",
)
assert result2 is None
@pytest.mark.asyncio
async def test_duplicate_content_returns_none(self, router_with_dedup, mock_redis_client):
"""相同用户发送相同内容(不同 MsgId)应在 60 秒内被过滤。"""
# 首次消息正常处理
result1 = await router_with_dedup.route_message(
from_user_id="dedup_user_002",
content="VPN连不上",
msg_id="msg_dedup_002a",
)
assert result1 is not None
# 不同 MsgId 但相同用户+内容,应被内容去重过滤
result2 = await router_with_dedup.route_message(
from_user_id="dedup_user_002",
content="VPN连不上",
msg_id="msg_dedup_002b",
)
assert result2 is None
@pytest.mark.asyncio
async def test_different_messages_pass_through(self, router_with_dedup, mock_redis_client):
"""不同消息应正常通过。"""
result1 = await router_with_dedup.route_message(
from_user_id="normal_user_001",
content="帮我重置密码",
msg_id="msg_normal_001",
)
result2 = await router_with_dedup.route_message(
from_user_id="normal_user_001",
content="安装Office",
msg_id="msg_normal_002",
)
assert result1 is not None
assert result2 is not None
@pytest.mark.asyncio
async def test_no_cache_service_skips_dedup(self, router_no_dedup):
"""cache_service=None 时跳过去重检查,所有消息正常处理。"""
# 两次相同 MsgId,但无去重 → 都正常处理
result1 = await router_no_dedup.route_message(
from_user_id="no_dedup_user",
content="帮我重置密码",
msg_id="msg_no_dedup_001",
)
result2 = await router_no_dedup.route_message(
from_user_id="no_dedup_user",
content="帮我重置密码",
msg_id="msg_no_dedup_001",
)
assert result1 is not None
assert result2 is not None
@pytest.mark.asyncio
async def test_none_msg_id_skips_msg_id_dedup(self, router_with_dedup):
"""msg_id=None 时跳过 MsgId 去重,但仍检查内容去重。"""
# 第一次:无 msg_id,正常处理
result1 = await router_with_dedup.route_message(
from_user_id="no_msgid_user",
content="WiFi连不上",
msg_id=None,
)
assert result1 is not None
# 第二次:无 msg_id,相同用户+内容 → 内容去重命中
result2 = await router_with_dedup.route_message(
from_user_id="no_msgid_user",
content="WiFi连不上",
msg_id=None,
)
assert result2 is None
@pytest.mark.asyncio
async def test_different_users_same_content_passes(self, router_with_dedup):
"""不同用户发送相同内容应正常通过(内容去重是用户维度的)。"""
result1 = await router_with_dedup.route_message(
from_user_id="user_a",
content="帮我重置密码",
msg_id="msg_user_a_001",
)
result2 = await router_with_dedup.route_message(
from_user_id="user_b",
content="帮我重置密码",
msg_id="msg_user_b_001",
)
assert result1 is not None
assert result2 is not None
@pytest.mark.asyncio
async def test_dedup_expired_allows_reprocessing(
self, router_with_dedup, mock_redis_client
):
"""TTL 过期后,相同消息可重新处理。"""
# 首次:正常处理
result1 = await router_with_dedup.route_message(
from_user_id="expire_user",
content="帮我重置密码",
msg_id="msg_expire_001",
)
assert result1 is not None
# 重复:被过滤
result2 = await router_with_dedup.route_message(
from_user_id="expire_user",
content="帮我重置密码",
msg_id="msg_expire_001",
)
assert result2 is None
# 模拟 TTL 过期:删除 Redis key
key = f"{MSG_DEDUP_PREFIX}:msg_expire_001"
await mock_redis_client.delete(key)
content_hash = hashlib.sha256("expire_user:帮我重置密码".encode("utf-8")).hexdigest()[:16]
content_key = f"{CONTENT_DEDUP_PREFIX}:expire_user:{content_hash}"
await mock_redis_client.delete(content_key)
# 过期后:可重新处理
result3 = await router_with_dedup.route_message(
from_user_id="expire_user",
content="帮我重置密码",
msg_id="msg_expire_001",
)
assert result3 is not None
@pytest.mark.asyncio
async def test_non_text_message_dedup(self, router_with_dedup, mock_redis_client):
"""非文本消息也应当经过去重检查。"""
# 首次:正常处理
result1 = await router_with_dedup.route_message(
from_user_id="nontext_user",
content="",
msg_type="image",
msg_id="msg_nontext_001",
media_id="media_123",
)
assert result1 is not None
# 重复:被过滤
result2 = await router_with_dedup.route_message(
from_user_id="nontext_user",
content="",
msg_type="image",
msg_id="msg_nontext_001",
media_id="media_123",
)
assert result2 is None
# =============================================================================
# CacheService 通用缓存测试
# =============================================================================
class TestCacheServiceGeneral:
"""测试 CacheService 通用缓存操作。"""
@pytest.mark.asyncio
async def test_get_existing_key(self, cache_service, mock_redis_client):
"""获取已存在的 key。"""
await cache_service.set("test_key", "test_value")
result = await cache_service.get("test_key")
assert result == "test_value"
@pytest.mark.asyncio
async def test_get_nonexistent_key(self, cache_service):
"""获取不存在的 key 返回 None。"""
result = await cache_service.get("nonexistent_key")
assert result is None
@pytest.mark.asyncio
async def test_set_with_ttl(self, cache_service, mock_redis_client):
"""设置带 TTL 的缓存。"""
result = await cache_service.set("ttl_key", "ttl_value", ttl=3600)
assert result is True
assert mock_redis_client._data.get("ttl_key") == "ttl_value"
assert mock_redis_client._ttl.get("ttl_key") == 3600
@pytest.mark.asyncio
async def test_set_without_ttl(self, cache_service, mock_redis_client):
"""设置不带 TTL 的缓存。"""
result = await cache_service.set("no_ttl_key", "no_ttl_value")
assert result is True
@pytest.mark.asyncio
async def test_delete_existing_key(self, cache_service, mock_redis_client):
"""删除已存在的 key。"""
await cache_service.set("delete_key", "delete_value")
result = await cache_service.delete("delete_key")
assert result is True
assert "delete_key" not in mock_redis_client._data
@pytest.mark.asyncio
async def test_delete_nonexistent_key(self, cache_service):
"""删除不存在的 key 返回 TrueRedis DELETE 语义)。"""
result = await cache_service.delete("nonexistent_delete")
assert result is True
@pytest.mark.asyncio
async def test_no_redis_operations_return_defaults(self, cache_service_no_redis):
"""Redis 不可用时通用操作返回默认值。"""
assert await cache_service_no_redis.get("any_key") is None
assert await cache_service_no_redis.set("any_key", "any_value") is False
assert await cache_service_no_redis.delete("any_key") is False