352 lines
12 KiB
Python
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"
|