from __future__ import annotations from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channel.capabilities import CapabilityMatrix from yuxi.channel.capabilities.levels import MarkdownSupport from yuxi.channel.constants import DeliveryStatus from yuxi.channel.outbound.dispatcher import OutboundDispatcher from yuxi.channel.plugins.protocol import ChannelMeta, DeliveryCapabilities, OutboundMessage from yuxi.channel.ports import MetaMixin, OutboundMixin @pytest.fixture def plugin_mock(): meta = ChannelMeta( channel_type="text-only", display_name="Text Only", capability_matrix=CapabilityMatrix(), # text only by default ) plugin = type("FakePlugin", (MetaMixin, OutboundMixin), {})(meta) plugin.get_meta = MagicMock(return_value=meta) plugin.get_delivery_capabilities = MagicMock( return_value=DeliveryCapabilities( max_text_length=1000, supports_markdown=False, supports_interactive=False, supports_media=False, ) ) plugin.chunk_text = MagicMock(return_value=["chunk"]) plugin.supports_batch_send = MagicMock(return_value=False) plugin.format_outbound = AsyncMock(return_value={"text": "payload"}) plugin.enrich_outbound = AsyncMock(return_value={"text": "enriched"}) plugin.send_message = AsyncMock(return_value="sent-id") plugin.upload_media = AsyncMock(return_value={"media_id": "m-1"}) plugin.classify_error = MagicMock(return_value=("retryable", None)) return plugin @pytest.fixture def registry_mock(plugin_mock): registry = MagicMock() registry.get_plugin.return_value = plugin_mock return registry @pytest.fixture def config_manager_mock(): cm = MagicMock() cm.get_config = AsyncMock(return_value={"account_id": "acc-1"}) return cm @pytest.fixture def dispatcher(registry_mock, config_manager_mock): return OutboundDispatcher(registry=registry_mock, config_manager=config_manager_mock) @pytest.fixture def message_mock(): msg = MagicMock() msg.id = 42 msg.content = "hello" msg.message_type = "text" msg.delivery_status = DeliveryStatus.PENDING msg.channel_metadata = {} return msg @pytest.fixture def conversation_mock(): conv = MagicMock() conv.id = 7 return conv @pytest.fixture def session_mock(): return MagicMock(commit=AsyncMock(), execute=AsyncMock()) @pytest.fixture(autouse=True) def pg_manager_mock(monkeypatch, session_mock): @asynccontextmanager async def ctx(): yield session_mock mock = MagicMock() mock.get_async_session_context = ctx monkeypatch.setattr("yuxi.channel.outbound.dispatcher.pg_manager", mock) return mock @pytest.fixture(autouse=True) def patch_flag_modified(monkeypatch): monkeypatch.setattr("yuxi.channel.outbound.dispatcher.flag_modified", MagicMock()) async def test_do_dispatch_drops_media_when_unsupported( dispatcher, plugin_mock, message_mock, conversation_mock, session_mock ): message_mock.channel_metadata = {"media": [{"url": "a.jpg"}]} message_mock.message_type = "text" conv_repo = MagicMock() conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock) session_mock.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock()))) with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo): await dispatcher._do_dispatch( {"channel_type": "text-only", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"}, message_mock, session_mock, ) plugin_mock.upload_media.assert_not_awaited() formatted = plugin_mock.format_outbound.await_args[1]["message"] assert isinstance(formatted, OutboundMessage) assert formatted.media == [] assert "已省略" in formatted.content async def test_do_dispatch_downgrades_interactive_to_markdown( dispatcher, plugin_mock, message_mock, conversation_mock, session_mock ): plugin_mock.get_meta.return_value = ChannelMeta( channel_type="markdown", display_name="Markdown", capability_matrix=CapabilityMatrix(markdown=MarkdownSupport.BASIC), ) plugin_mock.get_delivery_capabilities.return_value = DeliveryCapabilities( max_text_length=1000, supports_markdown=True, supports_interactive=False, supports_media=False, ) message_mock.message_type = "interactive" message_mock.content = "interactive content" conv_repo = MagicMock() conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock) session_mock.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock()))) with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo): await dispatcher._do_dispatch( {"channel_type": "markdown", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"}, message_mock, session_mock, ) formatted = plugin_mock.format_outbound.await_args[1]["message"] assert isinstance(formatted, OutboundMessage) assert formatted.content_type == "markdown" async def test_do_dispatch_downgrades_interactive_to_text_when_markdown_unsupported( dispatcher, plugin_mock, message_mock, conversation_mock, session_mock ): message_mock.message_type = "interactive" message_mock.content = "interactive content" conv_repo = MagicMock() conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock) session_mock.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock()))) with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo): await dispatcher._do_dispatch( {"channel_type": "text-only", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"}, message_mock, session_mock, ) formatted = plugin_mock.format_outbound.await_args[1]["message"] assert isinstance(formatted, OutboundMessage) assert formatted.content_type == "text" async def test_do_dispatch_downgrades_markdown_to_text_when_unsupported( dispatcher, plugin_mock, message_mock, conversation_mock, session_mock ): message_mock.message_type = "markdown" message_mock.content = "# title" conv_repo = MagicMock() conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock) session_mock.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock()))) with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo): await dispatcher._do_dispatch( {"channel_type": "text-only", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"}, message_mock, session_mock, ) formatted = plugin_mock.format_outbound.await_args[1]["message"] assert isinstance(formatted, OutboundMessage) assert formatted.content_type == "text"