130 lines
5.0 KiB
Python
130 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from yuxi.channel.constants import CHANNEL_DELIVERED_TTL_SECONDS, CHANNEL_STREAM_KEY
|
|
from yuxi.channel.outbound.publisher import publish_channel_message_event
|
|
from yuxi.storage.postgres.models_business import Conversation, Message
|
|
|
|
|
|
@pytest.fixture
|
|
def redis_mock():
|
|
return MagicMock(
|
|
exists=AsyncMock(return_value=0),
|
|
set=AsyncMock(return_value=True),
|
|
setex=AsyncMock(),
|
|
xadd=AsyncMock(return_value="123-0"),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def conversation():
|
|
conv = MagicMock(spec=Conversation)
|
|
conv.id = 1
|
|
conv.channel_type = "feishu"
|
|
conv.channel_metadata = {"account_id": "acc-1"}
|
|
conv.thread_id = "thread-1"
|
|
return conv
|
|
|
|
|
|
@pytest.fixture
|
|
def message():
|
|
msg = MagicMock(spec=Message)
|
|
msg.id = 10
|
|
msg.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
|
msg.request_id = "req-1"
|
|
msg.channel_metadata = {"attempts": 2, "next_retry_at": "2026-01-01T00:01:00+00:00"}
|
|
msg.delivery_status = "pending"
|
|
return msg
|
|
|
|
|
|
async def test_publish_channel_message_event_success(redis_mock, conversation, message):
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
event_id = await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
assert event_id == "123-0"
|
|
redis_mock.xadd.assert_awaited_once()
|
|
call_args = redis_mock.xadd.call_args
|
|
assert call_args.args[0] == CHANNEL_STREAM_KEY
|
|
assert json.loads(call_args.args[1]["payload"])["message_id"] == 10
|
|
assert call_args.args[1]["attempt"] == "2"
|
|
assert call_args.args[1]["next_retry_at"] == "2026-01-01T00:01:00+00:00"
|
|
|
|
|
|
async def test_publish_skips_non_channel_conversation(redis_mock, message):
|
|
for channel_type in [None, "web"]:
|
|
conv = MagicMock(spec=Conversation)
|
|
conv.channel_type = channel_type
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
result = await publish_channel_message_event(message, conv, "sk-1")
|
|
assert result is None
|
|
|
|
|
|
async def test_publish_skips_missing_account_id(redis_mock, conversation, message):
|
|
conversation.channel_metadata = {}
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
result = await publish_channel_message_event(message, conversation, "sk-1")
|
|
assert result is None
|
|
|
|
|
|
async def test_publish_skips_already_delivered(redis_mock, conversation, message):
|
|
redis_mock.exists.return_value = 1
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
result = await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
assert result is None
|
|
redis_mock.xadd.assert_not_called()
|
|
|
|
|
|
async def test_publish_skips_when_publishing_locked(redis_mock, conversation, message):
|
|
redis_mock.set.return_value = None
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
result = await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
assert result is None
|
|
redis_mock.xadd.assert_not_called()
|
|
|
|
|
|
async def test_publish_sets_delivered_for_complete_status(redis_mock, conversation, message):
|
|
message.delivery_status = "complete"
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
redis_mock.setex.assert_awaited_once_with("ch:delivered:10", CHANNEL_DELIVERED_TTL_SECONDS, "1")
|
|
|
|
|
|
async def test_publish_sets_delivered_for_partial_failed(redis_mock, conversation, message):
|
|
message.delivery_status = "partial_failed"
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
redis_mock.setex.assert_awaited_once()
|
|
|
|
|
|
async def test_publish_no_delivered_for_pending(redis_mock, conversation, message):
|
|
message.delivery_status = "pending"
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
await publish_channel_message_event(message, conversation, "sk-1")
|
|
|
|
redis_mock.setex.assert_not_called()
|
|
|
|
|
|
async def test_publish_default_timestamps_and_empty_metadata(redis_mock, conversation):
|
|
msg = MagicMock(spec=Message)
|
|
msg.id = 11
|
|
msg.created_at = None
|
|
msg.request_id = None
|
|
msg.channel_metadata = None
|
|
msg.delivery_status = "pending"
|
|
with patch("yuxi.channel.outbound.publisher.get_redis_client", AsyncMock(return_value=redis_mock)):
|
|
await publish_channel_message_event(msg, conversation, "sk-1")
|
|
|
|
payload = json.loads(redis_mock.xadd.call_args.args[1]["payload"])
|
|
assert payload["created_at"]
|
|
assert payload["request_id"] is None
|
|
assert redis_mock.xadd.call_args.args[1]["attempt"] == "0"
|
|
assert redis_mock.xadd.call_args.args[1]["next_retry_at"] == ""
|