from __future__ import annotations import asyncio import time from unittest.mock import AsyncMock, MagicMock import pytest from yuxi.channels.adapters.wechat.adapter import WeChatAdapter from yuxi.channels.adapters.wechat.bridge import BridgeClient from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, MessageType, ) class TestBridgeClient: @pytest.fixture def http_client(self): return AsyncMock() @pytest.fixture def bridge_client(self, http_client): return BridgeClient(http_client, "http://localhost:5555") @pytest.mark.asyncio async def test_health_check_healthy(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.status_code = 200 http_client.get = AsyncMock(return_value=mock_resp) result = await bridge_client.health_check() assert result is True @pytest.mark.asyncio async def test_health_check_unhealthy(self, bridge_client, http_client): http_client.get = AsyncMock(side_effect=Exception("connection refused")) result = await bridge_client.health_check() assert result is False @pytest.mark.asyncio async def test_get_login_status_logged_in(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = {"logged_in": True, "nickname": "test_user"} http_client.get = AsyncMock(return_value=mock_resp) result = await bridge_client.get_login_status() assert result["logged_in"] is True @pytest.mark.asyncio async def test_get_login_status_error(self, bridge_client, http_client): http_client.get = AsyncMock(side_effect=Exception("timeout")) result = await bridge_client.get_login_status() assert result["logged_in"] is False @pytest.mark.asyncio async def test_send_message_success(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = {"success": True, "msg_id": "bridge_msg_001"} http_client.post = AsyncMock(return_value=mock_resp) result = await bridge_client.send_message({"content": "hello"}) assert result.success is True assert result.message_id == "bridge_msg_001" @pytest.mark.asyncio async def test_send_message_failure(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = {"success": False, "error": "not logged in"} http_client.post = AsyncMock(return_value=mock_resp) result = await bridge_client.send_message({"content": "hello"}) assert result.success is False assert "not logged in" in str(result.error) @pytest.mark.asyncio async def test_send_media_message(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = {"success": True, "msg_id": "media_001"} http_client.post = AsyncMock(return_value=mock_resp) result = await bridge_client.send_media_message({"image_data": "base64..."}) assert result.success is True @pytest.mark.asyncio async def test_fetch_events_with_messages(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = [ {"type": "message", "data": {"sender_id": "u1", "content": "hi", "msg_id": "1", "msg_type": 1, "is_group": False}}, ] http_client.get = AsyncMock(return_value=mock_resp) events = await bridge_client.fetch_events() assert len(events) == 1 assert events[0]["type"] == "message" @pytest.mark.asyncio async def test_fetch_events_empty(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = [] http_client.get = AsyncMock(return_value=mock_resp) events = await bridge_client.fetch_events() assert events == [] @pytest.mark.asyncio async def test_fetch_events_error(self, bridge_client, http_client): http_client.get = AsyncMock(side_effect=Exception("network error")) events = await bridge_client.fetch_events() assert events == [] @pytest.mark.asyncio async def test_get_qr_code(self, bridge_client, http_client): mock_resp = MagicMock() mock_resp.json.return_value = {"qr_url": "http://qr.example.com/code"} http_client.post = AsyncMock(return_value=mock_resp) result = await bridge_client.get_qr_code() assert result["qr_url"] == "http://qr.example.com/code" class TestBridgeEventLoop: @pytest.fixture def adapter(self): config = {"bridge_url": "http://localhost:5555", "dm_policy": "open", "poll_interval": 0.1} adapter = WeChatAdapter(config) adapter._mode = "personal" adapter._bridge_client = MagicMock() adapter._bridge_client.health_check = AsyncMock(return_value=True) adapter._bridge_client.fetch_events = AsyncMock(return_value=[]) adapter._bridge_client.send_message = AsyncMock( return_value=DeliveryResult(success=True, message_id="test_id") ) adapter._http_client = AsyncMock() adapter._polling_lease._active = True adapter._polling_lease._last_renew = time.monotonic() return adapter @pytest.mark.asyncio async def test_event_loop_stops_on_request(self, adapter): adapter._status = ChannelStatus.CONNECTED task = asyncio.create_task(adapter._bridge_event_loop(0.1)) await asyncio.sleep(0.05) adapter._status = ChannelStatus.DISCONNECTED try: await asyncio.wait_for(task, timeout=1.0) except asyncio.TimeoutError: task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_event_loop_stops_when_lease_lost(self, adapter): adapter._status = ChannelStatus.CONNECTED adapter._polling_lease._active = False task = asyncio.create_task(adapter._bridge_event_loop(0.05)) try: await asyncio.wait_for(task, timeout=1.0) except asyncio.TimeoutError: task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_event_loop_health_check_failure(self, adapter): adapter._status = ChannelStatus.CONNECTED health_calls = [] async def failing_health(): health_calls.append(1) return False adapter._bridge_client.health_check = failing_health adapter._bridge_client.fetch_events = AsyncMock(return_value=[]) task = asyncio.create_task(adapter._bridge_event_loop(0.1)) await asyncio.sleep(0.15) adapter._status = ChannelStatus.DISCONNECTED try: await asyncio.wait_for(task, timeout=1.0) except asyncio.TimeoutError: task.cancel() with pytest.raises(asyncio.CancelledError): await task assert len(health_calls) >= 1 @pytest.mark.asyncio async def test_event_loop_message_delivery(self, adapter): adapter._status = ChannelStatus.CONNECTED message_count = 0 async def mock_handle(msg): nonlocal message_count message_count += 1 adapter._handle_message = mock_handle adapter._bridge_client.fetch_events = AsyncMock(return_value=[ {"type": "message", "data": {"sender_id": "u1", "chat_id": "u1", "msg_id": "1", "content": "hello", "is_group": False, "msg_type": 1}}, ]) task = asyncio.create_task(adapter._bridge_event_loop(0.1)) await asyncio.sleep(0.15) adapter._status = ChannelStatus.DISCONNECTED try: await asyncio.wait_for(task, timeout=1.0) except asyncio.TimeoutError: task.cancel() with pytest.raises(asyncio.CancelledError): await task assert message_count >= 1 @pytest.mark.asyncio async def test_event_loop_network_error_resilience(self, adapter): adapter._status = ChannelStatus.CONNECTED call_count = 0 async def flaky_fetch(): nonlocal call_count call_count += 1 if call_count == 1: raise Exception("network error") return [] adapter._bridge_client.fetch_events = flaky_fetch task = asyncio.create_task(adapter._bridge_event_loop(0.05)) await asyncio.sleep(0.15) adapter._status = ChannelStatus.DISCONNECTED try: await asyncio.wait_for(task, timeout=1.0) except asyncio.TimeoutError: task.cancel() with pytest.raises(asyncio.CancelledError): await task assert call_count >= 2 @pytest.mark.asyncio async def test_send_bridge_group_message_at_list(self, adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="sender_wxid", channel_chat_id="room_123", ), content="群聊回复", metadata={ "chat_type": "group", "reply_to_message_id": "msg_orig", "sender_wxid": "original_sender", }, ) adapter.config["reply_to_mode"] = "first" payload = adapter._format_bridge_text(response) assert payload["is_group"] is True assert payload["chat_id"] == "room_123" assert "original_sender" in payload.get("at_list", []) assert "@original_sender" in payload["content"]