311 lines
11 KiB
Python
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}
|