2026-05-12 00:56:47 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.adapters.yuanbao.adapter import YuanbaoAdapter
|
2026-05-13 16:43:01 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
from yuxi.channels.adapters.yuanbao.format import format_outbound
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
2026-05-12 00:56:47 +08:00
|
|
|
from yuxi.channels.adapters.yuanbao.security import (
|
|
|
|
|
check_dm_policy,
|
|
|
|
|
check_group_policy,
|
|
|
|
|
check_mention_required,
|
|
|
|
|
)
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
2026-05-12 00:56:47 +08:00
|
|
|
from yuxi.channels.adapters.yuanbao.session import (
|
|
|
|
|
resolve_agent_route,
|
|
|
|
|
resolve_chat_type_str,
|
|
|
|
|
resolve_thread_key,
|
|
|
|
|
)
|
2026-05-13 16:43:01 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-12 00:56:47 +08:00
|
|
|
from yuxi.channels.adapters.yuanbao.token import YuanbaoTokenManager
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
2026-05-12 00:56:47 +08:00
|
|
|
from yuxi.channels.models import (
|
|
|
|
|
Attachment,
|
|
|
|
|
ChannelIdentity,
|
|
|
|
|
ChannelMessage,
|
|
|
|
|
ChannelResponse,
|
|
|
|
|
ChannelType,
|
|
|
|
|
ChatType,
|
2026-05-13 16:43:01 +08:00
|
|
|
DeliveryResult,
|
|
|
|
|
EventType,
|
2026-05-12 00:56:47 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
def test_api_base_default(self):
|
2026-05-12 00:56:47 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@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()
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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"}
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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",
|
2026-05-13 16:43:01 +08:00
|
|
|
"content": "\u4f60\u597d",
|
2026-05-12 00:56:47 +08:00
|
|
|
"chat_type": "direct",
|
|
|
|
|
"timestamp": 1700000000,
|
|
|
|
|
}
|
|
|
|
|
msg = adapter.normalize_inbound(raw)
|
|
|
|
|
assert msg.identity.channel_chat_id == "user_123"
|
|
|
|
|
assert msg.chat_type == ChatType.DIRECT
|
2026-05-13 16:43:01 +08:00
|
|
|
assert msg.content == "\u4f60\u597d"
|
2026-05-12 00:56:47 +08:00
|
|
|
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",
|
2026-05-13 16:43:01 +08:00
|
|
|
"content": "\u7fa4\u804a\u6d88\u606f",
|
2026-05-12 00:56:47 +08:00
|
|
|
"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",
|
2026-05-13 16:43:01 +08:00
|
|
|
"content": "\u9891\u9053\u6d88\u606f",
|
2026-05-12 00:56:47 +08:00
|
|
|
"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": [
|
2026-05-13 16:43:01 +08:00
|
|
|
{"type": "image", "url": "https://example.com/photo.jpg", "filename": "photo.jpg", "size": 102400}
|
2026-05-12 00:56:47 +08:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
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",
|
2026-05-13 16:43:01 +08:00
|
|
|
"content": "\u6587\u6863",
|
2026-05-12 00:56:47 +08:00
|
|
|
"chat_type": "direct",
|
|
|
|
|
"timestamp": 1700000000,
|
|
|
|
|
"attachments": [
|
2026-05-13 16:43:01 +08:00
|
|
|
{"type": "file", "url": "https://example.com/doc.pdf", "filename": "doc.pdf", "size": 204800}
|
2026-05-12 00:56:47 +08:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
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",
|
2026-05-13 16:43:01 +08:00
|
|
|
"content": "@mybot \u4f60\u597d",
|
2026-05-12 00:56:47 +08:00
|
|
|
"chat_type": "group",
|
|
|
|
|
"timestamp": 1700000000,
|
|
|
|
|
}
|
|
|
|
|
msg = adapter.normalize_inbound(raw)
|
|
|
|
|
assert msg.mentions is not None
|
|
|
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_group_policy_allowlist_per_group(self):
|
2026-05-13 16:43:01 +08:00
|
|
|
config = {"group_policy": "allowlist", "groups": {"group_abc": {"allow_from": ["yb:user_001"]}}}
|
2026-05-12 00:56:47 +08:00
|
|
|
result = await check_group_policy("group_abc", "yb:user_001", config)
|
|
|
|
|
assert result is True
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_mention_required_disabled(self):
|
|
|
|
|
config = {"group_require_mention": False}
|
2026-05-13 16:43:01 +08:00
|
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
2026-05-12 00:56:47 +08:00
|
|
|
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(
|
2026-05-13 16:43:01 +08:00
|
|
|
identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP,
|
2026-05-12 00:56:47 +08:00
|
|
|
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}
|
2026-05-13 16:43:01 +08:00
|
|
|
msg = ChannelMessage(identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP)
|
2026-05-12 00:56:47 +08:00
|
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
|
|
|
assert result is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_mention_required_by_name(self):
|
2026-05-13 16:43:01 +08:00
|
|
|
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):
|
2026-05-12 00:56:47 +08:00
|
|
|
config = {"group_require_mention": True}
|
|
|
|
|
msg = ChannelMessage(
|
2026-05-13 16:43:01 +08:00
|
|
|
identity=_make_identity(), content="Hello", chat_type=ChatType.GROUP,
|
|
|
|
|
metadata={"reply_to_msg_id": "bot_msg_001"},
|
2026-05-12 00:56:47 +08:00
|
|
|
)
|
2026-05-13 16:43:01 +08:00
|
|
|
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)
|
2026-05-12 00:56:47 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
def test_resolve_chat_type_str_unknown(self):
|
|
|
|
|
assert resolve_chat_type_str("unknown") == "direct"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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"}}
|
2026-05-13 16:43:01 +08:00
|
|
|
route = resolve_agent_route(identity, chat_type="group", default_agent_id="default", groups_config=groups_config)
|
2026-05-12 00:56:47 +08:00
|
|
|
assert route == "agent:custom_agent:yuanbao:group:group_abc"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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)
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
await send_blocks_stream("chat_001", text, mock_send, chunk_size=1)
|
2026-05-12 00:56:47 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
await send_blocks_stream("chat_001", text, mock_send, metadata={"group_open_id": "group_abc"}, chunk_size=1)
|
2026-05-12 00:56:47 +08:00
|
|
|
assert len(sent_messages) == 1
|
|
|
|
|
assert sent_messages[0].metadata["group_open_id"] == "group_abc"
|
|
|
|
|
|
2026-05-13 16:43:01 +08:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-12 00:56:47 +08:00
|
|
|
|
|
|
|
|
# ==================== 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):
|
2026-05-13 16:43:01 +08:00
|
|
|
result = await health_check_yuanbao("https://open-api.yuanbao.tencent.com", "bad_token")
|
2026-05-12 00:56:47 +08:00
|
|
|
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")):
|
2026-05-13 16:43:01 +08:00
|
|
|
result = await health_check_yuanbao("https://open-api.yuanbao.tencent.com", "test_token")
|
2026-05-12 00:56:47 +08:00
|
|
|
assert result.status == "unhealthy"
|
2026-05-13 16:43:01 +08:00
|
|
|
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")
|