ForcePilot/backend/test/unit/channels/test_wechat_send.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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