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