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()