新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
277 lines
9.6 KiB
Python
277 lines
9.6 KiB
Python
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"] |