635 lines
27 KiB
Python
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()
|