1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
1891 lines
67 KiB
Python
1891 lines
67 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.qqbot.adapter import QQBotAdapter
|
|
from yuxi.channels.adapters.qqbot.format import (
|
|
build_ark_payload,
|
|
build_embed_payload,
|
|
build_image_payload,
|
|
build_markdown_payload,
|
|
build_text_payload,
|
|
format_outbound,
|
|
)
|
|
from yuxi.channels.adapters.qqbot.security import (
|
|
check_dm_policy,
|
|
check_group_policy,
|
|
check_mention_required,
|
|
)
|
|
from yuxi.channels.adapters.qqbot.session import (
|
|
resolve_agent_route,
|
|
resolve_chat_type,
|
|
resolve_thread_key,
|
|
)
|
|
from yuxi.channels.adapters.qqbot.streaming import send_blocks_stream
|
|
from yuxi.channels.adapters.qqbot.token import QQBotTokenManager
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
MentionsInfo,
|
|
)
|
|
|
|
|
|
def _make_identity(
|
|
channel_chat_id: str = "group_abc123",
|
|
channel_user_id: str = "user_001",
|
|
channel_message_id: str = "msg_001",
|
|
) -> ChannelIdentity:
|
|
return ChannelIdentity(
|
|
channel_id="qqbot",
|
|
channel_type=ChannelType.QQ_BOT,
|
|
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 QQBot",
|
|
channel_chat_id: str = "group_abc123",
|
|
**kwargs,
|
|
) -> ChannelResponse:
|
|
identity = _make_identity(channel_chat_id=channel_chat_id)
|
|
return ChannelResponse(identity=identity, content=content, **kwargs)
|
|
|
|
|
|
# ==================== Token Manager Tests ====================
|
|
|
|
|
|
class TestQQBotTokenManager:
|
|
def test_token_initial_state(self):
|
|
tm = QQBotTokenManager(app_id="test_app", app_secret="test_secret")
|
|
assert tm._access_token is None
|
|
assert tm._is_expired() is True
|
|
|
|
def test_api_base_production(self):
|
|
tm = QQBotTokenManager(app_id="test_app", app_secret="test_secret", sandbox=False)
|
|
assert tm.api_base == "https://api.sgroup.qq.com"
|
|
|
|
def test_api_base_sandbox(self):
|
|
tm = QQBotTokenManager(app_id="test_app", app_secret="test_secret", sandbox=True)
|
|
assert tm.api_base == "https://sandbox.api.sgroup.qq.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_token_refreshes(self):
|
|
tm = QQBotTokenManager(app_id="test_app", app_secret="test_secret")
|
|
|
|
with patch.object(tm, "_refresh", new_callable=AsyncMock) as mock_refresh:
|
|
mock_refresh.return_value = None
|
|
tm._access_token = None
|
|
await tm.get_token()
|
|
mock_refresh.assert_called_once()
|
|
|
|
def test_is_expired_with_no_token(self):
|
|
tm = QQBotTokenManager(app_id="test_app", app_secret="test_secret")
|
|
assert tm._is_expired() is True
|
|
|
|
|
|
# ==================== Format Tests ====================
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_text_payload_group(self):
|
|
resp = _make_response(content="Hello World", channel_chat_id="group_abc123")
|
|
payload = build_text_payload(resp)
|
|
assert payload["content"] == "Hello World"
|
|
assert payload["group_openid"] == "abc123"
|
|
assert "channel_id" not in payload
|
|
|
|
def test_text_payload_channel(self):
|
|
resp = _make_response(content="Hello", channel_chat_id="channel_xyz")
|
|
payload = build_text_payload(resp)
|
|
assert payload["channel_id"] == "channel_xyz"
|
|
|
|
def test_text_payload_truncation(self):
|
|
long_content = "A" * 3000
|
|
resp = _make_response(content=long_content)
|
|
payload = build_text_payload(resp)
|
|
assert len(payload["content"]) == 2000
|
|
|
|
def test_text_payload_with_reply(self):
|
|
resp = _make_response(content="Reply", reply_to_message_id="msg_orig")
|
|
payload = build_text_payload(resp)
|
|
assert payload["msg_id"] == "msg_orig"
|
|
|
|
def test_markdown_payload(self):
|
|
resp = _make_response(content="**Bold** text")
|
|
resp.metadata["markdown_template_id"] = "tmpl_001"
|
|
payload = build_markdown_payload(resp)
|
|
assert payload["msg_type"] == 2
|
|
assert payload["markdown"]["template_id"] == "tmpl_001"
|
|
|
|
def test_ark_payload(self):
|
|
resp = _make_response(content="Ark content")
|
|
resp.metadata["ark_template_id"] = "ark_23"
|
|
resp.metadata["ark_data"] = {"title": "Hello", "desc": "World"}
|
|
payload = build_ark_payload(resp)
|
|
assert payload["msg_type"] == 3
|
|
assert payload["ark"]["template_id"] == "ark_23"
|
|
|
|
def test_embed_payload(self):
|
|
resp = _make_response(content="Embed desc")
|
|
resp.metadata["embed"] = {"title": "Title", "fields": []}
|
|
payload = build_embed_payload(resp)
|
|
assert payload["msg_type"] == 4
|
|
assert payload["embed"]["title"] == "Title"
|
|
|
|
def test_image_payload(self):
|
|
resp = _make_response(content="Image caption")
|
|
resp.attachments = [Attachment(type="image", file_id="file_123")]
|
|
payload = build_image_payload(resp, "file_123")
|
|
assert payload["msg_type"] == 1
|
|
assert payload["file_image"] == "file_123"
|
|
|
|
def test_format_outbound_defaults_to_text(self):
|
|
resp = _make_response(content="Plain text")
|
|
payload = format_outbound(resp)
|
|
assert "content" in payload
|
|
|
|
def test_format_outbound_with_markdown_flag(self):
|
|
resp = _make_response(content="Markdown content")
|
|
payload = format_outbound(resp, use_markdown=True)
|
|
assert "markdown" in payload
|
|
|
|
|
|
# ==================== Normalize Inbound Tests ====================
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"}
|
|
return QQBotAdapter(config=config)
|
|
|
|
def test_at_message_create(self, adapter):
|
|
raw = {
|
|
"event_type": "at_message_create",
|
|
"event": {
|
|
"id": "msg_001",
|
|
"content": "你好 @bot",
|
|
"author": {"id": "user_123"},
|
|
"group_openid": "group_abc",
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_chat_id == "group_group_abc"
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.content == "你好 @bot"
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
|
|
def test_direct_message_create(self, adapter):
|
|
raw = {
|
|
"event_type": "direct_message_create",
|
|
"event": {
|
|
"id": "msg_002",
|
|
"content": "Private message",
|
|
"author": {"id": "user_456"},
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_chat_id == "dm_user_456"
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.content == "Private message"
|
|
|
|
def test_message_create_guild(self, adapter):
|
|
raw = {
|
|
"event_type": "message_create",
|
|
"event": {
|
|
"id": "msg_003",
|
|
"content": "Channel message",
|
|
"author": {"id": "user_789"},
|
|
"channel_id": "ch_guild_001",
|
|
"guild_id": "guild_001",
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
},
|
|
}
|
|
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("guild_id") == "guild_001"
|
|
|
|
def test_command_message(self, adapter):
|
|
raw = {
|
|
"event_type": "at_message_create",
|
|
"event": {
|
|
"id": "msg_004",
|
|
"content": "/reset",
|
|
"author": {"id": "user_123"},
|
|
"group_openid": "group_abc",
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.message_type == MessageType.COMMAND
|
|
|
|
def test_with_attachments(self, adapter):
|
|
raw = {
|
|
"event_type": "direct_message_create",
|
|
"event": {
|
|
"id": "msg_005",
|
|
"content": "Image",
|
|
"author": {"id": "user_456"},
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
"attachments": [
|
|
{
|
|
"content_type": "image/png",
|
|
"url": "https://example.com/img.png",
|
|
"filename": "img.png",
|
|
},
|
|
{
|
|
"content_type": "application/pdf",
|
|
"url": "https://example.com/doc.pdf",
|
|
"filename": "doc.pdf",
|
|
"size": 1024,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert len(msg.attachments) == 2
|
|
assert msg.attachments[0].type == "image"
|
|
assert msg.attachments[1].type == "file"
|
|
|
|
def test_unknown_event_type_raises(self, adapter):
|
|
from yuxi.channels.exceptions import MessageFormatError
|
|
|
|
raw = {"event_type": "unknown_event", "event": {}}
|
|
with pytest.raises(MessageFormatError):
|
|
adapter.normalize_inbound(raw)
|
|
|
|
|
|
# ==================== 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": ["qq:user_001", "qq:user_002"]}
|
|
result = await check_dm_policy("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": ["qq:user_002"]}
|
|
result = await check_dm_policy("user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_open(self):
|
|
config = {"group_policy": "open"}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_disabled(self):
|
|
config = {"group_policy": "disabled"}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_global(self):
|
|
config = {"group_policy": "allowlist", "group_allow_from": ["qq:user_001"]}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_policy_allowlist_per_group(self):
|
|
config = {
|
|
"group_policy": "allowlist",
|
|
"groups": {"group_abc": {"allow_from": ["qq:user_001"]}},
|
|
}
|
|
result = await check_group_policy("group_abc", "user_001", config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_disabled(self):
|
|
config = {"group_require_mention": False}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(),
|
|
content="Hello",
|
|
chat_type=ChatType.GROUP,
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_with_bot_mentioned(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(),
|
|
content="Hello",
|
|
chat_type=ChatType.GROUP,
|
|
mentions=MentionsInfo(is_bot_mentioned=True),
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_not_mentioned(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(),
|
|
content="Hello",
|
|
chat_type=ChatType.GROUP,
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_required_by_name(self):
|
|
config = {"group_require_mention": True}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(),
|
|
content="@mybot help",
|
|
chat_type=ChatType.GROUP,
|
|
)
|
|
result = await check_mention_required("group_abc", msg, config, bot_names=["mybot"])
|
|
assert result is True
|
|
|
|
|
|
# ==================== Session Tests ====================
|
|
|
|
|
|
class TestSessionRouting:
|
|
def test_resolve_thread_key_group(self):
|
|
identity = _make_identity(channel_chat_id="group_abc123")
|
|
thread_key = resolve_thread_key(identity)
|
|
assert thread_key == "qqbot:group:group_abc123"
|
|
|
|
def test_resolve_thread_key_direct(self):
|
|
identity = _make_identity(channel_chat_id="dm_user_openid_123")
|
|
thread_key = resolve_thread_key(identity)
|
|
assert thread_key == "qqbot:direct:dm_user_openid_123"
|
|
|
|
def test_resolve_thread_key_guild(self):
|
|
identity = _make_identity(channel_chat_id="ch_guild_001")
|
|
thread_key = resolve_thread_key(identity)
|
|
assert thread_key == "qqbot:guild:ch_guild_001"
|
|
|
|
def test_resolve_chat_type_group(self):
|
|
identity = _make_identity(channel_chat_id="group_abc")
|
|
assert resolve_chat_type(identity) == "group"
|
|
|
|
def test_resolve_chat_type_direct(self):
|
|
identity = _make_identity(channel_chat_id="user_123")
|
|
assert resolve_chat_type(identity) == "guild_channel"
|
|
|
|
def test_resolve_agent_route_group(self):
|
|
identity = _make_identity(channel_chat_id="group_abc123")
|
|
route = resolve_agent_route(identity, default_agent_id="my_bot")
|
|
assert route == "agent:my_bot:qqbot:group:group_abc123"
|
|
|
|
def test_resolve_agent_route_group_custom_agent(self):
|
|
identity = _make_identity(channel_chat_id="group_abc123")
|
|
groups_config = {"group_abc123": {"agent_id": "custom_agent"}}
|
|
route = resolve_agent_route(identity, default_agent_id="default", groups_config=groups_config)
|
|
assert route == "agent:custom_agent:qqbot:group:group_abc123"
|
|
|
|
|
|
# ==================== Adapter Tests ====================
|
|
|
|
|
|
class TestQQBotAdapter:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"}
|
|
return QQBotAdapter(config=config)
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "qqbot"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == ChannelType.QQ_BOT
|
|
|
|
def test_text_chunk_limit(self, adapter):
|
|
assert adapter.text_chunk_limit == 2000
|
|
|
|
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_initial_status(self, adapter):
|
|
assert adapter.status == "disconnected"
|
|
|
|
def test_format_outbound_text(self, adapter):
|
|
resp = _make_response(content="Hello")
|
|
payload = adapter.format_outbound(resp)
|
|
assert payload["content"] == "Hello"
|
|
|
|
def test_format_outbound_markdown(self, adapter):
|
|
adapter.config["use_markdown"] = True
|
|
resp = _make_response(content="**Bold**")
|
|
payload = adapter.format_outbound(resp)
|
|
assert "markdown" in payload
|
|
|
|
@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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_yields_nothing(self, adapter):
|
|
collected = []
|
|
async for msg in adapter.receive():
|
|
collected.append(msg)
|
|
assert collected == []
|
|
|
|
@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_with_mention(self, adapter):
|
|
adapter.config["group_policy"] = "open"
|
|
adapter.config["group_require_mention"] = False
|
|
adapter._bot_info = {"username": "mybot"}
|
|
msg = ChannelMessage(
|
|
identity=_make_identity(channel_chat_id="group_abc"),
|
|
content="Hello",
|
|
chat_type=ChatType.GROUP,
|
|
)
|
|
result = await adapter._check_security(msg)
|
|
assert result is True
|
|
|
|
|
|
# ==================== 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)
|
|
from yuxi.channels.models import DeliveryResult
|
|
return DeliveryResult(success=True, message_id=f"msg_{len(sent_messages)}")
|
|
|
|
await send_blocks_stream(
|
|
"chat_001", text, mock_send,
|
|
channel_id="qqbot", channel_type=ChannelType.QQ_BOT,
|
|
chunk_size=1,
|
|
)
|
|
assert len(sent_messages) > 0
|
|
full_text = " ".join(sent_messages)
|
|
assert "Para 1" in full_text
|
|
assert "Para 2" in full_text
|
|
assert "Para 3" in full_text
|
|
|
|
|
|
# ==================== Send URL Resolution Tests ====================
|
|
|
|
|
|
class TestSendUrlResolution:
|
|
def test_group_chat_prefix(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "group_openid123")
|
|
assert "/v2/groups/openid123/messages" in url
|
|
|
|
def test_channel_id(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "channel_xyz")
|
|
assert "/v2/channels/channel_xyz/messages" in url
|
|
|
|
def test_direct_c2c(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "")
|
|
assert "/v2/users/@me/messages" in url
|
|
|
|
|
|
# ==================== Token Manager with Shared Session ====================
|
|
|
|
|
|
class TestTokenManagerSharedSession:
|
|
@pytest.mark.asyncio
|
|
async def test_with_external_session(self):
|
|
mock_session = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(return_value={
|
|
"access_token": "token123",
|
|
"expires_in": 7200,
|
|
})
|
|
mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_session.post.return_value.__aexit__ = AsyncMock()
|
|
|
|
tm = QQBotTokenManager(
|
|
app_id="test_app",
|
|
app_secret="test_secret",
|
|
http_client=mock_session,
|
|
)
|
|
token = await tm.get_token()
|
|
assert token == "token123"
|
|
mock_session.post.assert_called_once()
|
|
|
|
|
|
# ==================== Webhook Ed25519 Tests ====================
|
|
|
|
|
|
class TestWebhookSignature:
|
|
def test_missing_headers_returns_false(self):
|
|
from yuxi.channels.adapters.qqbot.security import verify_webhook_ed25519
|
|
|
|
result = verify_webhook_ed25519({}, b"body", "secret")
|
|
assert result is False
|
|
|
|
def test_valid_signature(self):
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
from yuxi.channels.adapters.qqbot.security import verify_webhook_ed25519
|
|
|
|
private_key = Ed25519PrivateKey.generate()
|
|
seed = private_key.private_bytes_raw().hex()
|
|
|
|
timestamp = "1750407202"
|
|
body = b'{"d":{"plain_token":"test"}}'
|
|
message = timestamp.encode() + body
|
|
signature = private_key.sign(message)
|
|
|
|
headers = {
|
|
"x-signature-ed25519": signature.hex(),
|
|
"x-signature-timestamp": timestamp,
|
|
}
|
|
result = verify_webhook_ed25519(headers, body, seed)
|
|
assert result is True
|
|
|
|
def test_invalid_signature(self):
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
from yuxi.channels.adapters.qqbot.security import verify_webhook_ed25519
|
|
|
|
key1 = Ed25519PrivateKey.generate()
|
|
key2 = Ed25519PrivateKey.generate()
|
|
|
|
seed = key1.private_bytes_raw().hex()
|
|
timestamp = "1750407202"
|
|
body = b'{"d":{"plain_token":"test"}}'
|
|
message = timestamp.encode() + body
|
|
signature = key2.sign(message)
|
|
|
|
headers = {
|
|
"x-signature-ed25519": signature.hex(),
|
|
"x-signature-timestamp": timestamp,
|
|
}
|
|
result = verify_webhook_ed25519(headers, body, seed)
|
|
assert result is False
|
|
|
|
|
|
# ==================== Health Check Tests ====================
|
|
|
|
|
|
class TestHealthCheck:
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_protection(self):
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from yuxi.channels.adapters.qqbot.probe import health_check_dsm
|
|
|
|
with patch("aiohttp.ClientSession") as mock_session_cls:
|
|
mock_session = AsyncMock()
|
|
mock_session_cls.return_value.__aenter__.return_value = mock_session
|
|
mock_get = AsyncMock()
|
|
mock_get.__aenter__.side_effect = Exception("Connection timeout")
|
|
mock_session.get.return_value = mock_get
|
|
|
|
status = await health_check_dsm(
|
|
"https://api.sgroup.qq.com",
|
|
"token",
|
|
sandbox=False,
|
|
)
|
|
assert status.status == "unhealthy"
|
|
|
|
|
|
# ==================== Image Upload with Shared Session ====================
|
|
|
|
|
|
class TestImageUpload:
|
|
@pytest.mark.asyncio
|
|
async def test_upload_with_shared_session(self):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from yuxi.channels.adapters.qqbot.media_upload import upload_image
|
|
|
|
mock_session = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(return_value={"file_uuid": "file_001"})
|
|
mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_session.post.return_value.__aexit__ = AsyncMock()
|
|
|
|
file_id = await upload_image(
|
|
b"fake_image_data",
|
|
"token",
|
|
http_client=mock_session,
|
|
)
|
|
assert file_id == "file_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_with_shared_session(self):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from yuxi.channels.adapters.qqbot.media_upload import download_image
|
|
|
|
mock_session = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = 200
|
|
mock_resp.read = AsyncMock(return_value=b"image_bytes")
|
|
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
mock_session.get.return_value.__aexit__ = AsyncMock()
|
|
|
|
data = await download_image(
|
|
"file_001",
|
|
"token",
|
|
http_client=mock_session,
|
|
)
|
|
assert data == b"image_bytes"
|
|
|
|
|
|
# ==================== Message Dedup Tests ====================
|
|
|
|
|
|
class TestMessageDedup:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return QQBotAdapter(config={"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dedup_stores_msg_id(self, adapter):
|
|
await adapter._dispatch_event("at_message_create", {
|
|
"id": "msg_001",
|
|
"content": "Hello",
|
|
"author": {"id": "user_001"},
|
|
"group_openid": "group_001",
|
|
"timestamp": "2026-05-08T12:00:00",
|
|
})
|
|
assert "msg_001" in adapter._recent_msg_ids
|
|
|
|
def test_dedup_prunes_expired_ids(self, adapter):
|
|
import time
|
|
adapter._recent_msg_ids = {
|
|
"old_msg": time.monotonic() - 3600,
|
|
"recent_msg": time.monotonic() - 10,
|
|
}
|
|
adapter._prune_old_msg_ids(time.monotonic())
|
|
assert "old_msg" not in adapter._recent_msg_ids
|
|
assert "recent_msg" in adapter._recent_msg_ids
|
|
|
|
def test_dedup_config_default_window(self):
|
|
adapter = QQBotAdapter(config={"app_id": "123", "app_secret": "abc"})
|
|
assert adapter._dedup_window_s == 60
|
|
|
|
def test_dedup_config_custom_window(self):
|
|
adapter = QQBotAdapter(config={"app_id": "123", "app_secret": "abc", "dedup_window_s": 120})
|
|
assert adapter._dedup_window_s == 120
|
|
|
|
|
|
# ==================== Edit/Delete Message Tests ====================
|
|
|
|
|
|
class TestEditDeleteMessage:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return QQBotAdapter(config={"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_group_message_returns_error(self, adapter):
|
|
adapter._http_client = AsyncMock()
|
|
adapter._token_manager = AsyncMock()
|
|
adapter._token_manager.get_token.return_value = "mock_token"
|
|
adapter._token_manager.api_base = "https://api.test.com"
|
|
|
|
result = await adapter.edit_message("group_openid123", "msg_001", "edited content")
|
|
assert result.success is False
|
|
assert "group" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_group_message_returns_error(self, adapter):
|
|
adapter._http_client = AsyncMock()
|
|
adapter._token_manager = AsyncMock()
|
|
adapter._token_manager.get_token.return_value = "mock_token"
|
|
adapter._token_manager.api_base = "https://api.test.com"
|
|
|
|
result = await adapter.delete_message("group_openid123", "msg_001")
|
|
assert result.success is False
|
|
assert "group" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_without_client_returns_error(self, adapter):
|
|
result = await adapter.edit_message("channel_001", "msg_001", "content")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_without_client_returns_error(self, adapter):
|
|
result = await adapter.delete_message("channel_001", "msg_001")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_channel_message_success(self, adapter):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
adapter._http_client = MagicMock()
|
|
adapter._token_manager = MagicMock()
|
|
adapter._token_manager.get_token = AsyncMock(return_value="mock_token")
|
|
adapter._token_manager.api_base = "https://api.test.com"
|
|
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
adapter._http_client.patch.return_value.__aenter__.return_value = mock_resp
|
|
|
|
result = await adapter.edit_message("channel_001", "msg_001", "new content")
|
|
assert result.success is True
|
|
assert result.message_id == "msg_001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_channel_message_success(self, adapter):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
adapter._http_client = MagicMock()
|
|
adapter._token_manager = MagicMock()
|
|
adapter._token_manager.get_token = AsyncMock(return_value="mock_token")
|
|
adapter._token_manager.api_base = "https://api.test.com"
|
|
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
adapter._http_client.delete.return_value.__aenter__.return_value = mock_resp
|
|
|
|
result = await adapter.delete_message("channel_001", "msg_001")
|
|
assert result.success is True
|
|
assert result.message_id == "msg_001"
|
|
|
|
|
|
# ==================== Member Event Tests ====================
|
|
|
|
|
|
class TestMemberEvents:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return QQBotAdapter(config={"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"})
|
|
|
|
def test_guild_member_add_returns_joined_event(self, adapter):
|
|
raw = {
|
|
"event_type": "guild_member_add",
|
|
"event": {
|
|
"id": "evt_001",
|
|
"user": {"id": "user_new"},
|
|
"guild_id": "guild_001",
|
|
"channel_id": "ch_001",
|
|
"guild": {"name": "测试频道"},
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type.value == "member.joined"
|
|
assert msg.metadata["guild_id"] == "guild_001"
|
|
assert msg.metadata["guild_name"] == "测试频道"
|
|
|
|
def test_guild_member_remove_returns_left_event(self, adapter):
|
|
raw = {
|
|
"event_type": "guild_member_remove",
|
|
"event": {
|
|
"id": "evt_002",
|
|
"user": {"id": "user_left"},
|
|
"guild_id": "guild_001",
|
|
"channel_id": "ch_001",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type.value == "member.left"
|
|
|
|
def test_group_add_robot_returns_joined_event(self, adapter):
|
|
raw = {
|
|
"event_type": "group_add_robot",
|
|
"event": {
|
|
"id": "evt_003",
|
|
"op_user": {"id": "admin_001"},
|
|
"group_openid": "group_001",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type.value == "member.joined"
|
|
assert msg.identity.channel_chat_id == "group_group_001"
|
|
|
|
def test_group_del_robot_returns_left_event(self, adapter):
|
|
raw = {
|
|
"event_type": "group_del_robot",
|
|
"event": {
|
|
"id": "evt_004",
|
|
"op_user": {"id": "admin_001"},
|
|
"group_openid": "group_001",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type.value == "member.left"
|
|
|
|
def test_message_delete_returns_deleted_event(self, adapter):
|
|
raw = {
|
|
"event_type": "message_delete",
|
|
"event": {
|
|
"id": "evt_005",
|
|
"op_user": {"id": "op_001"},
|
|
"channel_id": "ch_001",
|
|
"guild_id": "guild_001",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type.value == "message.deleted"
|
|
|
|
def test_member_event_has_correct_channel_user_id(self, adapter):
|
|
raw = {
|
|
"event_type": "guild_member_add",
|
|
"event": {
|
|
"id": "evt_006",
|
|
"user": {"id": "user_new", "username": "新成员"},
|
|
"guild_id": "guild_001",
|
|
},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.identity.channel_user_id == "user_new"
|
|
|
|
|
|
# ==================== Reconnect Close Code Tests ====================
|
|
|
|
|
|
class TestCloseCodeClassification:
|
|
def test_classify_fatal_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(4013) == CloseCodeCategory.FATAL
|
|
assert classify_close_code(4014) == CloseCodeCategory.FATAL
|
|
assert classify_close_code(4100) == CloseCodeCategory.FATAL
|
|
assert classify_close_code(4101) == CloseCodeCategory.FATAL
|
|
|
|
def test_classify_recoverable_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(4000) == CloseCodeCategory.RECOVERABLE
|
|
assert classify_close_code(4001) == CloseCodeCategory.RECOVERABLE
|
|
assert classify_close_code(4002) == CloseCodeCategory.RECOVERABLE
|
|
assert classify_close_code(4012) == CloseCodeCategory.RECOVERABLE
|
|
|
|
def test_classify_rate_limited_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(4008) == CloseCodeCategory.RATE_LIMITED
|
|
assert classify_close_code(4009) == CloseCodeCategory.RATE_LIMITED
|
|
|
|
def test_classify_server_error_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(4900) == CloseCodeCategory.SERVER_ERROR
|
|
assert classify_close_code(4905) == CloseCodeCategory.SERVER_ERROR
|
|
assert classify_close_code(4913) == CloseCodeCategory.SERVER_ERROR
|
|
|
|
def test_classify_none_code_returns_abnormal(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(None) == CloseCodeCategory.ABNORMAL
|
|
|
|
def test_classify_unknown_4xxx_returns_server_side(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(4099) == CloseCodeCategory.SERVER_SIDE
|
|
assert classify_close_code(4200) == CloseCodeCategory.SERVER_SIDE
|
|
|
|
def test_classify_unknown_code_returns_abnormal(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import classify_close_code, CloseCodeCategory
|
|
|
|
assert classify_close_code(1000) == CloseCodeCategory.ABNORMAL
|
|
assert classify_close_code(3000) == CloseCodeCategory.ABNORMAL
|
|
assert classify_close_code(5000) == CloseCodeCategory.ABNORMAL
|
|
|
|
|
|
class TestReconnectManagerServerError:
|
|
@pytest.mark.asyncio
|
|
async def test_server_error_transitions_to_backoff(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, ReconnectState
|
|
|
|
mgr = QQBotReconnectManager(max_retries=5, base_delay=0.01, max_delay=0.1)
|
|
await mgr.transition(ReconnectState.CONNECTED)
|
|
mgr._session_id = "session_001"
|
|
mgr._last_seq = 10
|
|
|
|
await mgr.on_disconnect(4900)
|
|
|
|
assert mgr.state == ReconnectState.IDENTIFYING
|
|
assert mgr._retry_count == 1
|
|
assert mgr._session_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_error_exhausts_retries(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, ReconnectState
|
|
|
|
mgr = QQBotReconnectManager(max_retries=2, base_delay=0.01, max_delay=0.1)
|
|
await mgr.transition(ReconnectState.CONNECTED)
|
|
|
|
await mgr.on_disconnect(4900)
|
|
assert mgr.state == ReconnectState.IDENTIFYING
|
|
|
|
await mgr.transition(ReconnectState.CONNECTED)
|
|
await mgr.on_disconnect(4901)
|
|
assert mgr.state == ReconnectState.FROZEN
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fatal_code_freezes_immediately(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, ReconnectState
|
|
|
|
mgr = QQBotReconnectManager(max_retries=5)
|
|
await mgr.transition(ReconnectState.CONNECTED)
|
|
|
|
await mgr.on_disconnect(4100)
|
|
assert mgr.state == ReconnectState.FROZEN
|
|
|
|
|
|
# ==================== Credential Backup Tests ====================
|
|
|
|
|
|
class TestCredentialBackup:
|
|
@pytest.fixture
|
|
def tmp_backup_dir(self, tmp_path):
|
|
return str(tmp_path / "credential_backup")
|
|
|
|
def test_save_and_restore(self, tmp_backup_dir):
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialBackup, CredentialSnapshot
|
|
|
|
backup = CredentialBackup("test_app_id", tmp_backup_dir)
|
|
snapshot = CredentialSnapshot(
|
|
app_id="test_app_id",
|
|
app_secret="test_secret_123",
|
|
access_token="token_abc",
|
|
expires_at=999999999.0,
|
|
sandbox=True,
|
|
session_id="session_xyz",
|
|
)
|
|
|
|
assert backup.save(snapshot) is True
|
|
|
|
restored = backup.restore()
|
|
assert restored is not None
|
|
assert restored.app_id == "test_app_id"
|
|
assert restored.app_secret == "test_secret_123"
|
|
assert restored.access_token == "token_abc"
|
|
assert restored.sandbox is True
|
|
assert restored.session_id == "session_xyz"
|
|
|
|
def test_restore_nonexistent(self, tmp_backup_dir):
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialBackup
|
|
|
|
backup = CredentialBackup("nonexistent_app", tmp_backup_dir)
|
|
assert backup.restore() is None
|
|
|
|
def test_clear_removes_backup(self, tmp_backup_dir):
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialBackup, CredentialSnapshot
|
|
|
|
backup = CredentialBackup("test_app_id", tmp_backup_dir)
|
|
backup.save(CredentialSnapshot(app_id="test_app_id", app_secret="secret"))
|
|
|
|
assert backup.restore() is not None
|
|
assert backup.clear() is True
|
|
assert backup.restore() is None
|
|
|
|
def test_token_expired(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialSnapshot
|
|
|
|
snapshot = CredentialSnapshot(
|
|
app_id="test", app_secret="secret",
|
|
access_token="tok", expires_at=time.monotonic() - 100,
|
|
)
|
|
assert snapshot.token_expired() is True
|
|
|
|
snapshot_valid = CredentialSnapshot(
|
|
app_id="test", app_secret="secret",
|
|
access_token="tok", expires_at=time.monotonic() + 7200,
|
|
)
|
|
assert snapshot_valid.token_expired() is False
|
|
|
|
def test_snapshot_invalid_without_credentials(self):
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialSnapshot
|
|
|
|
assert CredentialSnapshot().is_valid() is False
|
|
assert CredentialSnapshot(app_id="test").is_valid() is False
|
|
assert CredentialSnapshot(app_id="test", app_secret="secret").is_valid() is True
|
|
|
|
def test_cleanup_expired(self, tmp_backup_dir):
|
|
import os
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialBackup, CredentialSnapshot
|
|
|
|
backup = CredentialBackup("old_app", tmp_backup_dir)
|
|
backup.save(CredentialSnapshot(app_id="old_app", app_secret="old_secret"))
|
|
|
|
filepath = os.path.join(tmp_backup_dir, "old_app.json")
|
|
mtime_back = time.time() - 86400 * 30
|
|
os.utime(filepath, (mtime_back, mtime_back))
|
|
|
|
removed = CredentialBackup.cleanup_expired(tmp_backup_dir, max_age_s=86400 * 7)
|
|
assert removed == 1
|
|
assert not os.path.exists(filepath)
|
|
|
|
|
|
# ==================== Capability Declaration Tests ====================
|
|
|
|
|
|
class TestCapabilityDeclarations:
|
|
def test_default_capabilities_edit_unsend_false(self):
|
|
assert QQBotAdapter.capabilities.edit is False
|
|
assert QQBotAdapter.capabilities.unsend is False
|
|
|
|
def test_guild_channel_enables_edit_unsend(self):
|
|
adapter = QQBotAdapter(config={
|
|
"app_id": "test",
|
|
"app_secret": "test",
|
|
"chat_types": ["guild_channel"],
|
|
})
|
|
assert adapter.capabilities.edit is True
|
|
assert adapter.capabilities.unsend is True
|
|
|
|
def test_mixed_chat_types_with_guild_enables_edit_unsend(self):
|
|
adapter = QQBotAdapter(config={
|
|
"app_id": "test",
|
|
"app_secret": "test",
|
|
"chat_types": ["direct", "group", "guild_channel"],
|
|
})
|
|
assert adapter.capabilities.edit is True
|
|
assert adapter.capabilities.unsend is True
|
|
|
|
|
|
# ==================== Pipeline Context Tests ====================
|
|
|
|
|
|
class TestPipelineContext:
|
|
def test_stop_sets_reason(self):
|
|
from yuxi.channels.pipeline.context import PipelineContext
|
|
|
|
ctx = PipelineContext()
|
|
assert ctx.stopped is False
|
|
|
|
ctx.stop("test_reason")
|
|
assert ctx.stopped is True
|
|
assert ctx._stop_reason == "test_reason"
|
|
|
|
def test_debug_summary(self):
|
|
from yuxi.channels.pipeline.context import PipelineContext
|
|
|
|
ctx = PipelineContext(event_type="test", sender_id="user_001", msg_id="msg_001")
|
|
summary = ctx.debug_summary()
|
|
assert "test" in summary
|
|
assert "user_001" in summary
|
|
|
|
|
|
# ==================== Adapter Backup Integration Tests ====================
|
|
|
|
|
|
class TestAdapterCredentialBackup:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return QQBotAdapter(config={"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"})
|
|
|
|
def test_backup_credentials_creates_backup(self, adapter, tmp_path):
|
|
adapter.config["credential_backup_dir"] = str(tmp_path)
|
|
|
|
adapter._token_manager = MagicMock()
|
|
type(adapter._token_manager).access_token = PropertyMock(return_value="mock_token")
|
|
type(adapter._token_manager).expires_at = PropertyMock(return_value=999999999.0)
|
|
adapter._reconnect_manager._session_id = "session_test"
|
|
|
|
adapter._backup_credentials()
|
|
|
|
assert adapter._credential_backup is not None
|
|
restored = adapter._credential_backup.restore()
|
|
assert restored is not None
|
|
assert restored.app_id == "test_app"
|
|
assert restored.access_token == "mock_token"
|
|
assert restored.session_id == "session_test"
|
|
|
|
def test_restore_credentials_no_backup(self, adapter, tmp_path):
|
|
adapter.config["credential_backup_dir"] = str(tmp_path)
|
|
|
|
result = adapter._restore_credentials()
|
|
assert result is False
|
|
|
|
def test_restore_credentials_with_valid_backup(self, adapter, tmp_path):
|
|
from yuxi.channels.adapters.qqbot.credential_backup import CredentialBackup, CredentialSnapshot
|
|
|
|
adapter.config["credential_backup_dir"] = str(tmp_path)
|
|
|
|
backup = CredentialBackup("test_app", str(tmp_path))
|
|
backup.save(CredentialSnapshot(
|
|
app_id="test_app",
|
|
app_secret="test_secret",
|
|
access_token="saved_token",
|
|
expires_at=999999999.0,
|
|
session_id="saved_session",
|
|
))
|
|
|
|
result = adapter._restore_credentials()
|
|
assert result is True
|
|
assert adapter._reconnect_manager._session_id == "saved_session"
|
|
|
|
|
|
# ==================== Session Store Tests ====================
|
|
|
|
|
|
class TestSessionStore:
|
|
@pytest.fixture
|
|
def tmp_store_dir(self, tmp_path):
|
|
return str(tmp_path / "session_store")
|
|
|
|
def test_save_and_load(self, tmp_store_dir):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionStore, SessionRecord
|
|
|
|
store = SessionStore("test_app_id", tmp_store_dir)
|
|
record = SessionRecord(
|
|
session_id="session_abc",
|
|
last_seq=42,
|
|
last_heartbeat=time.monotonic(),
|
|
identify_at=time.time(),
|
|
shard_id=0,
|
|
shard_count=1,
|
|
)
|
|
|
|
assert store.save(record) is True
|
|
|
|
loaded = store.load()
|
|
assert loaded is not None
|
|
assert loaded.session_id == "session_abc"
|
|
assert loaded.last_seq == 42
|
|
assert loaded.shard_id == 0
|
|
|
|
def test_load_nonexistent(self, tmp_store_dir):
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionStore
|
|
|
|
store = SessionStore("nonexistent_app", tmp_store_dir)
|
|
assert store.load() is None
|
|
|
|
def test_clear_removes_session(self, tmp_store_dir):
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionStore, SessionRecord
|
|
|
|
store = SessionStore("test_app", tmp_store_dir)
|
|
store.save(SessionRecord(session_id="session_xyz"))
|
|
|
|
assert store.load() is not None
|
|
assert store.clear() is True
|
|
assert store.load() is None
|
|
|
|
def test_to_dict_and_from_dict(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionRecord
|
|
|
|
record = SessionRecord(
|
|
session_id="sid_001",
|
|
last_seq=100,
|
|
shard_id=1,
|
|
shard_count=2,
|
|
metadata={"key": "value"},
|
|
)
|
|
data = record.to_dict()
|
|
restored = SessionRecord.from_dict(data)
|
|
|
|
assert restored.session_id == "sid_001"
|
|
assert restored.last_seq == 100
|
|
assert restored.shard_id == 1
|
|
assert restored.shard_count == 2
|
|
assert restored.metadata == {"key": "value"}
|
|
|
|
def test_cleanup_expired(self, tmp_store_dir):
|
|
import os
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionStore, SessionRecord
|
|
|
|
store = SessionStore("old_app", tmp_store_dir)
|
|
store.save(SessionRecord(session_id="old_session"))
|
|
|
|
filepath = os.path.join(tmp_store_dir, "old_app_session.json")
|
|
mtime_back = time.time() - 86400 * 30
|
|
os.utime(filepath, (mtime_back, mtime_back))
|
|
|
|
removed = SessionStore.cleanup_expired(tmp_store_dir, max_age_s=86400 * 7)
|
|
assert removed == 1
|
|
assert not os.path.exists(filepath)
|
|
|
|
|
|
# ==================== Known User Tracker Tests ====================
|
|
|
|
|
|
class TestKnownUserTracker:
|
|
@pytest.fixture
|
|
def tmp_persist_dir(self, tmp_path):
|
|
return str(tmp_path / "known_users")
|
|
|
|
def test_record_new_user(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
record = tracker.record("user_001", "Alice", "group")
|
|
|
|
assert record.user_id == "user_001"
|
|
assert record.username == "Alice"
|
|
assert record.message_count == 1
|
|
assert "group" in record.chat_types
|
|
|
|
def test_record_existing_user_updates(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "Alice", "group")
|
|
record = tracker.record("user_001", "Alice_v2", "dm")
|
|
|
|
assert record.message_count == 2
|
|
assert "group" in record.chat_types
|
|
assert "dm" in record.chat_types
|
|
|
|
def test_is_known(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "Alice")
|
|
|
|
assert tracker.is_known("user_001") is True
|
|
assert tracker.is_known("user_999") is False
|
|
|
|
def test_get_user(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "Alice")
|
|
|
|
u = tracker.get("user_001")
|
|
assert u is not None
|
|
assert u.username == "Alice"
|
|
|
|
def test_remove_user(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "Alice")
|
|
|
|
assert tracker.remove("user_001") is True
|
|
assert tracker.is_known("user_001") is False
|
|
assert tracker.remove("user_001") is False
|
|
|
|
def test_max_users_eviction(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", max_users=3, persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "A")
|
|
tracker.record("user_002", "B")
|
|
tracker.record("user_003", "C")
|
|
tracker.record("user_004", "D")
|
|
|
|
assert tracker.count == 3
|
|
assert tracker.is_known("user_001") is False
|
|
assert tracker.is_known("user_004") is True
|
|
|
|
def test_persist_and_restore(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker1 = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker1.record("user_001", "Alice")
|
|
tracker1.persist()
|
|
|
|
tracker2 = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
assert tracker2.is_known("user_001") is True
|
|
u = tracker2.get("user_001")
|
|
assert u.username == "Alice"
|
|
|
|
def test_clear(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "Alice")
|
|
tracker.clear()
|
|
|
|
assert tracker.count == 0
|
|
assert tracker.is_known("user_001") is False
|
|
|
|
def test_get_recent_users(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_001", "A")
|
|
tracker.record("user_002", "B")
|
|
tracker.record("user_003", "C")
|
|
|
|
recent = tracker.get_recent_users(limit=2)
|
|
assert len(recent) == 2
|
|
assert recent[0].user_id == "user_003"
|
|
assert recent[1].user_id == "user_002"
|
|
|
|
|
|
# ==================== GroupHistoryBuffer Tests ====================
|
|
|
|
|
|
class TestGroupHistoryBuffer:
|
|
def test_record_and_retrieve(self):
|
|
from yuxi.channels.adapters.qqbot.group_buffer import GroupHistoryBuffer, GroupMessage
|
|
|
|
buf = GroupHistoryBuffer(buffer_limit=10, ttl_seconds=3600)
|
|
msg = GroupMessage(
|
|
msg_id="msg_001", author_id="user_001",
|
|
author_name="Alice", content="Hello",
|
|
timestamp=1000.0, mentions_bot=True,
|
|
)
|
|
buf.record("group_abc", msg)
|
|
|
|
context = buf.recent_context("group_abc", count=5)
|
|
assert len(context) == 1
|
|
assert context[0].content == "Hello"
|
|
assert context[0].mentions_bot is True
|
|
|
|
def test_buffer_limit(self):
|
|
from yuxi.channels.adapters.qqbot.group_buffer import GroupHistoryBuffer, GroupMessage
|
|
|
|
buf = GroupHistoryBuffer(buffer_limit=3)
|
|
for i in range(5):
|
|
buf.record("group_abc", GroupMessage(
|
|
msg_id=f"msg_{i}", author_id="user_001",
|
|
author_name="A", content=f"Content {i}", timestamp=float(i),
|
|
))
|
|
|
|
context = buf.recent_context("group_abc", count=10)
|
|
assert len(context) == 3
|
|
assert context[0].msg_id == "msg_2"
|
|
assert context[-1].msg_id == "msg_4"
|
|
|
|
def test_expired_session(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.group_buffer import GroupHistoryBuffer, GroupMessage
|
|
|
|
buf = GroupHistoryBuffer(ttl_seconds=0.001)
|
|
buf.record("group_abc", GroupMessage(
|
|
msg_id="msg_001", author_id="user_001",
|
|
author_name="A", content="Hi", timestamp=time.time(),
|
|
))
|
|
|
|
time.sleep(0.01)
|
|
|
|
context = buf.recent_context("group_abc")
|
|
assert len(context) == 0
|
|
|
|
def test_gc_cleans_expired(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.group_buffer import GroupHistoryBuffer, GroupMessage
|
|
|
|
buf = GroupHistoryBuffer(ttl_seconds=0.001)
|
|
buf.record("group_abc", GroupMessage(
|
|
msg_id="msg_001", author_id="user_001",
|
|
author_name="A", content="Hi", timestamp=time.time(),
|
|
))
|
|
buf.record("group_xyz", GroupMessage(
|
|
msg_id="msg_002", author_id="user_002",
|
|
author_name="B", content="Hey", timestamp=time.time() - 3600,
|
|
))
|
|
|
|
time.sleep(0.01)
|
|
|
|
removed = buf.gc()
|
|
assert removed >= 1
|
|
|
|
def test_unknown_group_returns_empty(self):
|
|
from yuxi.channels.adapters.qqbot.group_buffer import GroupHistoryBuffer
|
|
|
|
buf = GroupHistoryBuffer()
|
|
assert buf.recent_context("unknown_group") == []
|
|
|
|
|
|
# ==================== Server Error Classification Tests ====================
|
|
|
|
|
|
class TestServerErrorClassification:
|
|
def test_classify_overload_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4901) == ServerErrorCategory.OVERLOAD
|
|
assert classify_server_error_category(4904) == ServerErrorCategory.OVERLOAD
|
|
assert classify_server_error_category(4912) == ServerErrorCategory.OVERLOAD
|
|
|
|
def test_classify_maintenance_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4902) == ServerErrorCategory.MAINTENANCE
|
|
assert classify_server_error_category(4905) == ServerErrorCategory.MAINTENANCE
|
|
|
|
def test_classify_network_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4903) == ServerErrorCategory.NETWORK
|
|
assert classify_server_error_category(4907) == ServerErrorCategory.NETWORK
|
|
|
|
def test_classify_unavailable_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4908) == ServerErrorCategory.UNAVAILABLE
|
|
|
|
def test_classify_timeout_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4913) == ServerErrorCategory.TIMEOUT
|
|
|
|
def test_classify_internal_codes(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4900) == ServerErrorCategory.INTERNAL
|
|
assert classify_server_error_category(4910) == ServerErrorCategory.INTERNAL
|
|
|
|
def test_classify_unknown_server_code(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
classify_server_error_category, ServerErrorCategory,
|
|
)
|
|
|
|
assert classify_server_error_category(4999) == ServerErrorCategory.UNKNOWN
|
|
|
|
def test_get_server_error_name(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import get_server_error_name
|
|
|
|
assert get_server_error_name(4900) == "server_internal_error"
|
|
assert get_server_error_name(4901) == "server_overload"
|
|
assert get_server_error_name(4913) == "server_timeout"
|
|
assert get_server_error_name(9999) == "server_error_9999"
|
|
|
|
def test_calc_delay_for_server_error(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import (
|
|
QQBotReconnectManager,
|
|
classify_server_error_category,
|
|
)
|
|
|
|
mgr = QQBotReconnectManager(base_delay=1.0, jitter=0)
|
|
|
|
cat = classify_server_error_category(4901)
|
|
delay = mgr._calc_delay_for_server_error(cat)
|
|
assert delay > mgr._calc_delay()
|
|
|
|
cat_maintenance = classify_server_error_category(4902)
|
|
delay_m = mgr._calc_delay_for_server_error(cat_maintenance)
|
|
assert delay_m > delay
|
|
|
|
|
|
# ==================== Rapid Disconnect Detection Tests ====================
|
|
|
|
|
|
class TestRapidDisconnectDetection:
|
|
@pytest.mark.asyncio
|
|
async def test_no_warning_on_normal_disconnect(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, ReconnectState
|
|
|
|
mgr = QQBotReconnectManager(max_retries=5, base_delay=0.01)
|
|
mgr._last_connect_time = 100.0
|
|
|
|
await mgr.on_disconnect(4000)
|
|
assert mgr._rapid_disconnect_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detects_rapid_disconnect(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, ReconnectState
|
|
|
|
mgr = QQBotReconnectManager(max_retries=5, base_delay=0.01)
|
|
mgr._last_connect_time = time.monotonic()
|
|
|
|
await mgr.on_disconnect(4000)
|
|
assert mgr._rapid_disconnect_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rapid_disconnect_threshold_reset(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, CloseCodeCategory
|
|
|
|
mgr = QQBotReconnectManager(max_retries=5)
|
|
mgr._last_connect_time = 100.0
|
|
|
|
mgr._check_rapid_disconnect(4000, CloseCodeCategory.RECOVERABLE, 103.0)
|
|
assert mgr._rapid_disconnect_count == 1
|
|
|
|
mgr._check_rapid_disconnect(4000, CloseCodeCategory.RECOVERABLE, 107.0)
|
|
assert mgr._rapid_disconnect_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_connected_resets_counter(self):
|
|
import time
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager
|
|
|
|
mgr = QQBotReconnectManager()
|
|
mgr._rapid_disconnect_count = 5
|
|
mgr.mark_connected()
|
|
|
|
assert mgr._rapid_disconnect_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fatal_code_skips_rapid_detection(self):
|
|
from yuxi.channels.adapters.qqbot.reconnect import QQBotReconnectManager, CloseCodeCategory
|
|
|
|
mgr = QQBotReconnectManager()
|
|
mgr._last_connect_time = 100.0
|
|
|
|
mgr._check_rapid_disconnect(4100, CloseCodeCategory.FATAL, 102.0)
|
|
assert mgr._rapid_disconnect_count == 0
|
|
|
|
|
|
# ==================== Audio Format Policy Tests ====================
|
|
|
|
|
|
class TestAudioFormatPolicy:
|
|
def test_default_policy(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
policy = AudioFormatPolicy()
|
|
assert policy.transcode_enabled is True
|
|
assert policy.upload_direct_formats == ["wav", "mp3"]
|
|
assert policy.stt_direct_formats == ["wav", "mp3", "pcm"]
|
|
assert policy.fallback_format == AudioFormat.MP3
|
|
|
|
def test_needs_transcode_direct_format(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
policy = AudioFormatPolicy()
|
|
assert policy.needs_transcode(AudioFormat.MP3) is False
|
|
assert policy.needs_transcode(AudioFormat.WAV) is False
|
|
|
|
def test_needs_transcode_indirect_format(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
policy = AudioFormatPolicy()
|
|
assert policy.needs_transcode(AudioFormat.SILK) is True
|
|
assert policy.needs_transcode(AudioFormat.AAC) is True
|
|
|
|
def test_needs_transcode_disabled(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
policy = AudioFormatPolicy(transcode_enabled=False)
|
|
assert policy.needs_transcode(AudioFormat.SILK) is False
|
|
|
|
def test_can_stt_direct(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
policy = AudioFormatPolicy()
|
|
assert policy.can_stt_direct(AudioFormat.WAV) is True
|
|
assert policy.can_stt_direct(AudioFormat.MP3) is True
|
|
assert policy.can_stt_direct(AudioFormat.PCM) is True
|
|
assert policy.can_stt_direct(AudioFormat.SILK) is False
|
|
|
|
def test_from_config(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy, AudioFormat
|
|
|
|
config = {
|
|
"audio_format_policy": {
|
|
"transcode_enabled": False,
|
|
"upload_direct_formats": ["wav"],
|
|
"stt_direct_formats": ["wav", "mp3"],
|
|
"fallback_format": "wav",
|
|
"sample_rate": 44100,
|
|
"channels": 2,
|
|
"bitrate": 64000,
|
|
}
|
|
}
|
|
policy = AudioFormatPolicy.from_config(config)
|
|
assert policy.transcode_enabled is False
|
|
assert policy.upload_direct_formats == ["wav"]
|
|
assert policy.stt_direct_formats == ["wav", "mp3"]
|
|
assert policy.fallback_format == AudioFormat.WAV
|
|
assert policy.sample_rate == 44100
|
|
assert policy.channels == 2
|
|
assert policy.bitrate == 64000
|
|
|
|
def test_from_config_empty(self):
|
|
from yuxi.channels.adapters.qqbot.audio import AudioFormatPolicy
|
|
|
|
policy = AudioFormatPolicy.from_config(None)
|
|
assert policy.transcode_enabled is True
|
|
policy2 = AudioFormatPolicy.from_config({})
|
|
assert policy2.transcode_enabled is True
|
|
|
|
|
|
# ==================== TTS Provider Tests ====================
|
|
|
|
|
|
class TestTTSProvider:
|
|
def test_default_config(self):
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider, AudioFormat
|
|
|
|
provider = TTSProvider()
|
|
assert provider._default_voice == "zh-CN-XiaoxiaoNeural"
|
|
assert provider._default_format == AudioFormat.MP3
|
|
|
|
def test_custom_config(self):
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider, AudioFormat
|
|
|
|
provider = TTSProvider(
|
|
default_voice="en-US-JennyNeural",
|
|
default_format=AudioFormat.WAV,
|
|
)
|
|
assert provider._default_voice == "en-US-JennyNeural"
|
|
assert provider._default_format == AudioFormat.WAV
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_builtin_returns_bytes(self):
|
|
from unittest.mock import patch
|
|
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
provider = TTSProvider()
|
|
|
|
with patch.object(provider, "_synthesize_builtin") as mock_synth:
|
|
mock_synth.return_value = b"mock_audio_bytes"
|
|
|
|
result = await provider.synthesize("你好世界")
|
|
assert result == b"mock_audio_bytes"
|
|
mock_synth.assert_called_once_with("你好世界")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_azure(self):
|
|
from unittest.mock import patch
|
|
|
|
import os
|
|
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
with patch.dict(os.environ, {"QQBOT_TTS_PROVIDER": "azure"}, clear=False):
|
|
provider = TTSProvider()
|
|
|
|
with patch.object(provider, "_synthesize_azure") as mock_synth:
|
|
mock_synth.return_value = b"azure_audio_bytes"
|
|
|
|
result = await provider.synthesize("Hello")
|
|
assert result == b"azure_audio_bytes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_edge(self):
|
|
from unittest.mock import patch
|
|
|
|
import os
|
|
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
with patch.dict(os.environ, {"QQBOT_TTS_PROVIDER": "edge"}, clear=False):
|
|
provider = TTSProvider()
|
|
|
|
with patch.object(provider, "_synthesize_edge") as mock_synth:
|
|
mock_synth.return_value = b"edge_audio_bytes"
|
|
|
|
result = await provider.synthesize("Hello")
|
|
assert result == b"edge_audio_bytes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_uses_cache(self):
|
|
from unittest.mock import patch
|
|
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
provider = TTSProvider()
|
|
|
|
with patch.object(provider, "_synthesize_builtin") as mock_synth:
|
|
mock_synth.return_value = b"cached_bytes"
|
|
|
|
result1 = await provider.synthesize("test text")
|
|
result2 = await provider.synthesize("test text")
|
|
|
|
assert result1 == b"cached_bytes"
|
|
assert result2 == b"cached_bytes"
|
|
mock_synth.assert_called_once()
|
|
|
|
def test_clear_cache(self):
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
provider = TTSProvider()
|
|
provider._cache["test_key"] = b"test_data"
|
|
provider.clear_cache()
|
|
assert len(provider._cache) == 0
|
|
|
|
def test_generate_silence(self):
|
|
from yuxi.channels.adapters.qqbot.audio import TTSProvider
|
|
|
|
silence = TTSProvider._generate_silence(1.0)
|
|
assert len(silence) == 32000
|
|
|
|
|
|
# ==================== STT Provider Tests ====================
|
|
|
|
|
|
class TestSTTProvider:
|
|
def test_default_config(self):
|
|
from yuxi.channels.adapters.qqbot.audio import STTProvider
|
|
|
|
provider = STTProvider()
|
|
assert provider._provider == "builtin"
|
|
|
|
def test_from_config(self):
|
|
from yuxi.channels.adapters.qqbot.audio import STTProvider
|
|
|
|
config = {
|
|
"stt": {
|
|
"provider": "azure",
|
|
"api_key": "test_key",
|
|
"region": "eastasia",
|
|
"model": "base",
|
|
}
|
|
}
|
|
provider = STTProvider.from_config(config)
|
|
assert provider._provider == "azure"
|
|
assert provider._api_key == "test_key"
|
|
assert provider._region == "eastasia"
|
|
assert provider._model == "base"
|
|
|
|
def test_from_config_empty(self):
|
|
from yuxi.channels.adapters.qqbot.audio import STTProvider
|
|
|
|
provider = STTProvider.from_config(None)
|
|
assert provider._provider == "builtin"
|
|
assert provider._api_key == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcribe_builtin_returns_string(self):
|
|
from unittest.mock import patch
|
|
|
|
from yuxi.channels.adapters.qqbot.audio import STTProvider
|
|
|
|
provider = STTProvider()
|
|
|
|
with patch.object(provider, "_transcribe_builtin") as mock_stt:
|
|
mock_stt.return_value = "你好世界"
|
|
|
|
result = await provider.transcribe(b"fake_audio")
|
|
assert result == "你好世界"
|
|
|
|
|
|
# ==================== Adapter TTS/STT Integration Tests ====================
|
|
|
|
|
|
class TestAdapterTTSIntegration:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return QQBotAdapter(config={"app_id": "test_app", "app_secret": "test_secret", "dm_policy": "open"})
|
|
|
|
def test_tts_provider_initialized(self, adapter):
|
|
assert adapter._tts_provider is not None
|
|
assert adapter._tts_provider._default_voice == "zh-CN-XiaoxiaoNeural"
|
|
|
|
def test_stt_provider_initialized(self, adapter):
|
|
assert adapter._stt_provider is not None
|
|
assert adapter._stt_provider._provider == "builtin"
|
|
|
|
def test_audio_format_policy_initialized(self, adapter):
|
|
assert adapter._audio_format_policy is not None
|
|
assert adapter._audio_format_policy.transcode_enabled is True
|
|
|
|
def test_tts_custom_config(self):
|
|
adapter = QQBotAdapter(config={
|
|
"app_id": "test_app",
|
|
"app_secret": "test_secret",
|
|
"tts_default_voice": "en-US-JennyNeural",
|
|
"tts_default_format": "wav",
|
|
})
|
|
assert adapter._tts_provider._default_voice == "en-US-JennyNeural"
|
|
|
|
def test_stt_custom_config(self):
|
|
adapter = QQBotAdapter(config={
|
|
"app_id": "test_app",
|
|
"app_secret": "test_secret",
|
|
"stt_provider": "azure",
|
|
"stt_api_key": "key123",
|
|
"stt_region": "eastasia",
|
|
})
|
|
assert adapter._stt_provider._provider == "azure"
|
|
assert adapter._stt_provider._api_key == "key123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_tts_voice_without_client(self, adapter):
|
|
result = await adapter.send_tts_voice("chat_001", "Hello voice")
|
|
assert result.success is False
|
|
assert "Client not initialized" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_tts_voice_success(self, adapter):
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
adapter._http_client = MagicMock()
|
|
adapter._token_manager = MagicMock()
|
|
adapter._token_manager.get_token = AsyncMock(return_value="mock_token")
|
|
adapter._token_manager.api_base = "https://api.test.com"
|
|
|
|
with patch.object(adapter._tts_provider, "_synthesize_builtin") as mock_synth:
|
|
mock_synth.return_value = b"fake_voice_data"
|
|
|
|
with patch("yuxi.channels.adapters.qqbot.voice_send.send_voice") as mock_send_voice:
|
|
mock_send_voice.return_value = DeliveryResult(success=True, message_id="msg_voice")
|
|
|
|
result = await adapter.send_tts_voice("group_openid123", "Hello")
|
|
assert result.success is True
|
|
assert result.message_id == "msg_voice"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcribe_voice_returns_text(self, adapter):
|
|
from unittest.mock import patch
|
|
|
|
with patch.object(adapter._stt_provider, "_transcribe_builtin") as mock_stt:
|
|
mock_stt.return_value = "转写结果"
|
|
|
|
result = await adapter.transcribe_voice(b"fake_audio")
|
|
assert result == "转写结果" |