ForcePilot/backend/test/unit/channel/outbound/test_dispatcher.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

635 lines
27 KiB
Python

from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from redis.exceptions import ResponseError
from yuxi.channel.capabilities import CapabilityMatrix
from yuxi.channel.capabilities.levels import MediaSupport
from yuxi.channel.constants import (
CHANNEL_CONSUMER_GROUP,
CHANNEL_DELIVERED_TTL_SECONDS,
CHANNEL_MAX_DELIVERY_ATTEMPTS,
CHANNEL_STREAM_KEY,
DeliveryStatus,
DispatchResult,
)
from yuxi.channel.exceptions import (
ChannelErrorClassification,
ChannelPermanentError,
ChannelRateLimitedError,
ChannelRetryableError,
)
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 redis_mock():
return MagicMock(
xgroup_create=AsyncMock(),
xreadgroup=AsyncMock(return_value=[]),
xautoclaim=AsyncMock(return_value=["0-0", []]),
xack=AsyncMock(),
exists=AsyncMock(return_value=0),
get=AsyncMock(return_value=None),
set=AsyncMock(return_value=True),
setex=AsyncMock(),
delete=AsyncMock(),
expire=AsyncMock(),
)
@pytest.fixture
def plugin_mock():
meta = ChannelMeta(
channel_type="feishu",
display_name="Feishu",
capability_matrix=CapabilityMatrix(),
)
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 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={"channel_type": "feishu", "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 world"
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_start_creates_consumer_group_and_task(dispatcher, redis_mock):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch.object(OutboundDispatcher, "_consume_loop", new_callable=AsyncMock):
await dispatcher.start()
redis_mock.xgroup_create.assert_awaited_once_with(CHANNEL_STREAM_KEY, CHANNEL_CONSUMER_GROUP, id="0", mkstream=True)
assert dispatcher._task is not None
dispatcher._task.cancel()
try:
await dispatcher._task
except asyncio.CancelledError:
pass
async def test_start_ignores_existing_consumer_group(dispatcher, redis_mock):
redis_mock.xgroup_create.side_effect = ResponseError("BUSYGROUP Consumer Group name already exists")
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch.object(OutboundDispatcher, "_consume_loop", new_callable=AsyncMock):
await dispatcher.start()
redis_mock.xgroup_create.assert_awaited_once()
async def test_start_raises_unexpected_response_error(dispatcher, redis_mock):
redis_mock.xgroup_create.side_effect = ResponseError("something else")
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with pytest.raises(ResponseError):
await dispatcher.start()
async def test_stop_cancels_task(dispatcher):
task = asyncio.create_task(asyncio.sleep(10))
dispatcher._task = task
await dispatcher.stop()
assert task.cancelled()
assert dispatcher._task is None
async def test_consume_loop_claims_pending_and_new(dispatcher, redis_mock):
redis_mock.xreadgroup.side_effect = [
[(CHANNEL_STREAM_KEY, [("1-0", {"payload": "{}"})])],
[(CHANNEL_STREAM_KEY, [("2-0", {"payload": "{}"})])],
asyncio.CancelledError(),
]
dispatcher._handle_event = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch.object(dispatcher, "_claim_stale_pending", AsyncMock()):
await dispatcher._consume_loop()
assert dispatcher._handle_event.await_count == 2
async def test_consume_loop_catches_exception_and_retries(dispatcher, redis_mock):
redis_mock.xreadgroup.side_effect = [RuntimeError("boom"), asyncio.CancelledError()]
dispatcher._handle_event = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch.object(dispatcher, "_claim_stale_pending", AsyncMock()):
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await dispatcher._consume_loop()
mock_sleep.assert_awaited_once_with(1)
async def test_claim_stale_pending_handles_entries(dispatcher, redis_mock):
redis_mock.xautoclaim.return_value = [
"0-0",
[("3-0", {"payload": json.dumps({"message_id": 1})})],
]
dispatcher._handle_event = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._claim_stale_pending(redis_mock)
dispatcher._handle_event.assert_awaited_once_with("3-0", {"payload": json.dumps({"message_id": 1})})
async def test_claim_stale_pending_catches_response_error(dispatcher, redis_mock):
redis_mock.xautoclaim.side_effect = ResponseError("err")
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._claim_stale_pending(redis_mock)
async def test_handle_event_malformed_payload_acks(dispatcher, redis_mock):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._handle_event("1-0", {"payload": "not-json"})
redis_mock.xack.assert_awaited_once_with(CHANNEL_STREAM_KEY, CHANNEL_CONSUMER_GROUP, "1-0")
async def test_handle_event_missing_message_id_acks(dispatcher, redis_mock):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._handle_event("1-0", {"payload": json.dumps({})})
redis_mock.xack.assert_awaited_once()
async def test_handle_event_already_delivered_acks(dispatcher, redis_mock):
redis_mock.exists.return_value = 1
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._handle_event("1-0", {"payload": json.dumps({"message_id": 42})})
redis_mock.xack.assert_awaited_once()
async def test_handle_event_deferred_when_before_retry_at(dispatcher, redis_mock):
future = datetime.now(UTC) + timedelta(hours=1)
message_mock = MagicMock()
message_mock.channel_metadata = {"next_retry_at": future.isoformat()}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
redis_mock.exists.return_value = 0
redis_mock.set.return_value = True
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
await dispatcher._handle_event("1-0", {"payload": json.dumps({"message_id": 42})})
redis_mock.xack.assert_not_awaited()
async def test_handle_event_lock_reclaim_and_process(dispatcher, redis_mock):
lock_json = json.dumps({"owner": OutboundDispatcher.CONSUMER_NAME, "entry_id": "0-0"})
redis_mock.get.return_value = lock_json
redis_mock.set.side_effect = [None, True]
dispatcher._process_event = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._handle_event("1-0", {"payload": json.dumps({"message_id": 42})})
redis_mock.get.assert_awaited_once()
dispatcher._process_event.assert_awaited_once()
async def test_handle_event_skip_when_lock_held_elsewhere(dispatcher, redis_mock):
redis_mock.get.return_value = json.dumps({"owner": "other"})
redis_mock.set.return_value = None
dispatcher._process_event = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._handle_event("1-0", {"payload": json.dumps({"message_id": 42})})
dispatcher._process_event.assert_not_awaited()
async def test_try_reclaim_lock_current_owner(dispatcher, redis_mock):
lock_json = json.dumps({"owner": OutboundDispatcher.CONSUMER_NAME})
redis_mock.get.return_value = lock_json
result = await dispatcher._try_reclaim_lock(redis_mock, "lock", "new-value")
assert result is True
redis_mock.set.assert_awaited_once_with("lock", "new-value", xx=True, ex=dispatcher.PROCESSING_LOCK_TTL)
async def test_try_reclaim_lock_no_current(dispatcher, redis_mock):
redis_mock.get.return_value = None
result = await dispatcher._try_reclaim_lock(redis_mock, "lock", "new-value")
assert result is True
redis_mock.set.assert_awaited_once_with("lock", "new-value", nx=True, ex=dispatcher.PROCESSING_LOCK_TTL)
async def test_try_reclaim_lock_other_owner(dispatcher, redis_mock):
redis_mock.get.return_value = json.dumps({"owner": "other"})
result = await dispatcher._try_reclaim_lock(redis_mock, "lock", "new-value")
assert result is False
async def test_renew_lock_extends_ttl_until_stopped(dispatcher, redis_mock):
stop_event = MagicMock()
stop_event.is_set = MagicMock(side_effect=[False, False, True])
stop_event.wait = AsyncMock(side_effect=[TimeoutError, TimeoutError])
with patch("yuxi.channel.outbound.dispatcher.CHANNEL_PROCESSING_LOCK_RENEW_INTERVAL_SECONDS", 0):
await dispatcher._renew_lock(redis_mock, "lock", stop_event)
assert redis_mock.expire.await_count == 2
async def test_process_event_message_not_found_acks(dispatcher, redis_mock, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=None)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
redis_mock.xack.assert_awaited_once_with(CHANNEL_STREAM_KEY, CHANNEL_CONSUMER_GROUP, "1-0")
redis_mock.delete.assert_awaited_once_with("processing-key")
async def test_process_event_success(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
dispatcher._do_dispatch = AsyncMock(return_value=(DispatchResult.SUCCESS, None))
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
assert message_mock.channel_metadata["attempts"] == 1
redis_mock.setex.assert_awaited_once_with("delivered-key", CHANNEL_DELIVERED_TTL_SECONDS, "1")
redis_mock.xack.assert_awaited_once()
redis_mock.delete.assert_awaited_once_with("processing-key")
async def test_process_event_max_attempts(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_mock.channel_metadata = {"attempts": CHANNEL_MAX_DELIVERY_ATTEMPTS}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
dispatcher._finalize_failure = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
dispatcher._finalize_failure.assert_awaited_once()
async def test_process_event_permanent_error(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
dispatcher._do_dispatch = AsyncMock(side_effect=ChannelPermanentError("perm"))
dispatcher._finalize_failure = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
dispatcher._finalize_failure.assert_awaited_once()
async def test_process_event_rate_limited_error(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
dispatcher._do_dispatch = AsyncMock(side_effect=ChannelRateLimitedError("rate", retry_after=30))
dispatcher._defer_retry = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
dispatcher._defer_retry.assert_awaited_once_with(42, retry_after_seconds=30)
async def test_process_event_retryable_error(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
dispatcher._do_dispatch = AsyncMock(side_effect=ChannelRetryableError("retry"))
dispatcher._defer_retry = AsyncMock()
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._process_event(
"1-0", {"message_id": 42}, 42, redis_mock, "delivered-key", "processing-key"
)
dispatcher._defer_retry.assert_awaited_once()
async def test_finalize_failure_partial_failed(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_mock.channel_metadata = {
"chunks": [
{"index": 0, "status": "success"},
{"index": 1, "status": "failed"},
]
}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._finalize_failure("1-0", 42, redis_mock, "delivered-key", "processing-key")
assert message_mock.delivery_status == DeliveryStatus.PARTIAL_FAILED
redis_mock.setex.assert_awaited_once()
redis_mock.xack.assert_awaited_once()
async def test_finalize_failure_all_failed(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_mock.channel_metadata = {"chunks": [{"index": 0, "status": "failed"}]}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._finalize_failure("1-0", 42, redis_mock, "delivered-key", "processing-key")
assert message_mock.delivery_status == DeliveryStatus.FAILED
async def test_defer_retry_with_explicit_retry_after(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_mock.channel_metadata = {"attempts": 2}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._defer_retry(42, retry_after_seconds=15)
assert message_mock.channel_metadata["next_retry_at"]
assert message_mock.channel_metadata["attempts"] == 2
async def test_defer_retry_computes_backoff(dispatcher, redis_mock, message_mock, pg_manager_mock):
message_mock.channel_metadata = {"attempts": 2}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=message_mock)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with patch("random.randint", return_value=0):
await dispatcher._defer_retry(42)
assert message_mock.channel_metadata["next_retry_at"]
async def test_get_next_retry_at(dispatcher, pg_manager_mock):
future = (datetime.now(UTC) + timedelta(minutes=5)).isoformat()
msg = MagicMock()
msg.channel_metadata = {"next_retry_at": future}
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=msg)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
result = await dispatcher._get_next_retry_at(42)
assert result is not None
async def test_get_next_retry_at_missing_message(dispatcher, pg_manager_mock):
message_repo = MagicMock()
message_repo.get = AsyncMock(return_value=None)
with patch("yuxi.channel.outbound.dispatcher.MessageRepository", return_value=message_repo):
result = await dispatcher._get_next_retry_at(42)
assert result is None
async def test_ack(dispatcher, redis_mock):
with patch("yuxi.channel.outbound.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await dispatcher._ack("1-0")
redis_mock.xack.assert_awaited_once_with(CHANNEL_STREAM_KEY, CHANNEL_CONSUMER_GROUP, "1-0")
async def test_do_dispatch_missing_conversation(dispatcher, message_mock, session_mock):
conv_repo = MagicMock()
conv_repo.get_conversation_by_id = AsyncMock(return_value=None)
with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.PERMANENT_FAILURE, None)
async def test_do_dispatch_missing_plugin(dispatcher, message_mock, conversation_mock, session_mock):
dispatcher.registry.get_plugin.return_value = None
conv_repo = MagicMock()
conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock)
with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.PERMANENT_FAILURE, None)
dispatcher.registry.get_plugin.assert_called_once_with("feishu")
async def test_do_dispatch_config_load_error(dispatcher, plugin_mock, message_mock, conversation_mock, session_mock):
dispatcher.config.get_config = AsyncMock(side_effect=RuntimeError("db down"))
conv_repo = MagicMock()
conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation_mock)
with patch("yuxi.channel.outbound.dispatcher.ConversationRepository", return_value=conv_repo):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.PERMANENT_FAILURE, None)
async def test_do_dispatch_success_single_send(dispatcher, plugin_mock, message_mock, conversation_mock, session_mock):
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):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.SUCCESS, None)
assert message_mock.delivery_status == DeliveryStatus.COMPLETE
async def test_do_dispatch_media_upload_and_cache(
dispatcher, plugin_mock, message_mock, conversation_mock, session_mock
):
message_mock.channel_metadata = {"media": [{"url": "a.jpg"}]}
message_mock.message_type = "text"
plugin_mock.get_meta.return_value = ChannelMeta(
channel_type="feishu",
display_name="Feishu",
capability_matrix=CapabilityMatrix(media={MediaSupport.IMAGE}),
)
plugin_mock.get_delivery_capabilities.return_value = DeliveryCapabilities(
max_text_length=10,
supports_markdown=False,
supports_interactive=False,
supports_media=True,
)
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": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
plugin_mock.upload_media.assert_awaited_once()
assert "uploaded_media" in message_mock.channel_metadata
async def test_do_dispatch_batch_send_with_partial_failure(
dispatcher, plugin_mock, message_mock, conversation_mock, session_mock
):
message_mock.content = "a" * 25
plugin_mock.supports_batch_send.return_value = True
plugin_mock.chunk_text.return_value = ["chunk-1", "chunk-2"]
plugin_mock.send_batch.return_value = ["id-1", None]
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):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.RETRYABLE, None)
assert message_mock.delivery_status == DeliveryStatus.PENDING
async def test_do_dispatch_single_send_permanent_chunk_failure(
dispatcher, plugin_mock, message_mock, conversation_mock, session_mock
):
message_mock.content = "hi"
plugin_mock.classify_error.return_value = (ChannelErrorClassification.PERMANENT, None)
plugin_mock.send_message = AsyncMock(side_effect=RuntimeError("perm"))
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):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.PERMANENT_FAILURE, None)
assert message_mock.delivery_status == DeliveryStatus.FAILED
async def test_do_dispatch_single_send_rate_limited(
dispatcher, plugin_mock, message_mock, conversation_mock, session_mock
):
message_mock.content = "hi"
plugin_mock.classify_error.return_value = (ChannelErrorClassification.RATE_LIMITED, 60)
plugin_mock.send_message = AsyncMock(side_effect=RuntimeError("rate"))
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):
result = await dispatcher._do_dispatch(
{"channel_type": "feishu", "account_id": "acc-1", "conversation_id": 7, "session_key": "sk-1"},
message_mock,
session_mock,
)
assert result == (DispatchResult.RETRYABLE, 60)
async def test_update_message_status(dispatcher, message_mock, session_mock):
await dispatcher._update_message_status(
session_mock,
message_mock,
DeliveryStatus.COMPLETE,
["id-1", None],
[{"index": 0, "status": "success"}],
)
assert message_mock.delivery_status == DeliveryStatus.COMPLETE
assert message_mock.channel_message_id == "id-1"
assert message_mock.channel_metadata["sent_ids"] == ["id-1", None]
session_mock.commit.assert_awaited_once()