1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
2214 lines
82 KiB
Python
2214 lines
82 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.yuanbao.adapter import YuanbaoAdapter
|
|
from yuxi.channels.adapters.yuanbao.chunking import chunk_text
|
|
from yuxi.channels.adapters.yuanbao.config_mapper import normalize_config, to_openclaw_key
|
|
from yuxi.channels.adapters.yuanbao.directory import (
|
|
DirectoryResult,
|
|
GroupInfo,
|
|
PeerInfo,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.dispatch import (
|
|
BotMenuAction,
|
|
CardAction,
|
|
DispatchAction,
|
|
DispatchContext,
|
|
DispatchResult,
|
|
InteractiveDispatcher,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.format import format_outbound
|
|
from yuxi.channels.adapters.yuanbao.event_queue import EventQueue
|
|
from yuxi.channels.adapters.yuanbao.outbound_queue import OutboundQueue
|
|
from yuxi.channels.adapters.yuanbao.probe import health_check_yuanbao
|
|
from yuxi.channels.adapters.yuanbao.proto_codec import ProtoCodec, is_protobuf_message
|
|
from yuxi.channels.adapters.yuanbao.security import (
|
|
check_dm_policy,
|
|
check_group_policy,
|
|
check_mention_required,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.security_audit import SecurityAuditLogger
|
|
from yuxi.channels.adapters.yuanbao.send import send_with_retry
|
|
from yuxi.channels.adapters.yuanbao.send_cache import SendMessageCache, SentMessageEntry
|
|
from yuxi.channels.adapters.yuanbao.session import (
|
|
resolve_agent_route,
|
|
resolve_chat_type_str,
|
|
resolve_thread_key,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.streaming import (
|
|
LaneStreamManager,
|
|
ReasoningStreamManager,
|
|
StreamManager,
|
|
StreamMode,
|
|
create_stream_manager,
|
|
send_blocks_stream,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.template import (
|
|
ActionSelector,
|
|
SelectorOption,
|
|
TemplateButton,
|
|
TemplateCard,
|
|
TemplateMessageBuilder,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.token import YuanbaoTokenManager
|
|
from yuxi.channels.adapters.yuanbao.yb_accounts import (
|
|
YuanbaoAccount,
|
|
YuanbaoAccountManager,
|
|
load_accounts_from_config,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.yb_commands import (
|
|
COMMAND_DEFINITIONS,
|
|
NativeCommandContext,
|
|
handle_command,
|
|
parse_command,
|
|
sync_commands_menu,
|
|
)
|
|
from yuxi.channels.adapters.yuanbao.doc_gen import YUANBAO_CONFIG_KEYS, generate_channel_docs
|
|
from yuxi.channels.capabilities import ChannelCapabilities
|
|
from yuxi.channels.meta import ChannelMeta
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
ChatType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
MentionsInfo,
|
|
)
|
|
|
|
YUANBAO = ChannelType.YUANBAO
|
|
|
|
|
|
def _make_identity(
|
|
channel_chat_id: str = "user_openid_001",
|
|
channel_user_id: str = "user_openid_001",
|
|
channel_message_id: str = "msg_001",
|
|
) -> ChannelIdentity:
|
|
return ChannelIdentity(
|
|
channel_id="yuanbao",
|
|
channel_type=YUANBAO,
|
|
channel_user_id=channel_user_id,
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=channel_message_id,
|
|
)
|
|
|
|
|
|
def _make_response(
|
|
content: str = "Hello from Yuanbao",
|
|
channel_chat_id: str = "user_openid_001",
|
|
**kwargs,
|
|
) -> ChannelResponse:
|
|
identity = _make_identity(channel_chat_id=channel_chat_id)
|
|
return ChannelResponse(identity=identity, content=content, **kwargs)
|
|
|
|
|
|
# ==================== Token Manager Tests ====================
|
|
|
|
|
|
class TestYuanbaoTokenManager:
|
|
def test_token_initial_state(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
assert tm._access_token is None
|
|
assert tm._is_expired() is True
|
|
|
|
def test_api_base_default(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
assert tm.api_base == "https://open-api.yuanbao.tencent.com"
|
|
|
|
def test_api_base_custom(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001", api_base="https://custom.api.com")
|
|
assert tm.api_base == "https://custom.api.com"
|
|
|
|
def test_bot_app_id(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="my_bot")
|
|
assert tm.bot_app_id == "my_bot"
|
|
|
|
def test_sign_request(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
signature = tm._sign_request(1700000000)
|
|
assert isinstance(signature, str)
|
|
assert len(signature) == 64
|
|
|
|
def test_sign_request_deterministic(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
sig1 = tm._sign_request(1700000000)
|
|
sig2 = tm._sign_request(1700000000)
|
|
assert sig1 == sig2
|
|
|
|
def test_is_expired_with_no_token(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
assert tm._is_expired() is True
|
|
|
|
def test_is_expired_with_no_expires_at(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
tm._access_token = "token"
|
|
tm._expires_at = None
|
|
assert tm._is_expired() is True
|
|
|
|
def test_is_expired_future_token(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
tm._access_token = "token"
|
|
tm._expires_at = time.time() + 7200
|
|
assert tm._is_expired() is False
|
|
|
|
def test_is_expired_expired_token(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
tm._access_token = "old"
|
|
tm._expires_at = time.time() - 100
|
|
assert tm._is_expired() is True
|
|
|
|
def test_pre_signed_token_initial(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001", pre_signed_token="presigned")
|
|
assert tm._access_token == "presigned"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_token_returns_pre_signed(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001", pre_signed_token="presigned")
|
|
token = await tm.get_token()
|
|
assert token == "presigned"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_token_refreshes(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
tm._access_token = None
|
|
tm._expires_at = None
|
|
|
|
with patch.object(tm, "_refresh", new_callable=AsyncMock) as mock_refresh:
|
|
mock_refresh.return_value = None
|
|
await tm.get_token()
|
|
mock_refresh.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_token_not_expired(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
tm._access_token = "valid"
|
|
tm._expires_at = time.time() + 7200
|
|
token = await tm.get_token()
|
|
assert token == "valid"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_token(self):
|
|
tm = YuanbaoTokenManager(app_key="test_key", app_secret="test_secret", bot_app_id="bot_001")
|
|
|
|
with patch.object(tm, "get_token", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = "new_token"
|
|
result = await tm.refresh_token()
|
|
assert result == "new_token"
|
|
|
|
|
|
# ==================== Format Tests ====================
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_text_payload_direct(self):
|
|
resp = _make_response(content="Hello World", channel_chat_id="user_openid_001")
|
|
payload = format_outbound(resp)
|
|
assert payload["content"] == "Hello World"
|
|
assert payload["msg_type"] == "text"
|
|
assert payload["open_id"] == "user_openid_001"
|
|
|
|
def test_text_payload_group(self):
|
|
resp = _make_response(content="Group message", channel_chat_id="group_openid_abc")
|
|
resp.metadata = {"group_open_id": "group_openid_abc"}
|
|
payload = format_outbound(resp)
|
|
assert payload["content"] == "Group message"
|
|
assert payload["group_open_id"] == "group_openid_abc"
|
|
|
|
def test_text_payload_channel(self):
|
|
resp = _make_response(content="Channel message", channel_chat_id="ch_001")
|
|
resp.metadata = {"channel_id": "ch_001"}
|
|
payload = format_outbound(resp)
|
|
assert payload["content"] == "Channel message"
|
|
assert payload["channel_id"] == "ch_001"
|
|
|
|
def test_image_payload(self):
|
|
resp = _make_response(content="Image caption", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.IMAGE
|
|
resp.attachments = [Attachment(type="image", url="https://example.com/img.png")]
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "image"
|
|
assert payload["media_url"] == "https://example.com/img.png"
|
|
|
|
def test_image_payload_no_attachment(self):
|
|
resp = _make_response(content="Image caption", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.IMAGE
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "image"
|
|
assert payload["content"] == "Image caption"
|
|
assert "media_url" not in payload
|
|
|
|
def test_file_payload(self):
|
|
resp = _make_response(content="File description", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.FILE
|
|
resp.attachments = [Attachment(type="file", url="https://example.com/doc.pdf", filename="doc.pdf")]
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "file"
|
|
assert payload["media_url"] == "https://example.com/doc.pdf"
|
|
assert payload["filename"] == "doc.pdf"
|
|
|
|
def test_file_payload_no_filename(self):
|
|
resp = _make_response(content="File", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.FILE
|
|
resp.attachments = [Attachment(type="file", url="https://example.com/doc.pdf")]
|
|
payload = format_outbound(resp)
|
|
assert payload["filename"] == "file"
|
|
|
|
def test_payload_with_reply(self):
|
|
resp = _make_response(content="Reply", reply_to_message_id="msg_orig")
|
|
payload = format_outbound(resp)
|
|
assert payload["reply_to_msg_id"] == "msg_orig"
|
|
|
|
def test_audio_as_file(self):
|
|
resp = _make_response(content="Audio file", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.AUDIO
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "audio"
|
|
|
|
def test_extra_metadata(self):
|
|
resp = _make_response(content="Extra", channel_chat_id="user_001")
|
|
resp.metadata = {"extra": {"key": "value"}}
|
|
payload = format_outbound(resp)
|
|
assert payload["extra"] == {"key": "value"}
|
|
|
|
def test_sticker_payload(self):
|
|
resp = _make_response(content="sticker", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.STICKER
|
|
resp.attachments = [Attachment(type="image", url="https://example.com/sticker.png")]
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "sticker"
|
|
assert payload["media_url"] == "https://example.com/sticker.png"
|
|
|
|
def test_video_payload(self):
|
|
resp = _make_response(content="video", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.VIDEO
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "video"
|
|
|
|
def test_card_payload(self):
|
|
resp = _make_response(content="card", channel_chat_id="user_001")
|
|
resp.message_type = MessageType.CARD
|
|
payload = format_outbound(resp)
|
|
assert payload["msg_type"] == "card"
|
|
|
|
def test_buttons_in_metadata(self):
|
|
resp = _make_response(content="Buttons", channel_chat_id="user_001")
|
|
resp.metadata = {"buttons": [{"text": "Click", "type": "url", "value": "https://example.com"}]}
|
|
payload = format_outbound(resp)
|
|
assert payload["buttons"] == [{"text": "Click", "type": "url", "value": "https://example.com"}]
|
|
|
|
def test_card_in_metadata(self):
|
|
resp = _make_response(content="Card", channel_chat_id="user_001")
|
|
resp.metadata = {"card": {"type": "card", "title": "Test Card"}}
|
|
payload = format_outbound(resp)
|
|
assert payload["card"] == {"type": "card", "title": "Test Card"}
|
|
|
|
def test_empty_chat_id_raises(self):
|
|
resp = _make_response(content="test", channel_chat_id="")
|
|
with pytest.raises(ValueError, match="channel_chat_id is empty"):
|
|
format_outbound(resp)
|
|
|
|
def test_metadata_none(self):
|
|
resp = _make_response(content="test", channel_chat_id="user_001")
|
|
resp.metadata = None
|
|
payload = format_outbound(resp)
|
|
assert payload["content"] == "test"
|
|
|
|
def test_group_open_id_takes_priority(self):
|
|
resp = _make_response(content="test", channel_chat_id="user_001")
|
|
resp.metadata = {"group_open_id": "group_abc", "channel_id": "ch_001"}
|
|
payload = format_outbound(resp)
|
|
assert "group_open_id" in payload
|
|
assert "channel_id" not in payload
|
|
|
|
|
|
# ==================== Normalize Inbound Tests ====================
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"app_key": "test_key", "app_secret": "test_secret", "dm_policy": "open"}
|
|
return YuanbaoAdapter(config=config)
|
|
|
|
def test_direct_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_001",
|
|
"msg_type": "text",
|
|
"content": "\u4f60\u597d",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_chat_id == "user_123"
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.content == "\u4f60\u597d"
|
|
assert msg.message_type == MessageType.TEXT
|
|
|
|
def test_group_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_456",
|
|
"group_open_id": "group_abc",
|
|
"msg_id": "msg_002",
|
|
"msg_type": "text",
|
|
"content": "\u7fa4\u804a\u6d88\u606f",
|
|
"chat_type": "group",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_chat_id == "group_abc"
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.metadata.get("group_open_id") == "group_abc"
|
|
|
|
def test_channel_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_789",
|
|
"channel_id": "ch_guild_001",
|
|
"msg_id": "msg_003",
|
|
"msg_type": "text",
|
|
"content": "\u9891\u9053\u6d88\u606f",
|
|
"chat_type": "channel",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_chat_id == "ch_guild_001"
|
|
assert msg.chat_type == ChatType.GUILD_CHANNEL
|
|
assert msg.metadata.get("channel_id") == "ch_guild_001"
|
|
|
|
def test_image_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_004",
|
|
"msg_type": "image",
|
|
"content": "",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
"attachments": [
|
|
{"type": "image", "url": "https://example.com/photo.jpg", "filename": "photo.jpg", "size": 102400}
|
|
],
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.IMAGE
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].url == "https://example.com/photo.jpg"
|
|
|
|
def test_file_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_005",
|
|
"msg_type": "file",
|
|
"content": "\u6587\u6863",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
"attachments": [
|
|
{"type": "file", "url": "https://example.com/doc.pdf", "filename": "doc.pdf", "size": 204800}
|
|
],
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.FILE
|
|
assert len(msg.attachments) == 1
|
|
|
|
def test_markdown_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_006",
|
|
"msg_type": "markdown",
|
|
"content": "**Bold** text",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.TEXT
|
|
assert msg.content == "**Bold** text"
|
|
|
|
def test_command_detection(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_007",
|
|
"msg_type": "text",
|
|
"content": "/reset",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.COMMAND
|
|
|
|
def test_metadata_includes_yuanbao_chat_type(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_008",
|
|
"msg_type": "text",
|
|
"content": "Hello",
|
|
"chat_type": "direct",
|
|
"reply_to_msg_id": "msg_prev",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.metadata["yuanbao_chat_type"] == "direct"
|
|
assert msg.metadata["reply_to_msg_id"] == "msg_prev"
|
|
|
|
def test_group_message_mention(self, adapter):
|
|
adapter._bot_info = {"username": "mybot"}
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_456",
|
|
"group_open_id": "group_abc",
|
|
"msg_id": "msg_009",
|
|
"msg_type": "text",
|
|
"content": "@mybot \u4f60\u597d",
|
|
"chat_type": "group",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
|
|
def test_sticker_message(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_sticker",
|
|
"msg_type": "sticker",
|
|
"content": "",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.STICKER
|
|
|
|
def test_edited_message_event_type(self, adapter):
|
|
raw = {
|
|
"type": "edited_message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_edit",
|
|
"msg_type": "text",
|
|
"content": "edited",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type == EventType.MESSAGE_UPDATED
|
|
|
|
def test_deleted_message_event_type(self, adapter):
|
|
raw = {
|
|
"type": "deleted_message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_del",
|
|
"msg_type": "text",
|
|
"content": "",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type == EventType.MESSAGE_DELETED
|
|
|
|
def test_no_open_id(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"msg_id": "msg_no_user",
|
|
"msg_type": "text",
|
|
"content": "Hello",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_user_id == ""
|
|
|
|
def test_invalid_timestamp(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_bad_ts",
|
|
"msg_type": "text",
|
|
"content": "Hello",
|
|
"chat_type": "direct",
|
|
"timestamp": "not_a_number",
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.content == "Hello"
|
|
|
|
def test_private_from_group_code(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_private",
|
|
"msg_type": "text",
|
|
"content": "Private",
|
|
"chat_type": "direct",
|
|
"private_from_group_code": "group_code_123",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.metadata["private_from_group_code"] == "group_code_123"
|
|
|
|
def test_custom_elements_link_card(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_123",
|
|
"msg_id": "msg_link_card",
|
|
"msg_type": "text",
|
|
"content": "",
|
|
"chat_type": "direct",
|
|
"timestamp": 1700000000,
|
|
"custom_elements": [
|
|
{
|
|
"type": "link_card",
|
|
"url": "https://example.com/article",
|
|
"title": "Article Title",
|
|
"description": "Article Description",
|
|
}
|
|
],
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.content == "Article Title"
|
|
assert msg.extracted_urls == ["https://example.com/article"]
|
|
assert msg.metadata["link_card"]["url"] == "https://example.com/article"
|
|
|
|
def test_group_no_bot_info_no_mention(self, adapter):
|
|
raw = {
|
|
"type": "message",
|
|
"open_id": "user_456",
|
|
"group_open_id": "group_abc",
|
|
"msg_id": "msg_nobot",
|
|
"msg_type": "text",
|
|
"content": "Hello",
|
|
"chat_type": "group",
|
|
"timestamp": 1700000000,
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is False
|
|
|
|
|
|
# ==================== Security Tests ====================
|
|
|
|
|
|
class TestSecurityPolicy:
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_open(self):
|
|
config = {"dm_policy": "open"}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_disabled(self):
|
|
config = {"dm_policy": "disabled"}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_allowlist_match(self):
|
|
config = {"dm_policy": "allowlist", "allow_from": ["yb:user_001", "yb:user_002"]}
|
|
result = await check_dm_policy("yb:user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_allowlist_no_match(self):
|
|
config = {"dm_policy": "allowlist", "allow_from": ["yb:user_002"]}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_allowlist_wildcard(self):
|
|
config = {"dm_policy": "allowlist", "allow_from": ["*"]}
|
|
result = await check_dm_policy("user_anyone", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_unknown(self):
|
|
config = {"dm_policy": "unknown_policy"}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_nested_config(self):
|
|
config = {"dm": {"policy": "open"}}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_nested_disabled(self):
|
|
config = {"dm": {"policy": "disabled"}}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_nested_allowlist(self):
|
|
config = {"dm": {"policy": "allowlist", "allowFrom": ["yb:user_001"]}}
|
|
result = await check_dm_policy("yb:user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_pairing_true(self):
|
|
config = {"dm_policy": "pairing", "paired_users": ["user_001"]}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_pairing_false(self):
|
|
config = {"dm_policy": "pairing", "paired_users": []}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_policy_default_pairing(self):
|
|
config = {}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_open(self):
|
|
config = {"group_policy": "open"}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_disabled(self):
|
|
config = {"group_policy": "disabled"}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_global(self):
|
|
config = {"group_policy": "allowlist", "group_allow_from": ["yb:user_001"]}
|
|
result = await check_group_policy("group_abc", "yb:user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_global_wildcard(self):
|
|
config = {"group_policy": "allowlist", "group_allow_from": ["*"]}
|
|
result = await check_group_policy("group_abc", "user_anyone", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_per_group(self):
|
|
config = {"group_policy": "allowlist", "groups": {"group_abc": {"allow_from": ["yb:user_001"]}}}
|
|
result = await check_group_policy("group_abc", "yb:user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_per_group_wildcard(self):
|
|
config = {"group_policy": "allowlist", "groups": {"group_abc": {"allow_from": ["*"]}}}
|
|
result = await check_group_policy("group_abc", "user_anyone", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_per_group_disabled(self):
|
|
config = {"group_policy": "open", "groups": {"group_abc": {"enabled": False}}}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_disabled(self):
|
|
config = {"group_require_mention": False}
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_with_bot_mentioned(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP,
|
|
mentions=MentionsInfo(is_bot_mentioned=True),
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_not_mentioned(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_by_name(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(identity=_make_identity(), content="@mybot help", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config, bot_names=["mybot"])
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_by_reply_to_bot_msg(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP,
|
|
metadata={"reply_to_msg_id": "bot_msg_001"},
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config, bot_message_ids={"bot_msg_001"})
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_default_true(self):
|
|
config = {}
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_require_mention_format(self):
|
|
config = {"requireMention": False}
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_per_group_config(self):
|
|
config = {"groups": {"group_abc": {"require_mention": False}}}
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
|
|
# ==================== Session Tests ====================
|
|
|
|
|
|
class TestSessionRouting:
|
|
def test_resolve_thread_key_direct(self):
|
|
identity = _make_identity(channel_chat_id="user_openid_123")
|
|
thread_key = resolve_thread_key(identity, chat_type="direct")
|
|
assert thread_key == "yuanbao:direct:user_openid_123"
|
|
|
|
def test_resolve_thread_key_group(self):
|
|
identity = _make_identity(channel_chat_id="group_openid_abc")
|
|
thread_key = resolve_thread_key(identity, chat_type="group")
|
|
assert thread_key == "yuanbao:group:group_openid_abc"
|
|
|
|
def test_resolve_thread_key_channel(self):
|
|
identity = _make_identity(channel_chat_id="ch_guild_001")
|
|
thread_key = resolve_thread_key(identity, chat_type="channel")
|
|
assert thread_key == "yuanbao:channel:ch_guild_001"
|
|
|
|
def test_resolve_thread_key_unknown_defaults_direct(self):
|
|
identity = _make_identity(channel_chat_id="user_123")
|
|
thread_key = resolve_thread_key(identity, chat_type="unknown")
|
|
assert thread_key == "yuanbao:direct:user_123"
|
|
|
|
def test_resolve_chat_type_str_direct(self):
|
|
assert resolve_chat_type_str("direct") == "direct"
|
|
|
|
def test_resolve_chat_type_str_group(self):
|
|
assert resolve_chat_type_str("group") == "group"
|
|
|
|
def test_resolve_chat_type_str_channel(self):
|
|
assert resolve_chat_type_str("channel") == "guild_channel"
|
|
|
|
def test_resolve_chat_type_str_unknown(self):
|
|
assert resolve_chat_type_str("unknown") == "direct"
|
|
|
|
def test_resolve_agent_route_direct(self):
|
|
identity = _make_identity(channel_chat_id="user_123")
|
|
route = resolve_agent_route(identity, chat_type="direct", default_agent_id="my_bot")
|
|
assert route == "agent:my_bot:yuanbao:direct:user_123"
|
|
|
|
def test_resolve_agent_route_group(self):
|
|
identity = _make_identity(channel_chat_id="group_abc")
|
|
route = resolve_agent_route(identity, chat_type="group", default_agent_id="default")
|
|
assert route == "agent:default:yuanbao:group:group_abc"
|
|
|
|
def test_resolve_agent_route_group_custom_agent(self):
|
|
identity = _make_identity(channel_chat_id="group_abc")
|
|
groups_config = {"group_abc": {"agent_id": "custom_agent"}}
|
|
route = resolve_agent_route(identity, chat_type="group", default_agent_id="default", groups_config=groups_config)
|
|
assert route == "agent:custom_agent:yuanbao:group:group_abc"
|
|
|
|
def test_resolve_agent_route_channel(self):
|
|
identity = _make_identity(channel_chat_id="ch_001")
|
|
route = resolve_agent_route(identity, chat_type="channel", default_agent_id="default")
|
|
assert route == "agent:default:yuanbao:channel:ch_001"
|
|
|
|
def test_resolve_agent_route_channel_custom(self):
|
|
identity = _make_identity(channel_chat_id="ch_001")
|
|
channels_config = {"ch_001": {"agent_id": "channel_agent"}}
|
|
route = resolve_agent_route(identity, chat_type="channel", default_agent_id="default", channels_config=channels_config)
|
|
assert route == "agent:channel_agent:yuanbao:channel:ch_001"
|
|
|
|
|
|
# ==================== Chunking Tests ====================
|
|
|
|
|
|
class TestChunking:
|
|
def test_short_text(self):
|
|
result = chunk_text("Short text", limit=20000)
|
|
assert result == ["Short text"]
|
|
|
|
def test_exact_limit(self):
|
|
text = "A" * 5000
|
|
result = chunk_text(text, limit=6000)
|
|
assert len(result) == 1
|
|
|
|
def test_multiple_chunks(self):
|
|
text = "\n\n".join([f"Paragraph {i}" for i in range(200)])
|
|
result = chunk_text(text, limit=500)
|
|
assert len(result) > 1
|
|
|
|
def test_single_very_long_paragraph(self):
|
|
text = "A" * 10000
|
|
result = chunk_text(text, limit=1000)
|
|
assert len(result) > 1
|
|
for chunk in result:
|
|
assert len(chunk) <= 1000
|
|
|
|
def test_empty_text(self):
|
|
result = chunk_text("", limit=1000)
|
|
assert result == [""]
|
|
|
|
def test_unicode_text(self):
|
|
text = "\u4f60\u597d\u4e16\u754c" * 500
|
|
result = chunk_text(text, limit=300)
|
|
assert len(result) > 1
|
|
assert "\u4f60\u597d\u4e16\u754c" in result[0]
|
|
|
|
def test_sentence_boundary_split(self):
|
|
text = "A" * 400 + "\u3002" + "B" * 400
|
|
result = chunk_text(text, limit=500)
|
|
assert len(result) >= 1
|
|
|
|
def test_default_limit(self):
|
|
text = "X" * 30000
|
|
result = chunk_text(text)
|
|
assert len(result) > 1
|
|
|
|
|
|
# ==================== Adapter Tests ====================
|
|
|
|
|
|
class TestYuanbaoAdapter:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"app_key": "test_key", "app_secret": "test_secret", "dm_policy": "open"}
|
|
return YuanbaoAdapter(config=config)
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "yuanbao"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == YUANBAO
|
|
|
|
def test_text_chunk_limit(self, adapter):
|
|
assert adapter.text_chunk_limit == 3000
|
|
|
|
def test_supports_markdown(self, adapter):
|
|
assert adapter.supports_markdown is True
|
|
|
|
def test_supports_streaming(self, adapter):
|
|
assert adapter.supports_streaming is True
|
|
|
|
def test_streaming_modes(self, adapter):
|
|
assert "off" in adapter.streaming_modes
|
|
assert "block" in adapter.streaming_modes
|
|
|
|
def test_max_media_size_mb(self, adapter):
|
|
assert adapter.max_media_size_mb == 20
|
|
|
|
def test_initial_status(self, adapter):
|
|
assert adapter.status == "disconnected"
|
|
|
|
def test_webhook_path(self, adapter):
|
|
assert adapter.webhook_path is None
|
|
|
|
def test_credential_source_default(self, adapter):
|
|
assert adapter.credential_source == "inline"
|
|
|
|
def test_history_limit_default(self, adapter):
|
|
assert adapter.history_limit == 100
|
|
|
|
def test_history_limit_custom(self):
|
|
adapter = YuanbaoAdapter(config={"app_key": "k", "app_secret": "s", "historyLimit": 50})
|
|
assert adapter.history_limit == 50
|
|
|
|
def test_disable_block_streaming_default(self, adapter):
|
|
assert adapter.disable_block_streaming is False
|
|
|
|
def test_send_cache(self, adapter):
|
|
assert adapter.send_cache is not None
|
|
assert adapter.send_cache.size == 0
|
|
|
|
def test_markdown_hint_enabled(self, adapter):
|
|
assert adapter.markdown_hint_enabled is True
|
|
|
|
def test_markdown_system_hint(self, adapter):
|
|
assert adapter.markdown_system_hint is not None
|
|
|
|
def test_markdown_system_hint_disabled(self):
|
|
adapter = YuanbaoAdapter(config={"app_key": "k", "app_secret": "s", "markdownHintEnabled": False})
|
|
assert adapter.markdown_system_hint is None
|
|
|
|
def test_debug_enabled_no_ids(self, adapter):
|
|
assert adapter.debug_enabled is False
|
|
|
|
def test_format_outbound_text(self, adapter):
|
|
resp = _make_response(content="Hello")
|
|
payload = adapter.format_outbound(resp)
|
|
assert payload["content"] == "Hello"
|
|
assert payload["msg_type"] == "text"
|
|
|
|
def test_format_outbound_group(self, adapter):
|
|
resp = _make_response(content="Group msg", channel_chat_id="group_abc")
|
|
resp.metadata = {"group_open_id": "group_abc"}
|
|
payload = adapter.format_outbound(resp)
|
|
assert payload["group_open_id"] == "group_abc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_without_token(self, adapter):
|
|
status = await adapter.health_check()
|
|
assert status.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_token_no_manager(self, adapter):
|
|
result = await adapter._refresh_token_if_needed()
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_without_client(self, adapter):
|
|
resp = _make_response(content="Test")
|
|
result = await adapter.send(resp)
|
|
assert result.success is False
|
|
|
|
def test_receive_yields_nothing(self, adapter):
|
|
async def _collect():
|
|
collected = []
|
|
async for msg in adapter.receive():
|
|
collected.append(msg)
|
|
return collected
|
|
|
|
result = asyncio.run(_collect())
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_security_dm_open(self, adapter):
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(channel_chat_id="user_123"),
|
|
content="Hello",
|
|
chat_type=ChatType.DIRECT,
|
|
)
|
|
result = await adapter._check_security(msg)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_security_group_open(self, adapter):
|
|
adapter.config["group_policy"] = "open"
|
|
adapter.config["group_require_mention"] = False
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(channel_chat_id="group_abc"),
|
|
content="Hello",
|
|
chat_type=ChatType.GROUP,
|
|
metadata={"group_open_id": "group_abc"},
|
|
)
|
|
result = await adapter._check_security(msg)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_security_channel(self, adapter):
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(channel_chat_id="ch_001"),
|
|
content="Hello",
|
|
chat_type=ChatType.GUILD_CHANNEL,
|
|
)
|
|
result = await adapter._check_security(msg)
|
|
assert result is True
|
|
|
|
def test_resolve_chat_context_direct(self, adapter):
|
|
raw = {"open_id": "user_001", "chat_type": "direct"}
|
|
chat_type, chat_id = adapter._resolve_chat_context(raw)
|
|
assert chat_type == ChatType.DIRECT
|
|
assert chat_id == "user_001"
|
|
|
|
def test_resolve_chat_context_group(self, adapter):
|
|
raw = {"open_id": "user_002", "group_open_id": "group_abc", "chat_type": "group"}
|
|
chat_type, chat_id = adapter._resolve_chat_context(raw)
|
|
assert chat_type == ChatType.GROUP
|
|
assert chat_id == "group_abc"
|
|
|
|
def test_resolve_chat_context_channel(self, adapter):
|
|
raw = {"open_id": "user_003", "channel_id": "ch_001", "chat_type": "channel"}
|
|
chat_type, chat_id = adapter._resolve_chat_context(raw)
|
|
assert chat_type == ChatType.GUILD_CHANNEL
|
|
assert chat_id == "ch_001"
|
|
|
|
def test_resolve_chat_context_channel_priority(self, adapter):
|
|
raw = {"open_id": "user_004", "group_open_id": "group_abc", "channel_id": "ch_001", "chat_type": "channel"}
|
|
chat_type, chat_id = adapter._resolve_chat_context(raw)
|
|
assert chat_type == ChatType.GUILD_CHANNEL
|
|
assert chat_id == "ch_001"
|
|
|
|
def test_resolve_chat_context_no_id_defaults(self, adapter):
|
|
raw = {"chat_type": "direct"}
|
|
chat_type, chat_id = adapter._resolve_chat_context(raw)
|
|
assert chat_type == ChatType.DIRECT
|
|
assert chat_id == ""
|
|
|
|
def test_resolve_env_var(self):
|
|
import os
|
|
os.environ["TEST_YB_KEY"] = "resolved_value"
|
|
result = YuanbaoAdapter._resolve_env_var("${TEST_YB_KEY}")
|
|
assert result == "resolved_value"
|
|
|
|
def test_resolve_env_var_not_set(self):
|
|
result = YuanbaoAdapter._resolve_env_var("${NONEXISTENT_VAR_XYZ}")
|
|
assert result == "${NONEXISTENT_VAR_XYZ}"
|
|
|
|
def test_resolve_env_var_no_pattern(self):
|
|
result = YuanbaoAdapter._resolve_env_var("plain_text")
|
|
assert result == "plain_text"
|
|
|
|
def test_is_allowed_domain_valid(self, adapter):
|
|
assert adapter._is_allowed_domain("https://open-api.yuanbao.tencent.com/api/test") is True
|
|
|
|
def test_is_allowed_domain_invalid(self, adapter):
|
|
assert adapter._is_allowed_domain("https://evil.com/api/test") is False
|
|
|
|
def test_is_allowed_domain_custom_api_base(self):
|
|
adapter = YuanbaoAdapter(config={"app_key": "k", "app_secret": "s", "apiBase": "https://custom.api.com"})
|
|
assert adapter._is_allowed_domain("https://custom.api.com/api/test") is True
|
|
|
|
def test_is_allowed_domain_invalid_url(self, adapter):
|
|
assert adapter._is_allowed_domain("not a url") is False
|
|
|
|
def test_bind_chat_to_account(self, adapter):
|
|
adapter.bind_chat_to_account("chat_001", "acc_001")
|
|
assert adapter._chat_account_map["chat_001"] == "acc_001"
|
|
|
|
def test_unbind_chat(self, adapter):
|
|
adapter._chat_account_map["chat_001"] = "acc_001"
|
|
adapter.unbind_chat("chat_001")
|
|
assert "chat_001" not in adapter._chat_account_map
|
|
|
|
def test_unbind_chat_no_exist(self, adapter):
|
|
adapter.unbind_chat("nonexistent")
|
|
|
|
def test_get_account_status_empty(self, adapter):
|
|
status = adapter.get_account_status()
|
|
assert status == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_message_not_supported(self, adapter):
|
|
result = await adapter.edit_message("chat_1", "msg_1", "new content")
|
|
assert result.success is False
|
|
assert "not supported" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_not_supported(self, adapter):
|
|
result = await adapter.delete_message("chat_1", "msg_1")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_message_not_supported(self, adapter):
|
|
result = await adapter.pin_message("chat_1", "msg_1")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_poll_not_supported(self, adapter):
|
|
result = await adapter.send_poll("chat_1", "Question?", ["A", "B"])
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chat_action_not_supported(self, adapter):
|
|
result = await adapter.send_chat_action("chat_1", "typing")
|
|
assert result.success is False
|
|
|
|
|
|
# ==================== Adapter send_policies Tests ====================
|
|
|
|
|
|
class TestAdapterSendPolicies:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return YuanbaoAdapter(config={"app_key": "k", "app_secret": "s", "dm_policy": "open"})
|
|
|
|
def test_apply_send_policies_empty_content_fallback(self, adapter):
|
|
resp = _make_response(content="", channel_chat_id="user_001")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result is not None
|
|
assert result.content != ""
|
|
|
|
def test_apply_send_policies_empty_content_no_fallback(self, adapter):
|
|
adapter.config["fallbackReply"] = ""
|
|
resp = _make_response(content="", channel_chat_id="user_001")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result is None
|
|
|
|
def test_apply_send_policies_empty_content_with_attachments(self, adapter):
|
|
adapter.config["fallbackReply"] = ""
|
|
resp = _make_response(content="", channel_chat_id="user_001")
|
|
resp.attachments = [Attachment(type="image", url="https://example.com/img.png")]
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result is not None
|
|
|
|
def test_apply_send_policies_reply_to_mode_off(self, adapter):
|
|
adapter.config["replyToMode"] = "off"
|
|
resp = _make_response(content="test", reply_to_message_id="msg_001")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result.reply_to_message_id is None
|
|
|
|
def test_apply_send_policies_reply_to_mode_all(self, adapter):
|
|
adapter.config["replyToMode"] = "all"
|
|
resp = _make_response(content="test", reply_to_message_id="msg_001")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result.reply_to_message_id == "msg_001"
|
|
|
|
def test_apply_send_policies_bot_message_reply_stripped(self, adapter):
|
|
adapter._bot_message_ids.add("bot_msg_001")
|
|
resp = _make_response(content="test", reply_to_message_id="bot_msg_001")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result.reply_to_message_id is None
|
|
|
|
def test_apply_send_policies_attachment_size_filter(self, adapter):
|
|
adapter.config["mediaMaxMb"] = 1
|
|
resp = _make_response(content="test", channel_chat_id="user_001")
|
|
resp.attachments = [
|
|
Attachment(type="image", url="https://example.com/small.jpg", size_bytes=1024),
|
|
Attachment(type="image", url="https://example.com/large.jpg", size_bytes=5 * 1024 * 1024),
|
|
]
|
|
result = adapter._apply_send_policies(resp)
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].url == "https://example.com/small.jpg"
|
|
|
|
def test_apply_send_policies_no_attachments(self, adapter):
|
|
resp = _make_response(content="test")
|
|
result = adapter._apply_send_policies(resp)
|
|
assert result is not None
|
|
|
|
def test_check_first_reply_new(self, adapter):
|
|
result = adapter._check_first_reply("new_msg_id")
|
|
assert result is True
|
|
assert "new_msg_id" in adapter._first_reply_db
|
|
|
|
def test_check_first_reply_duplicate(self, adapter):
|
|
adapter._check_first_reply("msg_dup")
|
|
result = adapter._check_first_reply("msg_dup")
|
|
assert result is False
|
|
|
|
def test_cleanup_first_reply_db(self, adapter):
|
|
now = time.time()
|
|
adapter._first_reply_db["old_msg"] = now - 120
|
|
adapter._first_reply_db["new_msg"] = now
|
|
adapter._cleanup_first_reply_db(now)
|
|
assert "old_msg" not in adapter._first_reply_db
|
|
assert "new_msg" in adapter._first_reply_db
|
|
|
|
|
|
# ==================== Streaming Tests ====================
|
|
|
|
|
|
class TestStreaming:
|
|
@pytest.mark.asyncio
|
|
async def test_stream_blocks(self):
|
|
text = "Para 1\n\nPara 2\n\nPara 3"
|
|
sent_messages = []
|
|
|
|
async def mock_send(response):
|
|
sent_messages.append(response.content)
|
|
|
|
await send_blocks_stream("chat_001", text, mock_send, chunk_size=1)
|
|
assert len(sent_messages) > 0
|
|
full_text = " ".join(sent_messages)
|
|
assert "Para 1" in full_text
|
|
assert "Para 2" in full_text
|
|
assert "Para 3" in full_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_with_metadata(self):
|
|
text = "Single block"
|
|
sent_messages = []
|
|
|
|
async def mock_send(response):
|
|
sent_messages.append(response)
|
|
|
|
await send_blocks_stream("chat_001", text, mock_send, metadata={"group_open_id": "group_abc"}, chunk_size=1)
|
|
assert len(sent_messages) == 1
|
|
assert sent_messages[0].metadata["group_open_id"] == "group_abc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_empty_text(self):
|
|
sent_messages = []
|
|
|
|
async def mock_send(response):
|
|
sent_messages.append(response)
|
|
|
|
await send_blocks_stream("chat_001", "", mock_send)
|
|
assert len(sent_messages) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_single_paragraph(self):
|
|
sent_messages = []
|
|
|
|
async def mock_send(response):
|
|
sent_messages.append(response.content)
|
|
|
|
await send_blocks_stream("chat_001", "Single paragraph text", mock_send, chunk_size=5000)
|
|
assert len(sent_messages) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_manager_append_and_finalize(self):
|
|
sent = []
|
|
|
|
async def mock_send(response):
|
|
sent.append(response.content)
|
|
|
|
mgr = StreamManager(chat_id="chat_001", send_fn=mock_send, chunk_size=10)
|
|
await mgr.append("Hello ")
|
|
await mgr.append("World")
|
|
count = await mgr.finalize()
|
|
assert count >= 1
|
|
full = " ".join(sent)
|
|
assert "Hello" in full
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_manager_cancel(self):
|
|
sent = []
|
|
|
|
async def mock_send(response):
|
|
sent.append(response.content)
|
|
|
|
import time
|
|
mgr = StreamManager(chat_id="chat_001", send_fn=mock_send, throttle_ms=999999)
|
|
mgr._last_flush = time.monotonic()
|
|
await mgr.append("Some text")
|
|
await mgr.cancel()
|
|
count = await mgr.finalize()
|
|
assert count == 0
|
|
assert mgr._buffer == ""
|
|
assert mgr._finalized is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_manager_disabled(self):
|
|
sent = []
|
|
|
|
async def mock_send(response):
|
|
sent.append(response.content)
|
|
|
|
mgr = StreamManager(chat_id="chat_001", send_fn=mock_send, enabled=False)
|
|
await mgr.append("text")
|
|
count = await mgr.finalize()
|
|
assert count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lane_stream_manager(self):
|
|
sent = []
|
|
|
|
async def mock_send(response):
|
|
sent.append(response)
|
|
|
|
mgr = LaneStreamManager(chat_id="chat_001", send_fn=mock_send)
|
|
await mgr.append_to_lane("lane_a", "Hello from A")
|
|
await mgr.append_to_lane("lane_b", "Hello from B")
|
|
count_a = await mgr.finalize_lane("lane_a")
|
|
count_b = await mgr.finalize_lane("lane_b")
|
|
assert count_a >= 1
|
|
assert count_b >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reasoning_stream_manager(self):
|
|
sent = []
|
|
|
|
async def mock_send(response):
|
|
sent.append(response)
|
|
|
|
mgr = ReasoningStreamManager(chat_id="chat_001", send_fn=mock_send)
|
|
await mgr.append_reasoning("Thinking...")
|
|
await mgr.append_answer("The answer is 42")
|
|
results = await mgr.finalize_all()
|
|
assert "reasoning" in results
|
|
assert "answer" in results
|
|
|
|
def test_create_stream_manager_text(self):
|
|
mgr = create_stream_manager("chat_1", lambda x: None, mode=StreamMode.TEXT)
|
|
assert isinstance(mgr, StreamManager)
|
|
|
|
def test_create_stream_manager_lane(self):
|
|
mgr = create_stream_manager("chat_1", lambda x: None, mode=StreamMode.LANE)
|
|
assert isinstance(mgr, LaneStreamManager)
|
|
|
|
def test_create_stream_manager_reasoning(self):
|
|
mgr = create_stream_manager("chat_1", lambda x: None, mode=StreamMode.REASONING)
|
|
assert isinstance(mgr, ReasoningStreamManager)
|
|
|
|
|
|
# ==================== Probe Tests ====================
|
|
|
|
|
|
class TestProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_healthy(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
mock_resp.json = AsyncMock(return_value={"bot_app_id": "bot_001"})
|
|
|
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
|
result = await health_check_yuanbao(
|
|
"https://open-api.yuanbao.tencent.com", "test_token", ws_connected=True,
|
|
)
|
|
assert result.status == "healthy"
|
|
assert result.metadata["ws_connected"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_unhealthy_auth(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 401
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
|
result = await health_check_yuanbao("https://open-api.yuanbao.tencent.com", "bad_token")
|
|
assert result.status == "unhealthy"
|
|
assert "Token" in result.last_error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_exception(self):
|
|
with patch("aiohttp.ClientSession.get", side_effect=Exception("Network error")):
|
|
result = await health_check_yuanbao("https://open-api.yuanbao.tencent.com", "test_token")
|
|
assert result.status == "unhealthy"
|
|
assert "Network error" in result.last_error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_degraded(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 500
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
|
result = await health_check_yuanbao("https://open-api.yuanbao.tencent.com", "test_token")
|
|
assert result.status == "degraded"
|
|
|
|
|
|
# ==================== Dispatch Tests ====================
|
|
|
|
|
|
class TestDispatch:
|
|
def test_dispatch_action_values(self):
|
|
assert DispatchAction.MESSAGE == "message"
|
|
assert DispatchAction.COMMAND == "command"
|
|
assert DispatchAction.CARD_ACTION == "card_action"
|
|
assert DispatchAction.BOT_MENU == "bot_menu"
|
|
assert DispatchAction.REACTION == "reaction"
|
|
assert DispatchAction.MEMBER_EVENT == "member_event"
|
|
assert DispatchAction.READ_RECEIPT == "read_receipt"
|
|
assert DispatchAction.SYSTEM_EVENT == "system_event"
|
|
assert DispatchAction.UNKNOWN == "unknown"
|
|
|
|
def test_card_action_from_event(self):
|
|
event = {
|
|
"action_id": "act_001",
|
|
"type": "card_click",
|
|
"button_id": "btn_1",
|
|
"value": "hello",
|
|
"open_id": "user_123",
|
|
"group_open_id": "group_abc",
|
|
}
|
|
card = CardAction.from_event(event)
|
|
assert card.action_id == "act_001"
|
|
assert card.action_type == "card_click"
|
|
assert card.button_id == "btn_1"
|
|
assert card.value == "hello"
|
|
assert card.user_id == "user_123"
|
|
assert card.chat_id == "group_abc"
|
|
|
|
def test_card_action_from_event_button_obj(self):
|
|
event = {
|
|
"open_id": "user_123",
|
|
"button": {"id": "btn_nested"},
|
|
}
|
|
card = CardAction.from_event(event)
|
|
assert card.button_id == "btn_nested"
|
|
|
|
def test_bot_menu_action_from_event(self):
|
|
event = {
|
|
"menu_id": "menu_001",
|
|
"menu_name": "Main Menu",
|
|
"open_id": "user_123",
|
|
"group_open_id": "group_abc",
|
|
}
|
|
menu = BotMenuAction.from_event(event)
|
|
assert menu.menu_id == "menu_001"
|
|
assert menu.menu_name == "Main Menu"
|
|
assert menu.user_id == "user_123"
|
|
assert menu.chat_id == "group_abc"
|
|
|
|
def test_bot_menu_action_from_event_content_fallback(self):
|
|
event = {
|
|
"content": "Fallback Menu",
|
|
"open_id": "user_123",
|
|
}
|
|
menu = BotMenuAction.from_event(event)
|
|
assert menu.menu_name == "Fallback Menu"
|
|
|
|
def test_classify_event_message(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "message", "content": "hello"}) == DispatchAction.MESSAGE
|
|
|
|
def test_classify_event_command(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "message", "content": "/help"}) == DispatchAction.COMMAND
|
|
|
|
def test_classify_event_edited_message(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "edited_message"}) == DispatchAction.MESSAGE
|
|
|
|
def test_classify_event_channel_post(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "channel_post"}) == DispatchAction.MESSAGE
|
|
|
|
def test_classify_event_reaction(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "reaction_added"}) == DispatchAction.REACTION
|
|
|
|
def test_classify_event_bot_menu(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "bot_menu"}) == DispatchAction.BOT_MENU
|
|
|
|
def test_classify_event_member_joined(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "member_joined"}) == DispatchAction.MEMBER_EVENT
|
|
|
|
def test_classify_event_card_action(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "card_action"}) == DispatchAction.CARD_ACTION
|
|
|
|
def test_classify_event_read_receipt(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "read_receipt"}) == DispatchAction.READ_RECEIPT
|
|
|
|
def test_classify_event_typing(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "typing"}) == DispatchAction.SYSTEM_EVENT
|
|
|
|
def test_classify_event_unknown(self):
|
|
assert InteractiveDispatcher.classify_event({"type": "weird_event_type"}) == DispatchAction.UNKNOWN
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatcher_register_and_dispatch(self):
|
|
dispatcher = InteractiveDispatcher()
|
|
ctx = DispatchContext(action=DispatchAction.MESSAGE, raw_event={"type": "message"})
|
|
|
|
async def handler(ctx):
|
|
return DispatchResult(handled=True, action=ctx.action)
|
|
|
|
dispatcher.register(DispatchAction.MESSAGE, handler)
|
|
result = await dispatcher.dispatch(ctx)
|
|
assert result.handled is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatcher_fallback(self):
|
|
dispatcher = InteractiveDispatcher()
|
|
ctx = DispatchContext(action=DispatchAction.UNKNOWN, raw_event={"type": "unknown"})
|
|
|
|
async def fallback(ctx):
|
|
return DispatchResult(handled=True, action=ctx.action, metadata={"fallback": True})
|
|
|
|
dispatcher.set_fallback(fallback)
|
|
result = await dispatcher.dispatch(ctx)
|
|
assert result.handled is True
|
|
assert result.metadata["fallback"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatcher_no_handler_no_fallback(self):
|
|
dispatcher = InteractiveDispatcher()
|
|
ctx = DispatchContext(action=DispatchAction.UNKNOWN, raw_event={})
|
|
result = await dispatcher.dispatch(ctx)
|
|
assert result.handled is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatcher_handler_error(self):
|
|
dispatcher = InteractiveDispatcher()
|
|
ctx = DispatchContext(action=DispatchAction.MESSAGE, raw_event={})
|
|
|
|
async def bad_handler(ctx):
|
|
raise RuntimeError("test error")
|
|
|
|
dispatcher.register(DispatchAction.MESSAGE, bad_handler)
|
|
result = await dispatcher.dispatch(ctx)
|
|
assert result.handled is False
|
|
|
|
|
|
# ==================== Event Queue Tests ====================
|
|
|
|
|
|
class TestEventQueue:
|
|
@pytest.mark.asyncio
|
|
async def test_event_queue_start_stop(self):
|
|
queue = EventQueue(max_size=100)
|
|
events = []
|
|
|
|
async def handler(event):
|
|
events.append(event)
|
|
|
|
await queue.start(handler)
|
|
assert queue.size == 0
|
|
await queue.stop()
|
|
assert queue._running is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_queue_enqueue_process(self):
|
|
queue = EventQueue(max_size=100)
|
|
events = []
|
|
|
|
async def handler(event):
|
|
events.append(event)
|
|
|
|
await queue.start(handler)
|
|
await queue.enqueue({"type": "message", "content": "hello"})
|
|
await asyncio_sleep_short()
|
|
await queue.stop()
|
|
assert len(events) >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_queue_overflow(self):
|
|
queue = EventQueue(max_size=1)
|
|
|
|
await queue.start(lambda e: asyncio.sleep(0.1))
|
|
await queue.enqueue({"type": "msg1"})
|
|
await queue.enqueue({"type": "msg2"})
|
|
await queue.enqueue({"type": "msg3"})
|
|
assert queue._dropped_count > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_queue_dropped_count_initial(self):
|
|
queue = EventQueue()
|
|
assert queue._dropped_count == 0
|
|
|
|
|
|
def asyncio_sleep_short():
|
|
return asyncio.sleep(0.01)
|
|
|
|
|
|
# ==================== Outbound Queue Tests ====================
|
|
|
|
|
|
class TestOutboundQueue:
|
|
@pytest.mark.asyncio
|
|
async def test_immediate_strategy(self):
|
|
sent = []
|
|
|
|
async def send_fn(response):
|
|
sent.append(response)
|
|
return DeliveryResult(success=True)
|
|
|
|
queue = OutboundQueue(config={"outboundQueueStrategy": "immediate"}, send_fn=send_fn)
|
|
resp = _make_response(content="Hello")
|
|
result = await queue.enqueue(resp)
|
|
assert result.success is True
|
|
assert len(sent) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_strategy_flush_on_max(self):
|
|
sent = []
|
|
|
|
async def send_fn(response):
|
|
sent.append(response)
|
|
return DeliveryResult(success=True)
|
|
|
|
queue = OutboundQueue(
|
|
config={"outboundQueueStrategy": "merge-text", "maxChars": 50, "minChars": 2000, "idleMs": 5000},
|
|
send_fn=send_fn,
|
|
)
|
|
resp = _make_response(content="A" * 60)
|
|
result = await queue.enqueue(resp)
|
|
assert result.success is True
|
|
assert len(sent) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_empty(self):
|
|
async def send_fn(response):
|
|
return DeliveryResult(success=True)
|
|
|
|
queue = OutboundQueue(config={}, send_fn=send_fn)
|
|
result = await queue.flush()
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_flush_content(self):
|
|
sent = []
|
|
|
|
async def send_fn(response):
|
|
sent.append(response)
|
|
return DeliveryResult(success=True)
|
|
|
|
queue = OutboundQueue(config={"outboundQueueStrategy": "merge-text", "maxChars": 5000, "minChars": 5000, "idleMs": 5000}, send_fn=send_fn)
|
|
await queue.enqueue(_make_response(content="Part 1"))
|
|
await queue.enqueue(_make_response(content="Part 2"))
|
|
result = await queue.flush()
|
|
assert result.success is True
|
|
assert len(sent) == 1
|
|
assert "Part 1" in sent[0].content
|
|
assert "Part 2" in sent[0].content
|
|
|
|
|
|
# ==================== Send Tests ====================
|
|
|
|
|
|
class TestSendWithRetry:
|
|
@pytest.mark.asyncio
|
|
async def test_send_success(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
mock_resp.json = AsyncMock(return_value={"msg_id": "sent_001"})
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.post.return_value = mock_resp
|
|
|
|
result = await send_with_retry(
|
|
mock_session, "token", "https://api.example.com", {"content": "test"}, {}
|
|
)
|
|
assert result.success is True
|
|
assert result.message_id == "sent_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_auth_expired(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 401
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.post.return_value = mock_resp
|
|
|
|
result = await send_with_retry(
|
|
mock_session, "bad_token", "https://api.example.com", {"content": "test"}, {}
|
|
)
|
|
assert result.success is False
|
|
assert result.auth_expired is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_timeout(self):
|
|
mock_session = MagicMock()
|
|
|
|
class TimeoutContextManager:
|
|
async def __aenter__(self):
|
|
raise TimeoutError()
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
mock_session.post.return_value = TimeoutContextManager()
|
|
|
|
result = await send_with_retry(
|
|
mock_session, "token", "https://api.example.com", {"content": "test"},
|
|
{"retry": {"attempts": 1, "min_delay_ms": 1, "max_delay_ms": 10}},
|
|
)
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_client_error(self):
|
|
import aiohttp
|
|
mock_session = MagicMock()
|
|
|
|
class ClientErrorContextManager:
|
|
async def __aenter__(self):
|
|
raise aiohttp.ClientError("Connection failed")
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
mock_session.post.return_value = ClientErrorContextManager()
|
|
|
|
result = await send_with_retry(
|
|
mock_session, "token", "https://api.example.com", {"content": "test"},
|
|
{"retry": {"attempts": 1, "min_delay_ms": 1, "max_delay_ms": 10}},
|
|
)
|
|
assert result.success is False
|
|
|
|
|
|
# ==================== Send Cache Tests ====================
|
|
|
|
|
|
class TestSendCache:
|
|
def test_sent_message_entry_to_dict(self):
|
|
entry = SentMessageEntry(msg_id="msg_001", chat_id="chat_001", content_preview="Hello", status="sent")
|
|
d = entry.to_dict()
|
|
assert d["msg_id"] == "msg_001"
|
|
assert d["chat_id"] == "chat_001"
|
|
assert d["content_preview"] == "Hello"
|
|
|
|
def test_sent_message_entry_from_dict(self):
|
|
data = {"msg_id": "msg_001", "chat_id": "chat_001", "content_preview": "Hi", "sent_at": 123.0, "status": "sent"}
|
|
entry = SentMessageEntry.from_dict(data)
|
|
assert entry.msg_id == "msg_001"
|
|
assert entry.chat_id == "chat_001"
|
|
|
|
def test_cache_add_and_get(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_001", "chat_001", "Hello World")
|
|
entry = cache.get("msg_001")
|
|
assert entry is not None
|
|
assert entry.msg_id == "msg_001"
|
|
|
|
def test_cache_get_missing(self):
|
|
cache = SendMessageCache()
|
|
assert cache.get("nonexistent") is None
|
|
|
|
def test_cache_size(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
cache.add("msg_2", "chat_1")
|
|
assert cache.size == 2
|
|
|
|
def test_cache_remove(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
assert cache.remove("msg_1") is True
|
|
assert cache.remove("msg_1") is False
|
|
|
|
def test_cache_clear(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
cache.clear()
|
|
assert cache.size == 0
|
|
|
|
def test_cache_get_by_chat(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_a")
|
|
cache.add("msg_2", "chat_b")
|
|
entries = cache.get_by_chat("chat_a")
|
|
assert len(entries) == 1
|
|
assert entries[0].msg_id == "msg_1"
|
|
|
|
def test_cache_update_status(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
assert cache.update_status("msg_1", "delivered") is True
|
|
assert cache.get("msg_1").status == "delivered"
|
|
|
|
def test_cache_update_status_missing(self):
|
|
cache = SendMessageCache()
|
|
assert cache.update_status("nonexistent", "delivered") is False
|
|
|
|
def test_cache_mark_deleted(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
assert cache.mark_deleted("msg_1") is True
|
|
assert cache.get("msg_1").status == "deleted"
|
|
|
|
def test_cache_mark_edited(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1", "Original")
|
|
assert cache.mark_edited("msg_1", "Updated") is True
|
|
entry = cache.get("msg_1")
|
|
assert entry.status == "edited"
|
|
assert entry.content_preview == "Updated"
|
|
|
|
def test_cache_get_by_status(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
cache.add("msg_2", "chat_1")
|
|
cache.mark_deleted("msg_1")
|
|
deleted = cache.get_by_status("deleted")
|
|
assert len(deleted) == 1
|
|
|
|
def test_cache_stats(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1")
|
|
cache.mark_deleted("msg_1")
|
|
cache.add("msg_2", "chat_1")
|
|
stats = cache.stats
|
|
assert stats.get("deleted", 0) == 1
|
|
assert stats.get("sent", 0) == 1
|
|
|
|
def test_cache_has_sent_in_chat(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_a")
|
|
assert cache.has_sent_in_chat("chat_a") is True
|
|
assert cache.has_sent_in_chat("chat_b") is False
|
|
|
|
def test_cache_content_preview_truncation(self):
|
|
cache = SendMessageCache()
|
|
cache.add("msg_1", "chat_1", "A" * 300)
|
|
entry = cache.get("msg_1")
|
|
assert len(entry.content_preview) <= 200
|
|
|
|
|
|
# ==================== YbAccounts Tests ====================
|
|
|
|
|
|
class TestYuanbaoAccountManager:
|
|
def test_account_creation(self):
|
|
acc = YuanbaoAccount(account_id="acc_1", app_key="key", app_secret="secret", name="Test")
|
|
assert acc.account_id == "acc_1"
|
|
assert acc.display_name == "Test"
|
|
|
|
def test_account_display_name_fallback(self):
|
|
acc = YuanbaoAccount(account_id="acc_1", app_key="key", app_secret="secret")
|
|
assert acc.display_name == "acc_1"
|
|
|
|
def test_manager_add_account(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key", "secret", name="Account 1")
|
|
assert mgr.account_count == 1
|
|
assert mgr.default_account_id == "acc_1"
|
|
|
|
def test_manager_get_account(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key", "secret")
|
|
acc = mgr.get_account("acc_1")
|
|
assert acc is not None
|
|
assert acc.account_id == "acc_1"
|
|
|
|
def test_manager_get_account_disabled(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key", "secret", enabled=False)
|
|
assert mgr.get_account("acc_1") is None
|
|
|
|
def test_manager_get_default_account(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key1", "secret1")
|
|
mgr.add_account("acc_2", "key2", "secret2")
|
|
assert mgr.get_default_account().account_id == "acc_1"
|
|
|
|
def test_manager_remove_account(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key1", "secret1")
|
|
mgr.add_account("acc_2", "key2", "secret2")
|
|
mgr.remove_account("acc_1")
|
|
assert mgr.account_count == 1
|
|
assert mgr.default_account_id == "acc_2"
|
|
|
|
def test_manager_remove_all_accounts(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key1", "secret1")
|
|
mgr.remove_account("acc_1")
|
|
assert mgr.account_count == 0
|
|
assert mgr.default_account_id == ""
|
|
|
|
def test_manager_enabled_accounts(self):
|
|
mgr = YuanbaoAccountManager()
|
|
mgr.add_account("acc_1", "key1", "secret1", enabled=True)
|
|
mgr.add_account("acc_2", "key2", "secret2", enabled=False)
|
|
assert mgr.account_count == 1
|
|
assert len(mgr.enabled_accounts) == 1
|
|
|
|
def test_load_accounts_from_config_empty(self):
|
|
config = {"app_key": "key", "app_secret": "secret"}
|
|
mgr = load_accounts_from_config(config)
|
|
assert mgr.account_count == 1
|
|
|
|
def test_load_accounts_from_config_with_accounts(self):
|
|
config = {
|
|
"app_key": "key",
|
|
"app_secret": "secret",
|
|
"accounts": {
|
|
"acc_1": {"app_key": "key1", "app_secret": "secret1", "name": "Bot 1"},
|
|
"acc_2": {"app_key": "key2", "app_secret": "secret2", "name": "Bot 2"},
|
|
},
|
|
"defaultAccount": "acc_2",
|
|
}
|
|
mgr = load_accounts_from_config(config)
|
|
assert mgr.account_count == 2
|
|
assert mgr.default_account_id == "acc_2"
|
|
|
|
|
|
# ==================== YbCommands Tests ====================
|
|
|
|
|
|
class TestYbCommands:
|
|
def test_parse_command_simple(self):
|
|
assert parse_command("/help") == ("help", None)
|
|
|
|
def test_parse_command_with_args(self):
|
|
assert parse_command("/help status") == ("help", "status")
|
|
|
|
def test_parse_command_not_a_command(self):
|
|
assert parse_command("Hello") == (None, None)
|
|
|
|
def test_parse_command_empty(self):
|
|
assert parse_command("") == (None, None)
|
|
|
|
def test_parse_command_whitespace(self):
|
|
assert parse_command(" /status ") == ("status", None)
|
|
|
|
def test_command_definitions_structure(self):
|
|
names = [cmd["name"] for cmd in COMMAND_DEFINITIONS]
|
|
assert "help" in names
|
|
assert "status" in names
|
|
assert "new" in names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_help_list(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("help", None, ctx)
|
|
assert result.action == "help_list"
|
|
assert result.response_text is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_help_detail(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("help", "status", ctx)
|
|
assert result.action == "help_detail"
|
|
assert "status" in result.response_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_help_not_found(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("help", "nonexistent", ctx)
|
|
assert result.action == "help_not_found"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_status(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct", adapter_status="connected")
|
|
result = await handle_command("status", None, ctx)
|
|
assert result.action == "status_report"
|
|
assert "connected" in result.response_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_new(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("new", None, ctx)
|
|
assert result.action == "new_session"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_stop(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("stop", None, ctx)
|
|
assert result.action == "stop_task"
|
|
assert result.metadata["request_stop"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_restart(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("restart", None, ctx)
|
|
assert result.action == "restart_session"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_compact(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("compact", None, ctx)
|
|
assert result.action == "compact_history"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_unknown_command(self):
|
|
ctx = NativeCommandContext(user_id="u1", chat_id="c1", chat_type="direct")
|
|
result = await handle_command("foobar", None, ctx)
|
|
assert result.action == "unknown"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_commands_menu_success(self):
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_resp.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = mock_resp
|
|
|
|
result = await sync_commands_menu("https://api.example.com", "token", mock_client)
|
|
assert result is True
|
|
|
|
|
|
# ==================== Config Mapper Tests ====================
|
|
|
|
|
|
class TestConfigMapper:
|
|
def test_normalize_config_basic(self):
|
|
config = {"app_key": "key", "app_secret": "secret"}
|
|
result = normalize_config(config)
|
|
assert result["app_key"] == "key"
|
|
|
|
def test_normalize_config_flatten_dm(self):
|
|
config = {"dm": {"policy": "allowlist", "allowFrom": ["user_1"]}}
|
|
result = normalize_config(config)
|
|
assert result["dm_policy"] == "allowlist"
|
|
assert result["allow_from"] == ["user_1"]
|
|
|
|
def test_normalize_config_flat_dm_takes_priority(self):
|
|
config = {"dm_policy": "open", "dm": {"policy": "disabled"}}
|
|
result = normalize_config(config)
|
|
assert result["dm_policy"] == "open"
|
|
|
|
def test_normalize_config_flatten_accounts(self):
|
|
config = {"accounts": {"acc_1": {"appKey": "key1"}}}
|
|
result = normalize_config(config)
|
|
assert result["accounts"]["acc_1"]["app_key"] == "key1"
|
|
|
|
def test_normalize_config_flatten_groups(self):
|
|
config = {"groups": {"g1": {"requireMention": True}}}
|
|
result = normalize_config(config)
|
|
assert result["groups"]["g1"]["require_mention"] is True
|
|
|
|
def test_to_openclaw_key(self):
|
|
assert to_openclaw_key("app_key") == "appKey"
|
|
assert to_openclaw_key("unknown_key") == "unknown_key"
|
|
|
|
|
|
# ==================== Proto Codec Tests ====================
|
|
|
|
|
|
class TestProtoCodec:
|
|
def test_is_protobuf(self):
|
|
assert ProtoCodec.is_protobuf(bytes([0xFE, 0x01, 0x01, 0x00, 0x00])) is True
|
|
assert ProtoCodec.is_protobuf(b"\x00\x00") is False
|
|
|
|
def test_is_protobuf_message_func(self):
|
|
assert is_protobuf_message(bytes([0xFE, 0x01, 0x01, 0x00, 0x00])) is True
|
|
assert is_protobuf_message(b"hello") is False
|
|
|
|
def test_encode_decode_roundtrip(self):
|
|
payload = {"type": "message", "content": "hello"}
|
|
frame = ProtoCodec.encode_biz(payload)
|
|
decoded = ProtoCodec.decode(frame)
|
|
assert decoded is not None
|
|
assert decoded["payload"]["content"] == "hello"
|
|
assert decoded["msg_type"] == "biz"
|
|
|
|
def test_encode_conn(self):
|
|
frame = ProtoCodec.encode_conn({"type": "connect"})
|
|
decoded = ProtoCodec.decode(frame)
|
|
assert decoded["msg_type"] == "conn"
|
|
|
|
def test_encode_heartbeat(self):
|
|
frame = ProtoCodec.encode_heartbeat()
|
|
assert len(frame) == 5
|
|
assert frame[0] == 0xFE
|
|
assert frame[1] == ProtoCodec.VERSION
|
|
assert frame[2] == ProtoCodec.MSG_TYPE_HEARTBEAT
|
|
assert frame[3:5] == b"\x00\x00"
|
|
|
|
def test_encode_auth(self):
|
|
frame = ProtoCodec.encode_auth("test_token")
|
|
decoded = ProtoCodec.decode(frame)
|
|
assert decoded["payload"]["token"] == "test_token"
|
|
assert decoded["msg_type"] == "auth"
|
|
|
|
def test_decode_invalid_length(self):
|
|
assert ProtoCodec.decode(b"\xFE") is None
|
|
|
|
def test_decode_wrong_magic(self):
|
|
assert ProtoCodec.decode(b"\x00\x01\x01\x00\x00") is None
|
|
|
|
def test_decode_malformed(self):
|
|
assert ProtoCodec.decode(b"\xFE\x01\x01\xFF\xFF") is None
|
|
|
|
def test_body_too_large(self):
|
|
with pytest.raises(ValueError, match="Body too large"):
|
|
ProtoCodec.encode_biz({"data": "A" * 70000})
|
|
|
|
def test_is_protobuf_empty(self):
|
|
assert ProtoCodec.is_protobuf(b"") is False
|
|
|
|
|
|
# ==================== Template Tests ====================
|
|
|
|
|
|
class TestTemplate:
|
|
def test_button_to_dict(self):
|
|
btn = TemplateButton(text="Click me", action_type="url", value="https://example.com")
|
|
d = btn.to_dict()
|
|
assert d["text"] == "Click me"
|
|
assert d["type"] == "url"
|
|
assert d["value"] == "https://example.com"
|
|
|
|
def test_selector_option_to_dict(self):
|
|
opt = SelectorOption(label="Option 1", value="opt1", description="First option")
|
|
d = opt.to_dict()
|
|
assert d["label"] == "Option 1"
|
|
assert d["description"] == "First option"
|
|
|
|
def test_selector_option_to_dict_no_description(self):
|
|
opt = SelectorOption(label="Option 1", value="opt1")
|
|
d = opt.to_dict()
|
|
assert "description" not in d
|
|
|
|
def test_action_selector_to_dict(self):
|
|
sel = ActionSelector(placeholder="Choose", options=[SelectorOption(label="A", value="a")], selector_id="sel_1")
|
|
d = sel.to_dict()
|
|
assert d["type"] == "action_select"
|
|
assert d["placeholder"] == "Choose"
|
|
assert len(d["options"]) == 1
|
|
|
|
def test_template_card_to_dict(self):
|
|
card = TemplateCard(title="Card Title", content="Card Content", image_url="https://img.com/pic.jpg")
|
|
d = card.to_dict()
|
|
assert d["title"] == "Card Title"
|
|
assert d["image_url"] == "https://img.com/pic.jpg"
|
|
|
|
def test_template_card_to_dict_with_buttons(self):
|
|
card = TemplateCard(title="Card", buttons=[TemplateButton(text="OK")])
|
|
d = card.to_dict()
|
|
assert len(d["buttons"]) == 1
|
|
|
|
def test_template_message_builder(self):
|
|
builder = TemplateMessageBuilder()
|
|
builder.set_content("Message content")
|
|
builder.add_button("Click", "url", "https://example.com")
|
|
assert builder.build_content() == "Message content"
|
|
metadata = builder.build_metadata()
|
|
assert len(metadata["buttons"]) == 1
|
|
|
|
def test_template_message_builder_card(self):
|
|
builder = TemplateMessageBuilder()
|
|
card = TemplateCard(title="Card")
|
|
builder.set_card(card)
|
|
metadata = builder.build_metadata()
|
|
assert metadata["card"] is not None
|
|
|
|
def test_template_message_builder_selector(self):
|
|
builder = TemplateMessageBuilder()
|
|
builder.add_selector(placeholder="Select...", options=[SelectorOption(label="L", value="v")])
|
|
metadata = builder.build_metadata()
|
|
assert len(metadata["selectors"]) == 1
|
|
|
|
def test_template_message_builder_reset(self):
|
|
builder = TemplateMessageBuilder()
|
|
builder.set_content("test")
|
|
builder.reset()
|
|
assert builder.build_content() == ""
|
|
assert builder.build_metadata() == {}
|
|
|
|
|
|
# ==================== Doc Gen Tests ====================
|
|
|
|
|
|
class TestDocGen:
|
|
def test_generate_channel_docs(self):
|
|
caps = ChannelCapabilities(chat_types=["direct", "group"], delivery_mode="direct")
|
|
meta = ChannelMeta(id="yuanbao", label="Test", selection_label="Test", blurb="Test channel", order=1)
|
|
result = generate_channel_docs("yuanbao", caps, meta)
|
|
assert "Test" in result
|
|
assert "yuanbao" in result
|
|
assert "## \u80fd\u529b\u77e9\u9635" in result
|
|
assert "## \u914d\u7f6e\u9879\u53c2\u8003" in result
|
|
|
|
def test_config_keys_have_required_fields(self):
|
|
assert "app_key" in YUANBAO_CONFIG_KEYS
|
|
assert "app_secret" in YUANBAO_CONFIG_KEYS
|
|
assert "dm_policy" in YUANBAO_CONFIG_KEYS
|
|
|
|
|
|
# ==================== Security Audit Tests ====================
|
|
|
|
|
|
class TestSecurityAudit:
|
|
def test_log_dm_policy_blocked(self):
|
|
SecurityAuditLogger.log_dm_policy_blocked("user_1", "test_reason", "allowlist")
|
|
|
|
def test_log_group_access_blocked(self):
|
|
SecurityAuditLogger.log_group_access_blocked("group_1", "user_1", "test")
|
|
|
|
def test_log_mention_required_blocked(self):
|
|
SecurityAuditLogger.log_mention_required_blocked("group_1", "user_1")
|
|
|
|
def test_log_unauthorized_access(self):
|
|
SecurityAuditLogger.log_unauthorized_access("user_1", "resource_1")
|
|
|
|
def test_log_rate_limit_exceeded(self):
|
|
SecurityAuditLogger.log_rate_limit_exceeded("user_1", "api")
|
|
|
|
def test_log_circuit_breaker_open(self):
|
|
SecurityAuditLogger.log_circuit_breaker_open("user_1", "too many failures")
|
|
|
|
def test_log_auth_failure(self):
|
|
SecurityAuditLogger.log_auth_failure("acc_1", "invalid token")
|
|
|
|
def test_set_output_format(self):
|
|
SecurityAuditLogger.set_output_format("json")
|
|
SecurityAuditLogger.log_dm_policy_blocked("user_1", "test", "open")
|
|
SecurityAuditLogger.set_output_format("text") |