ForcePilot/backend/test/unit/channel/middlewares/test_outbound.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

352 lines
12 KiB
Python

"""默认出站中间件单元测试。"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.channel.capabilities import CapabilityMatrix
from yuxi.channel.capabilities.levels import MarkdownSupport
from yuxi.channel.constants import DeliveryStatus, DispatchResult
from yuxi.channel.exceptions import ChannelErrorClassification
from yuxi.channel.middlewares.outbound import (
BuildMessageMiddleware,
ChunkMiddleware,
DowngradeMiddleware,
EnrichMiddleware,
FormatMiddleware,
MediaUploadMiddleware,
SendMiddleware,
StatusUpdateMiddleware,
_chunk_if_needed,
)
from yuxi.channel.middlewares.protocols import OutboundContext, OutboundResult
from yuxi.channel.plugins.protocol import ChannelMeta, DeliveryCapabilities, OutboundMessage
from yuxi.channel.ports import MetaMixin, OutboundMixin
async def _noop_next() -> OutboundResult:
return OutboundResult(status=DispatchResult.SUCCESS)
@pytest.fixture(autouse=True)
def patch_flag_modified(monkeypatch):
monkeypatch.setattr("yuxi.channel.middlewares.outbound.flag_modified", MagicMock())
@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=10,
supports_markdown=False,
supports_interactive=False,
supports_media=False,
)
)
plugin.chunk_text = MagicMock(return_value=["chunk-1", "chunk-2"])
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.send_batch = AsyncMock(return_value=["batch-1", "batch-2"])
plugin.upload_media = AsyncMock(return_value={"media_id": "m-1"})
plugin.classify_error = MagicMock(return_value=(ChannelErrorClassification.RETRYABLE, None))
return plugin
@pytest.fixture
def db_message_mock():
msg = MagicMock()
msg.id = 42
msg.content = "hello world"
msg.message_type = "text"
msg.channel_metadata = {}
return msg
@pytest.fixture
def base_ctx(plugin_mock, db_message_mock):
return OutboundContext(
event={
"channel_type": "text-only",
"account_id": "acc-1",
"session_key": "sk-1",
"conversation_id": 7,
},
message=None,
db_message=db_message_mock,
config={},
config_mw={},
plugin=plugin_mock,
channel_session=None,
conversation=None,
db=MagicMock(),
capabilities=plugin_mock.get_delivery_capabilities(),
)
async def test_build_message_creates_outbound_message(base_ctx):
mw = BuildMessageMiddleware()
called = False
async def next_mw():
nonlocal called
called = True
return OutboundResult(status=DispatchResult.SUCCESS)
result = await mw.process(base_ctx, next_mw)
assert called is True
assert result.status == DispatchResult.SUCCESS
assert base_ctx.message is not None
assert base_ctx.message.content == "hello world"
assert base_ctx.message.content_type == "text"
async def test_build_message_drops_media_when_unsupported(base_ctx, plugin_mock):
plugin_mock.get_meta.return_value = ChannelMeta(
channel_type="text-only",
display_name="Text Only",
capability_matrix=CapabilityMatrix(), # no media support
)
base_ctx.db_message.channel_metadata = {"media": [{"url": "a.jpg"}]}
mw = BuildMessageMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.message.media == []
assert "已省略" in base_ctx.message.content
async def test_build_message_downgrades_interactive_to_markdown(base_ctx, plugin_mock):
plugin_mock.get_meta.return_value = ChannelMeta(
channel_type="markdown",
display_name="Markdown",
capability_matrix=CapabilityMatrix(markdown=MarkdownSupport.BASIC),
)
base_ctx.db_message.message_type = "interactive"
mw = BuildMessageMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.message.content_type == "markdown"
async def test_build_message_downgrades_markdown_to_text(base_ctx, plugin_mock):
base_ctx.db_message.message_type = "markdown"
mw = BuildMessageMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.message.content_type == "text"
async def test_media_upload_caches_uploaded_media(base_ctx, plugin_mock):
base_ctx.message = OutboundMessage(
content="hello",
content_type="text",
media=[{"url": "a.jpg"}],
)
mw = MediaUploadMiddleware()
await mw.process(base_ctx, _noop_next)
plugin_mock.upload_media.assert_awaited_once()
assert "uploaded_media" in base_ctx.db_message.channel_metadata
assert base_ctx.message.media == [{"url": "a.jpg", "media_id": "m-1"}]
async def test_media_upload_skips_when_already_uploaded(base_ctx, plugin_mock):
base_ctx.message = OutboundMessage(
content="hello",
content_type="text",
media=[{"url": "a.jpg"}],
)
base_ctx.db_message.channel_metadata = {"uploaded_media": [{"url": "a.jpg", "media_id": "cached"}]}
mw = MediaUploadMiddleware()
await mw.process(base_ctx, _noop_next)
plugin_mock.upload_media.assert_not_awaited()
assert base_ctx.message.media == [{"url": "a.jpg", "media_id": "cached"}]
async def test_downgrade_calls_downgrader(base_ctx, plugin_mock):
base_ctx.message = OutboundMessage(
content="hello",
content_type="text",
)
mw = DowngradeMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.message is not None
assert base_ctx.message.content_type == "text"
async def test_chunk_splits_long_text(base_ctx, plugin_mock):
base_ctx.message = OutboundMessage(content="a" * 25, content_type="text")
mw = ChunkMiddleware()
await mw.process(base_ctx, _noop_next)
assert len(base_ctx.chunks) == 2
assert base_ctx.sent_ids == [None, None]
assert base_ctx.pending_chunk_indexes == [0, 1]
plugin_mock.chunk_text.assert_called_once_with("a" * 25, 10)
async def test_chunk_recovers_existing_state(base_ctx, plugin_mock):
base_ctx.message = OutboundMessage(content="a" * 25, content_type="text")
base_ctx.db_message.channel_metadata = {
"chunks": [{"index": 0, "status": "success", "id": "id-1"}, None],
"sent_ids": ["id-1", None],
"pending_chunk_indexes": [1],
}
mw = ChunkMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.pending_chunk_indexes == [1]
assert base_ctx.sent_ids == ["id-1", None]
async def test_format_builds_payloads(base_ctx, plugin_mock):
base_ctx.chunks = [
OutboundMessage(content="chunk-1", content_type="text"),
OutboundMessage(content="chunk-2", content_type="text"),
]
mw = FormatMiddleware()
await mw.process(base_ctx, _noop_next)
assert len(base_ctx.payloads) == 2
assert base_ctx.payloads == [{"text": "payload"}, {"text": "payload"}]
async def test_enrich_updates_payloads(base_ctx, plugin_mock):
base_ctx.chunks = [
OutboundMessage(content="chunk-1", content_type="text"),
]
base_ctx.payloads = [{"text": "payload"}]
mw = EnrichMiddleware()
await mw.process(base_ctx, _noop_next)
assert base_ctx.payloads == [{"text": "enriched"}]
async def test_send_single_send_success(base_ctx, plugin_mock):
base_ctx.chunks = [OutboundMessage(content="hi", content_type="text")]
base_ctx.payloads = [{"text": "payload"}]
base_ctx.pending_chunk_indexes = [0]
base_ctx.sent_ids = [None]
base_ctx.chunk_statuses = [None]
mw = SendMiddleware()
await mw.process(base_ctx, _noop_next)
plugin_mock.send_message.assert_awaited_once_with("sk-1", {"text": "payload"}, config={})
assert base_ctx.sent_ids == ["sent-id"]
assert base_ctx.chunk_statuses == [{"index": 0, "status": "success", "id": "sent-id"}]
assert base_ctx.pending_chunk_indexes == []
async def test_send_failure_still_calls_next_mw(base_ctx, plugin_mock):
base_ctx.chunks = [OutboundMessage(content="hi", content_type="text")]
base_ctx.payloads = [{"text": "payload"}]
base_ctx.pending_chunk_indexes = [0]
base_ctx.sent_ids = [None]
base_ctx.chunk_statuses = [None]
plugin_mock.send_message = AsyncMock(side_effect=RuntimeError("boom"))
mw = SendMiddleware()
next_called = False
async def next_mw():
nonlocal next_called
next_called = True
return OutboundResult(status=DispatchResult.SUCCESS)
await mw.process(base_ctx, next_mw)
assert next_called is True
assert base_ctx.dispatch_result == DispatchResult.RETRYABLE
assert base_ctx.chunk_statuses[0]["status"] == "failed"
async def test_send_batch_partial_failure(base_ctx, plugin_mock):
plugin_mock.supports_batch_send.return_value = True
base_ctx.chunks = [
OutboundMessage(content="chunk-1", content_type="text"),
OutboundMessage(content="chunk-2", content_type="text"),
]
base_ctx.payloads = [{"text": "p1"}, {"text": "p2"}]
base_ctx.pending_chunk_indexes = [0, 1]
base_ctx.sent_ids = [None, None]
base_ctx.chunk_statuses = [None, None]
plugin_mock.send_batch.return_value = ["id-1", None]
mw = SendMiddleware()
await mw.process(base_ctx, _noop_next)
plugin_mock.send_batch.assert_awaited_once_with("sk-1", [{"text": "p1"}, {"text": "p2"}], config={})
assert base_ctx.dispatch_result == DispatchResult.RETRYABLE
assert base_ctx.sent_ids == ["id-1", None]
assert base_ctx.pending_chunk_indexes == [1]
async def test_status_update_calls_callback_and_returns_result(base_ctx, db_message_mock):
base_ctx.sent_ids = ["id-1"]
base_ctx.chunk_statuses = [{"index": 0, "status": "success", "id": "id-1"}]
base_ctx.pending_chunk_indexes = []
callback_called = False
async def update_status(ctx, status):
nonlocal callback_called
callback_called = True
assert status == DeliveryStatus.COMPLETE
mw = StatusUpdateMiddleware(update_status=update_status)
result = await mw.process(base_ctx, _noop_next)
assert callback_called is True
assert result.status == DispatchResult.SUCCESS
assert db_message_mock.channel_metadata["sent_ids"] == ["id-1"]
assert db_message_mock.channel_metadata["pending_chunk_indexes"] == []
async def test_status_update_partial_failure(base_ctx):
base_ctx.sent_ids = ["id-1", None]
base_ctx.chunk_statuses = [
{"index": 0, "status": "success", "id": "id-1"},
{"index": 1, "status": "failed"},
]
base_ctx.pending_chunk_indexes = []
mw = StatusUpdateMiddleware()
result = await mw.process(base_ctx, _noop_next)
assert result.status == DispatchResult.PERMANENT_FAILURE
async def test_chunk_if_needed_non_text_unchanged():
msg = OutboundMessage(content="x", content_type="image")
result = _chunk_if_needed(msg, DeliveryCapabilities(max_text_length=1), lambda _text, _limit: [msg])
assert result == [msg]
async def test_chunk_if_needed_under_limit():
msg = OutboundMessage(content="short", content_type="text")
result = _chunk_if_needed(msg, DeliveryCapabilities(max_text_length=100), lambda _text, _limit: [msg])
assert result == [msg]
async def test_chunk_if_needed_splits():
msg = OutboundMessage(content="a" * 25, content_type="text")
def chunk_func(text, limit):
return [text[i : i + limit] for i in range(0, len(text), limit)]
result = _chunk_if_needed(msg, DeliveryCapabilities(max_text_length=10), chunk_func)
assert len(result) == 3
assert result[0].content_type == "text"