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

311 lines
11 KiB
Python

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from yuxi.channels.models import DeliveryResult
class TestMPTokenRetry:
@pytest.mark.asyncio
async def test_send_with_token_expired_retries(self):
from yuxi.channels.adapters.wechat.mp.send import _post_with_token_retry
from yuxi.channels.adapters.wechat.mp.client import MPClient
config = {"app_id": "test_app", "app_secret": "test_secret"}
http_client = AsyncMock()
client = MPClient(http_client, config)
fail_resp = MagicMock()
fail_resp.json.return_value = {"errcode": 40014, "errmsg": "invalid access_token hint: [xxx]"}
success_resp = MagicMock()
success_resp.json.return_value = {"errcode": 0, "errmsg": "ok"}
token_resp = MagicMock()
token_resp.json.return_value = {"access_token": "new_token", "expires_in": 7200}
async def mock_refresh_token():
return "new_token"
client._refresh_token = mock_refresh_token
http_client.post.side_effect = [fail_resp, success_resp]
result = await _post_with_token_retry(client, http_client, "expired_token", {"touser": "openid"})
assert result.success is True
assert http_client.post.call_count == 2
@pytest.mark.asyncio
async def test_send_token_expired_fallback_fails(self):
from yuxi.channels.adapters.wechat.mp.send import _post_with_token_retry
from yuxi.channels.adapters.wechat.mp.client import MPClient
config = {"app_id": "test_app", "app_secret": "test_secret"}
http_client = AsyncMock()
client = MPClient(http_client, config)
fail_resp1 = MagicMock()
fail_resp1.json.return_value = {"errcode": 40014, "errmsg": "invalid access_token"}
fail_resp2 = MagicMock()
fail_resp2.json.return_value = {"errcode": 45009, "errmsg": "api freq out of limit"}
async def mock_refresh_token():
return "new_token"
client._refresh_token = mock_refresh_token
http_client.post.side_effect = [fail_resp1, fail_resp2]
result = await _post_with_token_retry(client, http_client, "expired_token", {"touser": "openid"})
assert result.success is False
assert "超过限制" in result.error
assert http_client.post.call_count == 2
@pytest.mark.asyncio
async def test_template_message_uses_correct_url(self):
from yuxi.channels.adapters.wechat.mp.send import _post_with_token_retry
from yuxi.channels.adapters.wechat.mp.client import MPClient
config = {"app_id": "test_app", "app_secret": "test_secret"}
http_client = AsyncMock()
client = MPClient(http_client, config)
success_resp = MagicMock()
success_resp.json.return_value = {"errcode": 0, "errmsg": "ok"}
http_client.post.return_value = success_resp
payload = {"touser": "openid", "template_id": "tmpl_xxx", "data": {}}
result = await _post_with_token_retry(client, http_client, "valid_token", payload)
assert result.success is True
call_url = http_client.post.call_args[0][0]
assert "template/send" in call_url
@pytest.mark.asyncio
async def test_send_success_first_attempt(self):
from yuxi.channels.adapters.wechat.mp.send import _post_with_token_retry
from yuxi.channels.adapters.wechat.mp.client import MPClient
config = {"app_id": "test_app", "app_secret": "test_secret"}
http_client = AsyncMock()
client = MPClient(http_client, config)
success_resp = MagicMock()
success_resp.json.return_value = {"errcode": 0, "errmsg": "ok"}
http_client.post.return_value = success_resp
result = await _post_with_token_retry(client, http_client, "valid_token", {"touser": "openid"})
assert result.success is True
assert http_client.post.call_count == 1
class TestWeComPayloadBuilding:
def test_build_text_payload_direct(self):
from yuxi.channels.adapters.wechat.wecom.send import build_wecom_text_payload
payload = build_wecom_text_payload(
agent_id="1000001",
to_user="user123",
content="Hello",
chat_type="direct",
)
assert payload["msgtype"] == "text"
assert payload["agentid"] == "1000001"
assert payload["touser"] == "user123"
assert payload["text"]["content"] == "Hello"
def test_build_text_payload_group(self):
from yuxi.channels.adapters.wechat.wecom.send import build_wecom_text_payload
payload = build_wecom_text_payload(
agent_id="1000001",
to_user="user123|user456",
content="Group message",
chat_type="group",
)
assert payload["touser"] == "user123|user456"
assert payload["text"]["content"] == "Group message"
def test_build_text_payload_truncates_long_content(self):
from yuxi.channels.adapters.wechat.wecom.send import build_wecom_text_payload
long_text = "A" * 3000
payload = build_wecom_text_payload(
agent_id="1000001",
to_user="user123",
content=long_text,
)
assert len(payload["text"]["content"]) <= 2048
class TestAdapterXMLParsing:
def test_xml_body_size_limit(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
adapter._mode = "personal"
large_body = "A" * (1_048_576 + 1)
with pytest.raises(ValueError, match="1MB"):
adapter._parse_webhook_body(large_body)
def test_xml_parse_billion_laughs_rejected(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
adapter._mode = "personal"
billion_laughs = (
'<?xml version="1.0"?>'
'<!DOCTYPE lolz ['
'<!ENTITY lol "lol">'
'<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">'
'<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">'
']>'
'<xml><ToUserName>&lol3;</ToUserName></xml>'
)
result = adapter._parse_webhook_body(billion_laughs)
assert "ToUserName" in result
def test_xml_parse_normal_message(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
adapter._mode = "personal"
xml_body = "<xml><ToUserName>gh_123</ToUserName><FromUserName>openid_abc</FromUserName><Content>test</Content></xml>"
result = adapter._parse_webhook_body(xml_body)
assert result["ToUserName"] == "gh_123"
assert result["FromUserName"] == "openid_abc"
assert result["Content"] == "test"
def test_json_fallback_on_non_xml(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
adapter._mode = "personal"
json_body = '{"type": "text", "content": "hello"}'
result = adapter._parse_webhook_body(json_body)
assert result["type"] == "text"
assert result["content"] == "hello"
class TestBridgeEventLoop:
@pytest.mark.asyncio
async def test_event_loop_periodic_health_check(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
from yuxi.channels.models import ChannelStatus
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open", "poll_interval": 0.01})
adapter._status = ChannelStatus.CONNECTED
adapter._mode = "personal"
adapter._bridge_url = "http://localhost:5555"
mock_bridge = AsyncMock()
mock_bridge.health_check.return_value = False
mock_bridge.fetch_events.return_value = []
adapter._bridge_client = mock_bridge
adapter._status = ChannelStatus.DISCONNECTED
task_created = bool(adapter._bridge_client)
assert task_created
@pytest.mark.asyncio
async def test_event_loop_health_check_triggers_reconnect(self):
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
from yuxi.channels.models import ChannelStatus
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
adapter._status = ChannelStatus.CONNECTED
adapter._mode = "personal"
adapter._bridge_url = "http://localhost:5555"
mock_bridge = AsyncMock()
mock_bridge.health_check.return_value = False
mock_bridge.fetch_events.side_effect = asyncio.CancelledError
adapter._bridge_client = mock_bridge
try:
await adapter._bridge_event_loop(0.01)
except Exception:
pass
assert adapter._status == ChannelStatus.RECONNECTING
import asyncio
class TestProbeSafeJson:
def test_safe_json_valid(self):
from yuxi.channels.adapters.wechat.probe import _safe_json
resp = MagicMock()
resp.headers = {"content-type": "application/json"}
resp.json.return_value = {"access_token": "test_token"}
result = _safe_json(resp)
assert result == {"access_token": "test_token"}
def test_safe_json_non_json_content_type(self):
from yuxi.channels.adapters.wechat.probe import _safe_json
resp = MagicMock()
resp.headers = {"content-type": "text/html"}
resp.json.side_effect = ValueError("not json")
resp.text = "<html>error</html>"
result = _safe_json(resp)
assert result == {}
def test_safe_json_parse_error(self):
from yuxi.channels.adapters.wechat.probe import _safe_json
resp = MagicMock()
resp.headers = {"content-type": "application/json"}
resp.json.side_effect = ValueError("Invalid JSON")
resp.text = "not json"
result = _safe_json(resp)
assert result == {}
def test_safe_json_no_content_type(self):
from yuxi.channels.adapters.wechat.probe import _safe_json
resp = MagicMock()
resp.headers = {}
resp.text = ""
result = _safe_json(resp)
assert result == {}
class TestQRLoginCheckStatus:
@pytest.mark.asyncio
async def test_check_status_returns_logged_in_false_on_error(self):
from yuxi.channels.adapters.wechat.qr_login import QRLoginManager
http_client = AsyncMock()
http_client.get.side_effect = httpx.ConnectError("Connection refused")
mgr = QRLoginManager(http_client, "http://localhost:5555")
result = await mgr._check_status()
assert result == {"logged_in": False}
@pytest.mark.asyncio
async def test_check_status_returns_json_on_success(self):
from yuxi.channels.adapters.wechat.qr_login import QRLoginManager
resp = MagicMock()
resp.json.return_value = {"logged_in": True}
http_client = AsyncMock()
http_client.get.return_value = resp
mgr = QRLoginManager(http_client, "http://localhost:5555")
result = await mgr._check_status()
assert result == {"logged_in": True}