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 = ( '' '' '' '' ']>' '&lol3;' ) 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 = "gh_123openid_abctest" 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 = "error" 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}