新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
333 lines
12 KiB
Python
333 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
from yuxi.channels.adapters.wechat.outbound_adapter import WeChatOutboundAdapter
|
|
from yuxi.channels.adapters.wechat.retry_send import RetryConfig, SendRetrier
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
)
|
|
|
|
|
|
class TestWeChatSendChain:
|
|
@pytest.fixture
|
|
def wecom_adapter(self):
|
|
config = {"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "wecom"
|
|
adapter._wecom_client = MagicMock()
|
|
adapter._wecom_client.get_access_token = AsyncMock(return_value="test_token")
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def mp_adapter(self):
|
|
config = {"app_id": "test", "app_secret": "test", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "mp"
|
|
adapter._mp_client = MagicMock()
|
|
adapter._mp_client.get_access_token = AsyncMock(return_value="test_token")
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def personal_adapter(self):
|
|
config = {"bridge_url": "http://localhost:5555", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "personal"
|
|
adapter._bridge_client = MagicMock()
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def channel_response(self):
|
|
return ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="user123",
|
|
channel_chat_id="user123",
|
|
),
|
|
content="测试消息",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_wecom_success(self, wecom_adapter, channel_response):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"errcode": 0, "errmsg": "ok", "msgid": "msg_abc123"}
|
|
wecom_adapter._http_client.post = AsyncMock(return_value=mock_resp)
|
|
|
|
result = await wecom_adapter.send(channel_response)
|
|
assert result.success is True
|
|
assert result.message_id == "msg_abc123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_wecom_rate_limited(self, wecom_adapter, channel_response):
|
|
wecom_adapter._rate_limiter._tokens = 0
|
|
|
|
for i in range(30):
|
|
channel_response.content = f"消息{i}"
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"errcode": 0, "errmsg": "ok", "msgid": f"msg_{i}"}
|
|
wecom_adapter._http_client.post = AsyncMock(return_value=mock_resp)
|
|
await wecom_adapter.send(channel_response)
|
|
wecom_adapter._rate_limiter._tokens = 1.0
|
|
|
|
wecom_adapter._rate_limiter._tokens = 0
|
|
result = await wecom_adapter.send(channel_response)
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_mp_success(self, mp_adapter, channel_response):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"errcode": 0, "errmsg": "ok", "msgid": 456789}
|
|
mp_adapter._http_client.post = AsyncMock(return_value=mock_resp)
|
|
|
|
result = await mp_adapter.send(channel_response)
|
|
assert result.success is True
|
|
assert result.message_id == "456789"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_bridge_success(self, personal_adapter, channel_response):
|
|
personal_adapter._bridge_client.send_message = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="bridge_123")
|
|
)
|
|
|
|
result = await personal_adapter.send(channel_response)
|
|
assert result.success is True
|
|
assert result.message_id == "bridge_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_bridge_silent(self, personal_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="user123",
|
|
channel_chat_id="user123",
|
|
),
|
|
content="静默消息",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
personal_adapter._bridge_client.send_message = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="silent_1")
|
|
)
|
|
|
|
result = await personal_adapter.send(response, disable_notification=True)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_banned_temporarily(self, wecom_adapter, channel_response):
|
|
wecom_adapter._banned = True
|
|
wecom_adapter._banned_reason = "48001 API unauthorized"
|
|
wecom_adapter._ban_permanent = False
|
|
|
|
result = await wecom_adapter.send(channel_response)
|
|
assert result.success is False
|
|
assert "banned" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_banned_permanently(self, wecom_adapter, channel_response):
|
|
wecom_adapter._banned = True
|
|
wecom_adapter._ban_permanent = True
|
|
wecom_adapter._banned_reason = "48001 API unauthorized"
|
|
|
|
result = await wecom_adapter.send(channel_response)
|
|
assert result.success is False
|
|
assert "permanently banned" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_wecom_48001_ban_progressive(self, wecom_adapter, channel_response):
|
|
result = DeliveryResult(success=False, error="errcode=48001 API unauthorized hint: [xxx]")
|
|
wecom_adapter._check_banned_response(result)
|
|
assert wecom_adapter._banned is True
|
|
assert wecom_adapter._ban_permanent is False
|
|
assert wecom_adapter._ban_attempts == 1
|
|
assert wecom_adapter._ban_cooldown_until is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_ban_cooldown_expires(self, wecom_adapter):
|
|
import time
|
|
|
|
wecom_adapter._banned = True
|
|
wecom_adapter._banned_reason = "48001"
|
|
wecom_adapter._ban_permanent = False
|
|
wecom_adapter._ban_attempts = 1
|
|
wecom_adapter._ban_cooldown_until = time.time() - 1
|
|
|
|
wecom_adapter._check_ban_cooldown()
|
|
assert wecom_adapter._banned is False
|
|
assert wecom_adapter._ban_cooldown_until is None
|
|
|
|
|
|
class TestSendRetrier:
|
|
def test_retry_config_defaults(self):
|
|
config = RetryConfig()
|
|
assert config.max_retries == 3
|
|
assert config.base_delay == 1.0
|
|
assert config.max_delay == 30.0
|
|
assert config.jitter_enabled is True
|
|
|
|
def test_retry_config_from_adapter_config(self):
|
|
cfg = RetryConfig.from_config({
|
|
"retry_attempts": 5,
|
|
"retry_min_delay": 2.0,
|
|
"retry_max_delay": 60.0,
|
|
})
|
|
assert cfg.max_retries == 5
|
|
assert cfg.base_delay == 2.0
|
|
assert cfg.max_delay == 60.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrier_success_first_attempt(self):
|
|
retrier = SendRetrier()
|
|
call_count = 0
|
|
|
|
async def succeed():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return "ok"
|
|
|
|
result = await retrier.execute(succeed)
|
|
assert result == "ok"
|
|
assert call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrier_retry_then_succeed(self):
|
|
import httpx
|
|
|
|
retrier = SendRetrier(RetryConfig(max_retries=3, base_delay=0.01))
|
|
call_count = 0
|
|
|
|
async def flaky():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count < 3:
|
|
raise httpx.TimeoutException("timeout")
|
|
return "ok"
|
|
|
|
result = await retrier.execute(flaky)
|
|
assert result == "ok"
|
|
assert call_count == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrier_exhausted(self):
|
|
import httpx
|
|
|
|
retrier = SendRetrier(RetryConfig(max_retries=2, base_delay=0.01))
|
|
call_count = 0
|
|
|
|
async def always_fail():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
raise httpx.TimeoutException("timeout")
|
|
|
|
with pytest.raises(httpx.TimeoutException):
|
|
await retrier.execute(always_fail)
|
|
assert call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrier_non_retryable_http_error(self):
|
|
import httpx
|
|
|
|
retrier = SendRetrier()
|
|
call_count = 0
|
|
|
|
async def auth_fail():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
resp = MagicMock()
|
|
resp.status_code = 403
|
|
raise httpx.HTTPStatusError("forbidden", request=MagicMock(), response=resp)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
await retrier.execute(auth_fail)
|
|
assert call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrier_rate_limit_backoff(self):
|
|
import httpx
|
|
|
|
retrier = SendRetrier(RetryConfig(max_retries=2, base_delay=0.01))
|
|
call_count = 0
|
|
|
|
async def rate_limited():
|
|
nonlocal call_count
|
|
call_count += 1
|
|
resp = MagicMock()
|
|
resp.status_code = 429
|
|
raise httpx.HTTPStatusError("rate limited", request=MagicMock(), response=resp)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
await retrier.execute(rate_limited)
|
|
assert call_count == 2
|
|
|
|
|
|
class TestOutboundAdapter:
|
|
def test_build_attached_results_success(self):
|
|
adapter = WeChatOutboundAdapter()
|
|
result = DeliveryResult(success=True, message_id="msg_123")
|
|
attached = adapter.build_attached_results(result, mode="wecom")
|
|
assert attached["success"] is True
|
|
assert attached["message_id"] == "msg_123"
|
|
assert attached["sent_message_id"] == "msg_123"
|
|
assert attached["mode"] == "wecom"
|
|
|
|
def test_build_attached_results_failure(self):
|
|
adapter = WeChatOutboundAdapter()
|
|
result = DeliveryResult(success=False, error="test error")
|
|
attached = adapter.build_attached_results(result)
|
|
assert attached["success"] is False
|
|
assert attached["error"] == "test error"
|
|
assert attached["message_id"] is None
|
|
|
|
def test_build_attached_results_with_metadata(self):
|
|
adapter = WeChatOutboundAdapter()
|
|
result = DeliveryResult(
|
|
success=True,
|
|
message_id="msg_456",
|
|
metadata={"delivery_time_ms": 120, "server": "wecom"},
|
|
)
|
|
attached = adapter.build_attached_results(result)
|
|
assert attached["delivery_time_ms"] == 120
|
|
assert attached["server"] == "wecom"
|
|
|
|
def test_sort_outbound_messages_priority(self):
|
|
messages = [
|
|
{"msgtype": "text", "content": "normal"},
|
|
{"msgtype": "voice", "content": "urgent"},
|
|
{"msgtype": "image", "content": "photo"},
|
|
]
|
|
sorted_msgs = WeChatOutboundAdapter.sort_outbound_messages(messages)
|
|
assert sorted_msgs[0]["msgtype"] == "voice"
|
|
assert sorted_msgs[1]["msgtype"] == "image"
|
|
assert sorted_msgs[2]["msgtype"] == "text"
|
|
|
|
def test_sort_outbound_messages_empty(self):
|
|
assert WeChatOutboundAdapter.sort_outbound_messages([]) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_deliver_payload_adds_sent_at(self):
|
|
adapter = WeChatOutboundAdapter()
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="u1",
|
|
channel_chat_id="u1",
|
|
),
|
|
content="test",
|
|
)
|
|
payload = await adapter.before_deliver_payload({}, response)
|
|
assert "_sent_at" in payload
|
|
assert isinstance(payload["_sent_at"], float)
|
|
|
|
def test_should_suppress_local_payload_prompt(self):
|
|
assert WeChatOutboundAdapter.should_suppress_local_payload_prompt({}) is True |