新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
379 lines
15 KiB
Python
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 |