新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
891 lines
31 KiB
Python
891 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
DeliveryResult,
|
|
MessageType,
|
|
)
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
|
|
class TestWeChatAdapter:
|
|
@pytest.fixture
|
|
def wecom_config(self):
|
|
return {
|
|
"mode": "wecom",
|
|
"corp_id": "test_corp_id",
|
|
"corp_secret": "test_corp_secret",
|
|
"agent_id": "1000001",
|
|
"dm_policy": "open",
|
|
}
|
|
|
|
@pytest.fixture
|
|
def mp_config(self):
|
|
return {
|
|
"mode": "mp",
|
|
"app_id": "test_app_id",
|
|
"app_secret": "test_app_secret",
|
|
"dm_policy": "open",
|
|
}
|
|
|
|
@pytest.fixture
|
|
def personal_config(self):
|
|
return {
|
|
"mode": "personal",
|
|
"bridge_url": "http://localhost:5555",
|
|
"dm_policy": "open",
|
|
}
|
|
|
|
def test_channel_id(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
assert adapter.channel_id == "wechat"
|
|
|
|
def test_channel_type(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
assert adapter.channel_type == ChannelType.WECHAT
|
|
|
|
def test_capabilities(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
assert adapter.supports_streaming is False
|
|
assert adapter.supports_markdown is False
|
|
assert adapter.text_chunk_limit == 2048
|
|
assert adapter.max_media_size_mb == 20
|
|
|
|
def test_mode_selection_wecom(self, wecom_config):
|
|
adapter = WeChatAdapter(wecom_config)
|
|
assert adapter._mode == "personal"
|
|
|
|
def test_mode_selection_mp(self, mp_config):
|
|
adapter = WeChatAdapter(mp_config)
|
|
assert adapter._mode == "personal"
|
|
|
|
def test_mode_selection_personal(self, personal_config):
|
|
adapter = WeChatAdapter(personal_config)
|
|
assert adapter._mode == "personal"
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
@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"
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def mp_adapter(self):
|
|
config = {"app_id": "test", "app_secret": "test", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "mp"
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def personal_adapter(self):
|
|
config = {"bridge_url": "http://localhost:5555", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "personal"
|
|
return adapter
|
|
|
|
def test_normalize_wecom_text_message(self, wecom_adapter):
|
|
payload = {
|
|
"FromUserName": "user123",
|
|
"ToUserName": "agent456",
|
|
"MsgId": "789012",
|
|
"MsgType": "text",
|
|
"Content": "你好",
|
|
}
|
|
result = wecom_adapter.normalize_inbound(payload)
|
|
|
|
assert isinstance(result, ChannelMessage)
|
|
assert result.content == "你好"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.identity.channel_user_id == "user123"
|
|
assert result.identity.channel_message_id == "789012"
|
|
assert result.metadata["wechat_mode"] == "wecom"
|
|
|
|
def test_normalize_wecom_image_message(self, wecom_adapter):
|
|
payload = {
|
|
"FromUserName": "user123",
|
|
"ToUserName": "agent456",
|
|
"MsgId": "789013",
|
|
"MsgType": "image",
|
|
"MediaId": "media_abc",
|
|
"PicUrl": "http://example.com/pic.jpg",
|
|
}
|
|
result = wecom_adapter.normalize_inbound(payload)
|
|
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "image"
|
|
assert result.attachments[0].file_id == "media_abc"
|
|
|
|
def test_normalize_mp_text_message(self, mp_adapter):
|
|
payload = {
|
|
"FromUserName": "openid_abc",
|
|
"ToUserName": "mp_appid_xyz",
|
|
"MsgId": "123456",
|
|
"MsgType": "text",
|
|
"Content": "你好公众号",
|
|
}
|
|
result = mp_adapter.normalize_inbound(payload)
|
|
|
|
assert result.content == "你好公众号"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.identity.channel_user_id == "openid_abc"
|
|
assert result.identity.channel_chat_id == "openid_abc"
|
|
assert result.metadata["wechat_mode"] == "mp"
|
|
|
|
def test_normalize_bridge_direct_message(self, personal_adapter):
|
|
payload = {
|
|
"sender_id": "wxid_abc",
|
|
"chat_id": "wxid_abc",
|
|
"msg_id": "111",
|
|
"content": "你好",
|
|
"is_group": False,
|
|
"msg_type": 1,
|
|
}
|
|
result = personal_adapter.normalize_inbound(payload)
|
|
|
|
assert result.content == "你好"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.identity.channel_user_id == "wxid_abc"
|
|
assert result.metadata["wechat_mode"] == "personal"
|
|
|
|
def test_normalize_bridge_group_message(self, personal_adapter):
|
|
payload = {
|
|
"sender_id": "wxid_abc",
|
|
"chat_id": "room_123",
|
|
"msg_id": "222",
|
|
"content": "群聊消息",
|
|
"is_group": True,
|
|
"msg_type": 1,
|
|
}
|
|
result = personal_adapter.normalize_inbound(payload)
|
|
|
|
assert result.content == "群聊消息"
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_chat_id == "room_123"
|
|
assert result.metadata["room_id"] == "room_123"
|
|
assert result.metadata["sender_wxid"] == "wxid_abc"
|
|
|
|
def test_normalize_webhook_xml_body(self, wecom_adapter):
|
|
xml_body = (
|
|
"<xml><ToUserName>agent</ToUserName>"
|
|
"<FromUserName>user</FromUserName>"
|
|
"<MsgType>text</MsgType>"
|
|
"<Content>test</Content>"
|
|
"<MsgId>123</MsgId></xml>"
|
|
)
|
|
result = wecom_adapter.normalize_inbound(xml_body)
|
|
|
|
assert result.content == "test"
|
|
assert result.identity.channel_user_id == "user"
|
|
assert result.metadata["wechat_mode"] == "wecom"
|
|
|
|
|
|
class TestFormatOutbound:
|
|
@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"
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def mp_adapter(self):
|
|
config = {"app_id": "test", "app_secret": "test", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "mp"
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def personal_adapter(self):
|
|
config = {"bridge_url": "http://localhost:5555", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "personal"
|
|
return adapter
|
|
|
|
def test_format_wecom_direct_message(self, wecom_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"},
|
|
)
|
|
payload = wecom_adapter.format_outbound(response)
|
|
|
|
assert payload["msgtype"] == "text"
|
|
assert payload["touser"] == "user123"
|
|
assert payload["agentid"] == "1000001"
|
|
assert payload["text"]["content"] == "回复消息"
|
|
|
|
def test_format_mp_message(self, mp_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="openid_abc",
|
|
channel_chat_id="openid_abc",
|
|
),
|
|
content="公众号回复",
|
|
)
|
|
payload = mp_adapter.format_outbound(response)
|
|
|
|
assert payload["touser"] == "openid_abc"
|
|
assert payload["msgtype"] == "text"
|
|
assert payload["text"]["content"] == "公众号回复"
|
|
|
|
def test_format_bridge_direct_message(self, personal_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="wxid_abc",
|
|
channel_chat_id="wxid_abc",
|
|
),
|
|
content="桥接回复",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
payload = personal_adapter.format_outbound(response)
|
|
|
|
assert payload["chat_id"] == "wxid_abc"
|
|
assert payload["is_group"] is False
|
|
assert payload["content"] == "桥接回复"
|
|
|
|
def test_format_bridge_group_message(self, personal_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="wxid_abc",
|
|
channel_chat_id="room_123",
|
|
),
|
|
content="群聊回复",
|
|
metadata={"chat_type": "group"},
|
|
)
|
|
payload = personal_adapter.format_outbound(response)
|
|
|
|
assert payload["chat_id"] == "room_123"
|
|
assert payload["is_group"] is True
|
|
|
|
def test_text_truncation(self, wecom_adapter):
|
|
long_content = "A" * 3000
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="user123",
|
|
channel_chat_id="user123",
|
|
),
|
|
content=long_content,
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
payload = wecom_adapter.format_outbound(response)
|
|
|
|
assert len(payload["text"]["content"]) <= 2048
|
|
|
|
|
|
class TestSecurity:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"dm_policy": "open", "allow_from": ["wx:allowed_user"]}
|
|
return WeChatAdapter(config)
|
|
|
|
def test_dm_policy_open(self):
|
|
config = {"dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_dm_policy("any_user") is True
|
|
|
|
def test_dm_policy_disabled(self):
|
|
config = {"dm_policy": "disabled"}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_dm_policy("any_user") is False
|
|
|
|
def test_dm_policy_allowlist_allowed(self):
|
|
config = {"dm_policy": "allowlist", "allow_from": ["wx:allowed_user"]}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_dm_policy("allowed_user") is True
|
|
|
|
def test_dm_policy_allowlist_denied(self):
|
|
config = {"dm_policy": "allowlist", "allow_from": ["wx:other_user"]}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_dm_policy("denied_user") is False
|
|
|
|
def test_group_policy_open(self):
|
|
config = {"group_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_group_policy("any_chat", "any_user") is True
|
|
|
|
def test_group_policy_disabled(self):
|
|
config = {"group_policy": "disabled"}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_group_policy("any_chat", "any_user") is False
|
|
|
|
def test_group_policy_allowlist_global(self):
|
|
config = {
|
|
"group_policy": "allowlist",
|
|
"group_allow_from": ["wx:allowed_user"],
|
|
}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._check_group_policy("some_chat", "allowed_user") is True
|
|
|
|
def test_at_bot_wecom(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
adapter._mode = "wecom"
|
|
adapter.config["agent_name"] = "MyBot"
|
|
payload = {"Content": "@MyBot 你好"}
|
|
assert adapter._is_at_bot(payload) is True
|
|
|
|
def test_at_bot_personal(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
adapter._mode = "personal"
|
|
payload = {"at_list": ["wxid_bot"]}
|
|
assert adapter._is_at_bot(payload) is True
|
|
|
|
|
|
class TestHealthCheck:
|
|
@pytest.fixture
|
|
async def adapter(self):
|
|
config = {"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "wecom"
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_no_client(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
assert "HTTP client not initialized" in (result.last_error or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_wecom_success(self):
|
|
config = {"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "wecom"
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"access_token": "test_token", "expires_in": 7200}
|
|
mock_resp.headers = {"content-type": "application/json"}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get.return_value = mock_resp
|
|
|
|
adapter._http_client = mock_client
|
|
result = await adapter.health_check()
|
|
assert result.status == "healthy"
|
|
|
|
|
|
class TestPreConnect:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"dm_policy": "open"}
|
|
return WeChatAdapter(config)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_wecom(self, adapter):
|
|
adapter._mode = "wecom"
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "ready"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_mp(self, adapter):
|
|
adapter._mode = "mp"
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "pending_scan"
|
|
|
|
|
|
class TestCapabilitiesUpdated:
|
|
def test_tts_voice_enabled(self):
|
|
from yuxi.channels.adapters.wechat.capabilities import WECHAT_CAPABILITIES
|
|
|
|
assert WECHAT_CAPABILITIES["tts"]["voice"]["enabled"] is True
|
|
assert WECHAT_CAPABILITIES["tts"]["voice"]["synthesis_target"] == "voice-note"
|
|
|
|
def test_reply_enabled(self):
|
|
from yuxi.channels.adapters.wechat.capabilities import WECHAT_CAPABILITIES
|
|
|
|
assert WECHAT_CAPABILITIES["reply"] is True
|
|
|
|
def test_group_management_enabled(self):
|
|
from yuxi.channels.adapters.wechat.capabilities import WECHAT_CAPABILITIES
|
|
|
|
assert WECHAT_CAPABILITIES["groupManagement"] is True
|
|
|
|
|
|
class TestReplySupport:
|
|
@pytest.fixture
|
|
def wecom_adapter(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "wecom"
|
|
adapter._wecom_client = MagicMock()
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.fixture
|
|
def mp_adapter(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {"app_id": "test", "app_secret": "test", "dm_policy": "open"}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "mp"
|
|
adapter._mp_client = MagicMock()
|
|
adapter._http_client = AsyncMock()
|
|
return adapter
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_format_reply_with_context(self, wecom_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="user123",
|
|
channel_chat_id="user123",
|
|
),
|
|
content="回复消息",
|
|
reply_to_message_id="msg_456",
|
|
metadata={"reply_to_channel_user_id": "original_sender"},
|
|
)
|
|
payload = wecom_adapter.format_outbound(response)
|
|
assert payload is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mp_format_reply_with_context(self, mp_adapter):
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="wechat",
|
|
channel_type=ChannelType.WECHAT,
|
|
channel_user_id="openid_abc",
|
|
channel_chat_id="openid_abc",
|
|
),
|
|
content="回复消息",
|
|
reply_to_message_id="msg_789",
|
|
metadata={"reply_to_channel_user_id": "original_sender"},
|
|
)
|
|
payload = mp_adapter.format_outbound(response)
|
|
assert payload["touser"] == "openid_abc"
|
|
|
|
def test_normalize_bridge_reply_id(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
|
|
adapter._mode = "personal"
|
|
|
|
payload = {
|
|
"sender_id": "wxid_abc",
|
|
"chat_id": "wxid_abc",
|
|
"msg_id": "333",
|
|
"content": "回复",
|
|
"is_group": False,
|
|
"msg_type": 1,
|
|
"reply_to_msg_id": "orig_222",
|
|
}
|
|
result = adapter.normalize_inbound(payload)
|
|
assert result.reply_to_message_id == "orig_222"
|
|
|
|
|
|
class TestBannedDetection:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"}
|
|
return WeChatAdapter(config)
|
|
|
|
def test_banned_initial_state(self, adapter):
|
|
assert adapter.is_banned is False
|
|
assert adapter.banned_reason is None
|
|
|
|
def test_check_banned_response_48001(self, adapter):
|
|
result = DeliveryResult(success=False, error="errcode=48001 API unauthorized")
|
|
adapter._check_banned_response(result)
|
|
assert adapter.is_banned is True
|
|
assert "48001" in adapter.banned_reason
|
|
assert adapter._ban_permanent is False
|
|
assert adapter._ban_attempts == 1
|
|
assert adapter._ban_cooldown_until is not None
|
|
assert adapter._status.value == "error"
|
|
|
|
def test_check_banned_response_progressive_permanent(self, adapter):
|
|
adapter._ban_backoff_intervals = [1, 5]
|
|
|
|
result = DeliveryResult(success=False, error="errcode=48001 API unauthorized")
|
|
adapter._check_banned_response(result)
|
|
assert adapter._ban_permanent is False
|
|
assert adapter._ban_attempts == 1
|
|
|
|
adapter._check_banned_response(result)
|
|
assert adapter._ban_permanent is False
|
|
assert adapter._ban_attempts == 2
|
|
|
|
adapter._check_banned_response(result)
|
|
assert adapter._ban_permanent is True
|
|
assert adapter._ban_attempts == 3
|
|
assert adapter._status.value == "disabled"
|
|
|
|
def test_unban_channel_temporary(self, adapter):
|
|
adapter._banned = True
|
|
adapter._banned_reason = "48001"
|
|
adapter._ban_permanent = False
|
|
adapter._ban_cooldown_until = 1000.0
|
|
|
|
result = adapter.unban_channel()
|
|
assert result is True
|
|
assert adapter._banned is False
|
|
assert adapter._banned_reason is None
|
|
assert adapter._ban_cooldown_until is None
|
|
|
|
def test_unban_channel_permanent(self, adapter):
|
|
adapter._banned = True
|
|
adapter._ban_permanent = True
|
|
|
|
result = adapter.unban_channel()
|
|
assert result is False
|
|
assert adapter._banned is True
|
|
|
|
def test_ban_status_property(self, adapter):
|
|
adapter._banned = True
|
|
adapter._banned_reason = "48001"
|
|
adapter._ban_permanent = False
|
|
adapter._ban_attempts = 1
|
|
adapter._ban_cooldown_until = 9999999999.0
|
|
|
|
status = adapter.ban_status
|
|
assert status["banned"] is True
|
|
assert status["permanent"] is False
|
|
assert status["attempts"] == 1
|
|
assert status["cooldown_remaining_seconds"] is not None
|
|
|
|
def test_check_ban_cooldown_expired(self, adapter):
|
|
import time
|
|
|
|
adapter._banned = True
|
|
adapter._ban_permanent = False
|
|
adapter._ban_attempts = 1
|
|
adapter._ban_cooldown_until = time.time() - 10
|
|
|
|
adapter._check_ban_cooldown()
|
|
assert adapter._banned is False
|
|
assert adapter._ban_cooldown_until is None
|
|
|
|
|
|
class TestAutoJoin:
|
|
@pytest.fixture
|
|
def wecom_adapter(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {
|
|
"corp_id": "test",
|
|
"corp_secret": "test",
|
|
"agent_id": "1000001",
|
|
"dm_policy": "open",
|
|
"auto_join_groups": ["chat_1", "chat_2"],
|
|
}
|
|
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.mark.asyncio
|
|
async def test_auto_join_groups_wecom_existing(self, wecom_adapter):
|
|
get_resp = MagicMock()
|
|
get_resp.json.return_value = {"errcode": 0, "chat_info": {"chatid": "chat_1"}}
|
|
wecom_adapter._http_client.get = AsyncMock(return_value=get_resp)
|
|
|
|
await wecom_adapter._auto_join_groups()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_join_groups_disabled_when_empty(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
|
|
adapter._mode = "personal"
|
|
adapter._http_client = AsyncMock()
|
|
|
|
await adapter._auto_join_groups()
|
|
|
|
|
|
class TestAutoDerive:
|
|
def test_auto_derive_disabled_by_default(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
|
|
assert adapter._should_auto_derive() is False
|
|
|
|
def test_auto_derive_enabled_with_config(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {
|
|
"bridge_url": "http://localhost:5555",
|
|
"dm_policy": "open",
|
|
"auto_derive_sessions": True,
|
|
}
|
|
adapter = WeChatAdapter(config)
|
|
assert adapter._should_auto_derive() is True
|
|
|
|
def test_auto_derive_key_format(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"})
|
|
adapter._mode = "personal"
|
|
key = adapter._get_auto_derive_key("room_123", "sender_abc")
|
|
assert "personal" in key
|
|
assert "room_123" in key
|
|
assert "sender_abc" in key
|
|
|
|
def test_auto_derive_metadata_in_bridge_group(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
config = {
|
|
"bridge_url": "http://localhost:5555",
|
|
"dm_policy": "open",
|
|
"auto_derive_sessions": True,
|
|
}
|
|
adapter = WeChatAdapter(config)
|
|
adapter._mode = "personal"
|
|
|
|
payload = {
|
|
"sender_id": "wxid_abc",
|
|
"chat_id": "room_123",
|
|
"msg_id": "555",
|
|
"content": "群聊消息",
|
|
"is_group": True,
|
|
"msg_type": 1,
|
|
}
|
|
result = adapter.normalize_inbound(payload)
|
|
assert result.metadata.get("auto_derive_session") is True
|
|
assert "auto_derive_key" in result.metadata
|
|
|
|
|
|
class TestLogoutAccount:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
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.invalidate_token = MagicMock()
|
|
return adapter
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_clears_wecom_client(self, adapter):
|
|
await adapter.logout_account()
|
|
assert adapter._wecom_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_returns_result(self, adapter):
|
|
result = await adapter.logout_account()
|
|
assert "cleared" in result
|
|
assert "loggedOut" in result
|
|
|
|
|
|
class TestReadMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_read_message_wecom(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"corp_id": "test", "corp_secret": "test", "agent_id": "1000001", "dm_policy": "open"})
|
|
adapter._mode = "wecom"
|
|
adapter._wecom_client = MagicMock()
|
|
adapter._wecom_client.get_access_token = AsyncMock(return_value="token")
|
|
adapter._http_client = AsyncMock()
|
|
|
|
mock_head_resp = MagicMock()
|
|
mock_head_resp.status_code = 200
|
|
adapter._http_client.head = AsyncMock(return_value=mock_head_resp)
|
|
|
|
result = await adapter.read_message("media_123")
|
|
assert result["msg_id"] == "media_123"
|
|
assert result["available"] is True
|
|
assert "read_at" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_message_unavailable(self):
|
|
from yuxi.channels.adapters.wechat.adapter import WeChatAdapter
|
|
|
|
adapter = WeChatAdapter({"app_id": "test", "app_secret": "test", "dm_policy": "open"})
|
|
adapter._mode = "mp"
|
|
adapter._mp_client = MagicMock()
|
|
adapter._mp_client.get_access_token = AsyncMock(return_value="token")
|
|
adapter._http_client = AsyncMock()
|
|
|
|
mock_head_resp = MagicMock()
|
|
mock_head_resp.status_code = 404
|
|
adapter._http_client.head = AsyncMock(return_value=mock_head_resp)
|
|
|
|
result = await adapter.read_message("expired_media")
|
|
assert result["msg_id"] == "expired_media"
|
|
assert result["available"] is False
|
|
|
|
def test_read_action_is_implemented(self):
|
|
from yuxi.channels.adapters.wechat.message_actions import WECHAT_MESSAGE_ACTIONS
|
|
|
|
assert WECHAT_MESSAGE_ACTIONS["read"]["status"] == "implemented"
|
|
|
|
|
|
class TestConnectionLifecycle:
|
|
@pytest.fixture
|
|
def wecom_config(self):
|
|
return {
|
|
"corp_id": "test_corp",
|
|
"corp_secret": "test_secret",
|
|
"agent_id": "1000001",
|
|
"dm_policy": "open",
|
|
}
|
|
|
|
@pytest.fixture
|
|
def personal_config(self):
|
|
return {
|
|
"bridge_url": "http://localhost:5555",
|
|
"dm_policy": "open",
|
|
}
|
|
|
|
def test_initial_status_disconnected(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
def test_connect_twice_idempotent(self, wecom_config):
|
|
adapter = WeChatAdapter(wecom_config)
|
|
adapter._mode = "wecom"
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_cleans_up(self, wecom_config):
|
|
adapter = WeChatAdapter(wecom_config)
|
|
adapter._mode = "wecom"
|
|
adapter._http_client = AsyncMock()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
|
|
await adapter.disconnect()
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
assert adapter._http_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_already_disconnected(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
adapter._status = ChannelStatus.DISCONNECTED
|
|
|
|
await adapter.disconnect()
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_cancels_polling_task(self, personal_config):
|
|
adapter = WeChatAdapter(personal_config)
|
|
adapter._mode = "personal"
|
|
adapter._http_client = AsyncMock()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
|
|
async def mock_loop(_interval):
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(10)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|
|
adapter._polling_task = asyncio.create_task(mock_loop(1.0))
|
|
await asyncio.sleep(0.01)
|
|
|
|
await adapter.disconnect()
|
|
assert adapter._polling_task is None
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_wecom(self):
|
|
adapter = WeChatAdapter({"corp_id": "test", "corp_secret": "test", "agent_id": "1", "dm_policy": "open"})
|
|
adapter._mode = "wecom"
|
|
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "ready"
|
|
assert "token" in result["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_mp(self):
|
|
adapter = WeChatAdapter({"app_id": "test", "app_secret": "test", "dm_policy": "open"})
|
|
adapter._mode = "mp"
|
|
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "pending_scan"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_unknown_mode(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
adapter._mode = "personal"
|
|
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] in ("error", "pending_scan")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_with_qr_wait_wecom(self):
|
|
adapter = WeChatAdapter({"corp_id": "test", "corp_secret": "test", "agent_id": "1", "dm_policy": "open"})
|
|
adapter._mode = "wecom"
|
|
|
|
result = await adapter.login_with_qr_wait()
|
|
assert result["status"] == "ready"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_with_qr_wait_mp(self):
|
|
adapter = WeChatAdapter({"app_id": "test", "app_secret": "test", "dm_policy": "open"})
|
|
adapter._mode = "mp"
|
|
|
|
result = await adapter.login_with_qr_wait()
|
|
assert result["status"] == "ready"
|
|
|
|
def test_state_snapshot_disconnected(self):
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
snapshot = adapter.state_snapshot
|
|
assert snapshot.configured is True
|
|
assert snapshot.connected is False
|
|
|
|
def test_resolve_config_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("WECHAT_BRIDGE_URL", "http://bridge.example.com")
|
|
adapter = WeChatAdapter({"dm_policy": "open"})
|
|
value = adapter._resolve_config("bridge_url", "WECHAT_BRIDGE_URL")
|
|
assert value == "http://bridge.example.com"
|
|
|
|
def test_resolve_config_from_config_first(self):
|
|
adapter = WeChatAdapter({"bridge_url": "http://cfg.example.com", "dm_policy": "open"})
|
|
value = adapter._resolve_config("bridge_url", "WECHAT_BRIDGE_URL")
|
|
assert value == "http://cfg.example.com"
|
|
|
|
def test_logout_clears_all_clients(self):
|
|
adapter = WeChatAdapter({"corp_id": "test", "corp_secret": "test", "agent_id": "1", "dm_policy": "open"})
|
|
adapter._mode = "wecom"
|
|
adapter._wecom_client = MagicMock()
|
|
adapter._mp_client = MagicMock()
|
|
adapter._bridge_client = MagicMock()
|
|
|
|
import asyncio
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
result = loop.run_until_complete(adapter.logout_account())
|
|
assert adapter._wecom_client is None
|
|
assert adapter._mp_client is None
|
|
assert adapter._bridge_client is None
|
|
finally:
|
|
loop.close()
|