"""默认出站中间件单元测试。""" 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"