ForcePilot/backend/test/unit/channel/outbound/test_dispatcher_capabilities.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

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"