200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
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"
|