ForcePilot/backend/test/unit/channels/test_router.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

379 lines
15 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 = {}
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_short_circuits(self):
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_short_circuits_basic(self):
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
@pytest.mark.asyncio
async def test_summary_command_short_circuits(self):
router, manager = _router_with_mocks()
msg = _make_message("/summary")
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_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
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
assert router._resolve_agent_config_id(msg) == 99
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")
assert router._resolve_agent_config_id(msg) == 10
def test_channel_default_priority(self):
config = {"agent_config_id": 7}
router, _ = self._make_router_with_config(config)
msg = _make_message("hello")
assert router._resolve_agent_config_id(msg) == 7
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")
assert router._resolve_agent_config_id(msg) == 3
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")
assert router._resolve_agent_config_id(msg) == 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