ForcePilot/backend/test/unit/channels/test_router.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

488 lines
20 KiB
Python

from __future__ import annotations
import json
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Prevent importing the real yuxi.services.chat_service which triggers
# heavy transitive dependencies (scipy, sklearn) with numpy version conflicts.
_mock_chat_service = MagicMock()
_mock_chat_service.stream_agent_chat = MagicMock()
sys.modules.setdefault("yuxi.services.chat_service", _mock_chat_service)
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
MentionsInfo,
)
from yuxi.channels.policy.dedup_policy import DedupPolicy
from yuxi.channels.policy.group_chat_policy import GroupChatPolicy
from yuxi.channels.policy.schedule_policy import SchedulePolicy
from yuxi.channels.policy.welcome_policy import WelcomePolicy
from yuxi.channels.router import MessageRouter
def _make_identity(channel_id: str = "test", channel_type: ChannelType = ChannelType.WEBCHAT) -> ChannelIdentity:
return ChannelIdentity(
channel_id=channel_id,
channel_type=channel_type,
channel_user_id="user_123",
channel_chat_id="chat_456",
channel_message_id="msg_001",
)
def _make_message(content: str = "hello", identity: ChannelIdentity | None = None) -> ChannelMessage:
if identity is None:
identity = _make_identity()
return ChannelMessage(identity=identity, content=content)
def _make_group_message(content: str = "hello") -> ChannelMessage:
identity = _make_identity()
return ChannelMessage(
identity=identity,
content=content,
chat_type=ChatType.GROUP,
)
class _FakeChannelManager:
def __init__(self):
self.send_calls: list[tuple] = []
self._channels_config: dict = {}
self._adapters: dict = {}
self._stats_collector = None
async def send_outbound(self, channel_id: str, response) -> None:
self.send_calls.append((channel_id, response))
def _router_with_mocks(**overrides):
manager = _FakeChannelManager()
manager._channels_config = {"test": {"enabled": True}, "other": {}}
defaults = {"channel_manager": manager}
defaults.update(overrides)
return MessageRouter(**defaults), manager
def _setup_db_mocks():
"""Create a standard set of mocks for pg_manager and related classes."""
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
mock_pg = MagicMock()
mock_pg.get_async_session_context.return_value = mock_ctx
mock_record = MagicMock()
mock_record.id = 1
mock_repo = MagicMock()
mock_repo.create_record = AsyncMock(return_value=mock_record)
mock_repo.mark_success = AsyncMock()
mock_repo.mark_error = AsyncMock()
mock_sm = MagicMock()
mock_sm.resolve_user = AsyncMock(return_value="user_internal")
mock_sm.resolve_thread = AsyncMock(return_value="thread_1")
mock_sm.reset_thread = AsyncMock(return_value="new_thread")
return {
"mock_pg": mock_pg,
"mock_session": mock_session,
"mock_ctx": mock_ctx,
"mock_record": mock_record,
"mock_repo": mock_repo,
"mock_sm": mock_sm,
}
class TestPolicyChainOrchestration:
@pytest.mark.asyncio
async def test_dedup_short_circuits_before_policies(self):
dedup = DedupPolicy()
router, manager = _router_with_mocks(dedup_policy=dedup)
msg = _make_message("first message")
await router.route_inbound(msg)
dup_msg = _make_message("first message")
dup_msg.identity.channel_message_id = msg.identity.channel_message_id
await router.route_inbound(dup_msg)
assert len(manager.send_calls) == 1
@pytest.mark.asyncio
async def test_context_command_short_circuits(self):
with patch("yuxi.storage.postgres.manager.pg_manager") as mock_pg:
mocks = _setup_db_mocks()
mock_pg.get_async_session_context = mocks["mock_pg"].get_async_session_context
mock_pg.Session = mocks["mock_pg"].Session
mock_sm_cls = MagicMock(return_value=mocks["mock_sm"])
with patch("yuxi.channels.router.SessionMapper", mock_sm_cls):
router, manager = _router_with_mocks()
msg = _make_message("/reset")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "已重置" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_history_command_returns_recent(self):
mocks = _setup_db_mocks()
mock_record = MagicMock()
mock_record.sender_user_id = "user12345678"
mock_record.content_preview = "test question"
mock_record.reply_content_preview = "test answer"
mock_record.status = "success"
mock_record.created_at = MagicMock()
mock_record.created_at.strftime = MagicMock(return_value="12:00")
mocks["mock_repo"].get_recent_records = AsyncMock(return_value=[mock_record])
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])):
router, manager = _router_with_mocks()
msg = _make_message("/history")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "最近对话历史" in manager.send_calls[0][1].content
assert "test question" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_history_command_empty(self):
mocks = _setup_db_mocks()
mocks["mock_repo"].get_recent_records = AsyncMock(return_value=[])
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])):
router, manager = _router_with_mocks()
msg = _make_message("/history")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "暂无对话历史记录" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_history_command_error_handling(self):
mocks = _setup_db_mocks()
mocks["mock_repo"].get_recent_records = AsyncMock(side_effect=RuntimeError("db error"))
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])):
router, manager = _router_with_mocks()
msg = _make_message("/history")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "获取历史记录失败" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_context_command_returns_info(self):
mocks = _setup_db_mocks()
mocks["mock_repo"].get_chat_message_count = AsyncMock(return_value=42)
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])):
router, manager = _router_with_mocks()
msg = _make_message("/context")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "当前对话上下文" in manager.send_calls[0][1].content
assert "thread_1" in manager.send_calls[0][1].content
assert "42" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_summary_command_generates_summary(self):
mocks = _setup_db_mocks()
mocks["mock_repo"].get_chat_message_count = AsyncMock(return_value=5)
router, manager = _router_with_mocks()
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])):
router._resolve_agent_config_id = AsyncMock(return_value=1)
router._invoke_agent = AsyncMock(return_value="\u8fd9\u662f\u5bf9\u8bdd\u7684\u6458\u8981\u5185\u5bb9")
msg = _make_message("/summary")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "\u5bf9\u8bdd\u6458\u8981" in manager.send_calls[0][1].content
assert "\u8fd9\u662f\u5bf9\u8bdd\u7684\u6458\u8981\u5185\u5bb9" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_schedule_blocks_off_hours(self):
class _OffHourSchedule(SchedulePolicy):
def is_working_hours(self, now=None):
return False
router, manager = _router_with_mocks(schedule_policy=_OffHourSchedule())
msg = _make_message("hello")
await router.route_inbound(msg)
assert len(manager.send_calls) == 1
assert "非工作时间" in manager.send_calls[0][1].content
@pytest.mark.asyncio
async def test_group_chat_filters_group_messages(self):
class _AlwaysWorkingSchedule(SchedulePolicy):
def is_working_hours(self, now=None):
return True
group_policy = GroupChatPolicy()
group_policy.configure("mention_only")
router, manager = _router_with_mocks(group_chat_policy=group_policy, schedule_policy=_AlwaysWorkingSchedule())
msg = _make_group_message("hello in group")
await router.route_inbound(msg)
assert len(manager.send_calls) == 0
@pytest.mark.asyncio
async def test_direct_chat_bypasses_group_filter(self):
group_policy = GroupChatPolicy()
group_policy.configure("mention_only")
mocks = _setup_db_mocks()
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])), \
patch("yuxi.services.chat_service.stream_agent_chat", MagicMock(return_value=MagicMock(__aiter__=AsyncMock(return_value=[])))):
router, manager = _router_with_mocks(group_chat_policy=group_policy)
msg = _make_message("hello direct")
await router.route_inbound(msg)
assert len(manager.send_calls) >= 1
class TestAgentInvocation:
@pytest.mark.asyncio
async def test_agent_call_success(self):
class _AlwaysWorkingSchedule(SchedulePolicy):
def is_working_hours(self, now=None):
return True
mocks = _setup_db_mocks()
chunk = json.dumps({"status": "loading", "response": "Hello from agent"}).encode("utf-8")
async def mock_iter():
yield chunk
mock_stream = MagicMock(return_value=mock_iter())
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])), \
patch("yuxi.services.chat_service.stream_agent_chat", mock_stream):
router, manager = _router_with_mocks(schedule_policy=_AlwaysWorkingSchedule())
msg = _make_message("what is AI")
await router.route_inbound(msg)
assert len(manager.send_calls) >= 1
assert "Hello from agent" in manager.send_calls[-1][1].content
@pytest.mark.asyncio
async def test_agent_call_error_handling(self):
class _AlwaysWorkingSchedule(SchedulePolicy):
def is_working_hours(self, now=None):
return True
mocks = _setup_db_mocks()
async def mock_iter():
raise RuntimeError("agent failure")
yield b""
mock_stream = MagicMock(return_value=mock_iter())
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])), \
patch("yuxi.services.chat_service.stream_agent_chat", mock_stream):
router, manager = _router_with_mocks(schedule_policy=_AlwaysWorkingSchedule())
msg = _make_message("test")
await router.route_inbound(msg)
assert len(manager.send_calls) >= 1
assert "出错" in manager.send_calls[-1][1].content
class TestAgentConfigIdResolution:
def _make_router_with_config(self, channel_config: dict):
manager = _FakeChannelManager()
manager._channels_config = {"test": channel_config}
return MessageRouter(channel_manager=manager), manager
@pytest.mark.asyncio
async def test_metadata_priority(self):
config = {"agent_config_id": 5}
router, _ = self._make_router_with_config(config)
msg = _make_message("hello")
msg.metadata["agent_config_id"] = 99
result = await router._resolve_agent_config_id(msg)
assert result == 99
@pytest.mark.asyncio
async def test_command_routing_priority(self):
config = {"command_routing": {"/img": 10}, "agent_config_id": 5}
router, _ = self._make_router_with_config(config)
msg = _make_message("/img please")
result = await router._resolve_agent_config_id(msg)
assert result == 10
@pytest.mark.asyncio
async def test_channel_default_priority(self):
config = {"agent_config_id": 7}
router, _ = self._make_router_with_config(config)
msg = _make_message("hello")
result = await router._resolve_agent_config_id(msg)
assert result == 7
@pytest.mark.asyncio
async def test_global_default_priority(self):
config = {}
router, _ = self._make_router_with_config(config)
fake_conf = MagicMock()
fake_conf.default_agent_id = 3
with patch("yuxi.config", fake_conf):
msg = _make_message("hello")
result = await router._resolve_agent_config_id(msg)
assert result == 3
@pytest.mark.asyncio
async def test_fallback_to_one(self):
config = {}
router, _ = self._make_router_with_config(config)
fake_conf = MagicMock()
fake_conf.default_agent_id = None
with patch("yuxi.config", fake_conf):
msg = _make_message("hello")
result = await router._resolve_agent_config_id(msg)
assert result == 1
class TestRouteOutbound:
async def test_route_outbound_success(self):
from yuxi.channels.models import AgentResult
router, manager = _router_with_mocks()
result = AgentResult(response_text="outbound reply")
identity = _make_identity()
await router.route_outbound(result, "test", identity)
assert len(manager.send_calls) == 1
assert manager.send_calls[0][1].content == "outbound reply"
async def test_route_outbound_with_attachments(self):
from yuxi.channels.models import AgentResult, Attachment
router, manager = _router_with_mocks()
attachment = Attachment(type="file", url="https://example.com/file.pdf")
result = AgentResult(response_text="here you go", attachments=[attachment])
identity = _make_identity()
await router.route_outbound(result, "test", identity)
assert len(manager.send_calls) == 1
assert len(manager.send_calls[0][1].attachments) == 1
class TestWelcomeFlow:
@pytest.mark.asyncio
async def test_first_message_triggers_welcome(self):
class _AlwaysWorkingSchedule(SchedulePolicy):
def is_working_hours(self, now=None):
return True
welcome = WelcomePolicy()
mocks = _setup_db_mocks()
chunk = json.dumps({"status": "loading", "response": "agent reply"}).encode("utf-8")
async def mock_iter():
yield chunk
mock_stream = MagicMock(return_value=mock_iter())
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])), \
patch("yuxi.services.chat_service.stream_agent_chat", mock_stream):
router, manager = _router_with_mocks(welcome_policy=welcome, schedule_policy=_AlwaysWorkingSchedule())
msg = _make_message("hi")
await router.route_inbound(msg)
welcome_contents = [c[1].content for c in manager.send_calls if "AI" in c[1].content]
assert len(welcome_contents) >= 1
@pytest.mark.asyncio
async def test_second_message_no_welcome(self):
welcome = WelcomePolicy()
welcome.mark_welcomed("returning_user")
mocks = _setup_db_mocks()
mocks["mock_sm"].resolve_user = AsyncMock(return_value="returning_user")
chunk = json.dumps({"status": "loading", "response": "agent reply"}).encode("utf-8")
async def mock_iter():
yield chunk
mock_stream = MagicMock(return_value=mock_iter())
with patch("yuxi.storage.postgres.manager.pg_manager", mocks["mock_pg"]), \
patch("yuxi.channels.router.SessionMapper", MagicMock(return_value=mocks["mock_sm"])), \
patch("yuxi.repositories.channel_message_record_repository.ChannelMessageRecordRepository", MagicMock(return_value=mocks["mock_repo"])), \
patch("yuxi.services.chat_service.stream_agent_chat", mock_stream):
router, manager = _router_with_mocks(welcome_policy=welcome)
msg = _make_message("hi again")
await router.route_inbound(msg)
welcome_texts = [c[1].content for c in manager.send_calls if "AI" in c[1].content]
assert len(welcome_texts) == 0
class TestStatsCollection:
@pytest.mark.asyncio
async def test_stats_success_records_metrics(self):
from yuxi.channels.services.stats_collector import StatsCollector
from yuxi.channels.services.runtime_state import RuntimeState
state = RuntimeState()
collector = StatsCollector(state)
collector.record_request()
collector.record_response_time(100.0)
collector.record_response_time(200.0)
assert state.request_count == 1
summary = collector.get_summary()
assert summary["avg_response_time_ms"] == 150.0
@pytest.mark.asyncio
async def test_stats_error_records_metrics(self):
from yuxi.channels.services.stats_collector import StatsCollector
from yuxi.channels.services.runtime_state import RuntimeState
state = RuntimeState()
collector = StatsCollector(state)
collector.record_request()
collector.record_error()
assert state.request_count == 1
assert state.error_count == 1
def test_stats_summary_no_data(self):
from yuxi.channels.services.stats_collector import StatsCollector
from yuxi.channels.services.runtime_state import RuntimeState
state = RuntimeState()
collector = StatsCollector(state)
summary = collector.get_summary()
assert summary["avg_response_time_ms"] == 0.0
assert summary["total_requests"] == 0