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