ForcePilot/backend/test/unit/channels/test_qqbot_modules.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
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重连相关测试
2026-05-13 16:43:01 +08:00

1338 lines
47 KiB
Python

from __future__ import annotations
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.models import ChannelMessage, ChannelIdentity, DeliveryResult
# ============================================================================
# Test: CommandRegistry (commands/framework.py)
# ============================================================================
class TestCommandRegistry:
@pytest.fixture
def registry(self):
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
return CommandRegistry()
@pytest.fixture
async def sample_handler(self):
async def handler(ctx):
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
return CommandResult(success=True, message=f"Hello {ctx.command_name}")
return handler
@pytest.mark.asyncio
async def test_register_and_dispatch(self, registry, sample_handler):
registry.register("test", sample_handler, description="Test command", usage="/test")
assert "test" in registry._commands
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
result = await registry.dispatch("test", [], "/test", MagicMock(), MagicMock())
assert isinstance(result, CommandResult)
assert result.success is True
assert "Hello test" in result.message
@pytest.mark.asyncio
async def test_register_alias(self, registry, sample_handler):
registry.register("greet", sample_handler, aliases=["hello", "hi"])
assert "greet" in registry._commands
assert "hello" in registry._commands
assert "hi" in registry._commands
result = await registry.dispatch("hello", [], "/hello", MagicMock(), MagicMock())
assert result.success is True
def test_unregister(self, registry, sample_handler):
async def handler(ctx):
pass
registry.register("rm_test", handler, aliases=["rm_alias"])
registry.unregister("rm_test")
assert "rm_test" not in registry._commands
assert "rm_alias" not in registry._commands
def test_unregister_nonexistent(self, registry):
registry.unregister("nonexistent")
@pytest.mark.asyncio
async def test_dispatch_unknown_command(self, registry):
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
result = await registry.dispatch("unknown", [], "/unknown", MagicMock(), MagicMock())
assert isinstance(result, CommandResult)
assert result.success is False
assert "未知命令" in result.message
@pytest.mark.asyncio
async def test_dispatch_handler_exception(self, registry):
async def failing_handler(ctx):
raise ValueError("Test error")
registry.register("fail", failing_handler)
result = await registry.dispatch("fail", [], "/fail", MagicMock(), MagicMock())
assert result.success is False
assert "Test error" in result.message
def test_resolve_command(self, registry):
result = registry.resolve("/hello world")
assert result == ("hello", ["world"])
def test_resolve_command_no_args(self, registry):
result = registry.resolve("/ping")
assert result == ("ping", [])
def test_resolve_non_command(self, registry):
result = registry.resolve("hello")
assert result is None
def test_resolve_empty_content(self, registry):
result = registry.resolve("/")
assert result is None
def test_resolve_only_slash(self, registry):
result = registry.resolve("/")
assert result is None
def test_command_names(self, registry, sample_handler):
registry.register("cmd1", sample_handler, aliases=["alias1"])
registry.register("cmd2", sample_handler)
names = registry.command_names
assert "cmd1" in names
assert "cmd2" in names
assert "alias1" not in names or (names.count("cmd1") == 1)
def test_register_default_args(self, registry, sample_handler):
registry.register("simple", sample_handler)
cmd = registry._commands["simple"]
assert cmd.description == ""
assert cmd.usage == ""
assert cmd.aliases == []
assert cmd.metadata == {}
# ============================================================================
# Test: Builtin Commands (commands/builtin.py)
# ============================================================================
class TestBuiltinCommands:
@pytest.fixture
def registry(self):
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
reg = CommandRegistry()
from yuxi.channels.adapters.qqbot.commands.builtin import register_builtin_commands
register_builtin_commands(reg)
return reg
@pytest.mark.asyncio
async def test_ping(self, registry):
msg = MagicMock(spec=ChannelMessage)
msg.identity = MagicMock()
msg.identity.channel_user_id = "user_001"
result = await registry.dispatch("bot-ping", [], "/bot-ping", msg, MagicMock())
assert result.success is True
assert "Pong!" in result.message
@pytest.mark.asyncio
async def test_ping_alias(self, registry):
result = await registry.dispatch("ping", [], "/ping", MagicMock(), MagicMock())
assert result.success is True
assert "Pong!" in result.message
@pytest.mark.asyncio
async def test_version(self, registry):
adapter = MagicMock()
adapter._plugin_version = "2.0.0"
adapter._bot_info = {"id": "bot_123", "username": "TestBot"}
result = await registry.dispatch("bot-version", [], "/bot-version", MagicMock(), adapter)
assert result.success is True
assert "2.0.0" in result.message
@pytest.mark.asyncio
async def test_version_no_bot_info(self, registry):
adapter = MagicMock()
adapter._plugin_version = "1.0.0"
adapter._bot_info = None
result = await registry.dispatch("bot-version", [], "/bot-version", MagicMock(), adapter)
assert result.success is True
assert "1.0.0" in result.message
@pytest.mark.asyncio
async def test_help_with_registry(self, registry):
adapter = MagicMock()
adapter._command_registry = registry
result = await registry.dispatch("bot-help", [], "/bot-help", MagicMock(), adapter)
assert result.success is True
assert "可用命令" in result.message
@pytest.mark.asyncio
async def test_help_no_registry(self, registry):
adapter = MagicMock()
adapter._command_registry = None
result = await registry.dispatch("bot-help", [], "/bot-help", MagicMock(), adapter)
assert result.success is True
assert "未初始化" in result.message
@pytest.mark.asyncio
async def test_status(self, registry):
adapter = MagicMock()
adapter.status = "running"
adapter._reconnect_manager = None
adapter._circuit_breaker = None
result = await registry.dispatch("bot-status", [], "/bot-status", MagicMock(), adapter)
assert result.success is True
assert "running" in result.message
@pytest.mark.asyncio
async def test_clearlogs(self, registry):
adapter = MagicMock()
adapter._known_users = None
adapter._group_buffer = None
result = await registry.dispatch("bot-clearlogs", [], "/bot-clearlogs", MagicMock(), adapter)
assert result.success is True
# ============================================================================
# Test: Streaming Command (commands/streaming_cmd.py)
# ============================================================================
class TestStreamingCommand:
@pytest.fixture
def registry(self):
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
reg = CommandRegistry()
from yuxi.channels.adapters.qqbot.commands.streaming_cmd import register_streaming_command
register_streaming_command(reg)
return reg
@pytest.mark.asyncio
async def test_streaming_no_args(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "block"}
result = await registry.dispatch("bot-streaming", [], "/bot-streaming", MagicMock(), adapter)
assert result.success is True
assert "block" in result.message
@pytest.mark.asyncio
async def test_streaming_status(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "off"}
result = await registry.dispatch("bot-streaming", ["status"], "/bot-streaming status", MagicMock(), adapter)
assert result.success is True
assert "off" in result.message
@pytest.mark.asyncio
async def test_streaming_on(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "off"}
adapter.write_config = MagicMock()
result = await registry.dispatch("bot-streaming", ["on"], "/bot-streaming on", MagicMock(), adapter)
assert result.success is True
assert adapter.config["streaming_mode"] == "block"
@pytest.mark.asyncio
async def test_streaming_off(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "block"}
adapter.write_config = MagicMock()
result = await registry.dispatch("bot-streaming", ["off"], "/bot-streaming off", MagicMock(), adapter)
assert result.success is True
assert adapter.config["streaming_mode"] == "off"
@pytest.mark.asyncio
async def test_streaming_invalid(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "off"}
result = await registry.dispatch("bot-streaming", ["invalid"], "/bot-streaming invalid", MagicMock(), adapter)
assert result.success is False
@pytest.mark.asyncio
async def test_streaming_block_keyword(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "off"}
adapter.write_config = MagicMock()
result = await registry.dispatch("bot-streaming", ["block"], "/bot-streaming block", MagicMock(), adapter)
assert result.success is True
assert adapter.config["streaming_mode"] == "block"
@pytest.mark.asyncio
async def test_persist_config_write_error_handled(self, registry):
adapter = MagicMock()
adapter.config = {"streaming_mode": "off"}
def failing_write(key, value):
raise OSError("Write failed")
adapter.write_config = failing_write
result = await registry.dispatch("bot-streaming", ["on"], "/bot-streaming on", MagicMock(), adapter)
assert result.success is True
# ============================================================================
# Test: MessageSeqManager (send.py)
# ============================================================================
class TestMessageSeqManager:
@pytest.fixture
def seq(self):
from yuxi.channels.adapters.qqbot.send import MessageSeqManager
return MessageSeqManager()
@pytest.mark.asyncio
async def test_acquire_active_increments(self, seq):
s1 = await seq.acquire_active()
s2 = await seq.acquire_active()
assert s2 == s1 + 1
@pytest.mark.asyncio
async def test_acquire_passive_decrements(self, seq):
s1 = await seq.acquire_passive()
s2 = await seq.acquire_passive()
assert s2 == s1 - 1
@pytest.mark.asyncio
async def test_active_seq_wraps_at_max(self, seq):
seq._active_seq = seq.MAX_SEQ
s = await seq.acquire_active()
assert s == seq.MAX_SEQ
s2 = await seq.acquire_active()
assert s2 == 1
@pytest.mark.asyncio
async def test_passive_seq_wraps_at_max(self, seq):
seq._passive_seq = seq.MAX_SEQ
s = await seq.acquire_passive()
assert s == seq.MAX_SEQ
s2 = await seq.acquire_passive()
assert s2 == seq.MAX_SEQ - 1
def test_reset(self, seq):
seq._active_seq = 100
seq._passive_seq = -50
seq.reset()
assert seq._active_seq == 1
assert seq._passive_seq == 0
def test_snapshot(self, seq):
seq._active_seq = 42
seq._passive_seq = -7
snap = seq.snapshot()
assert snap["active_seq"] == 42
assert snap["passive_seq"] == -7
def test_restore(self, seq):
seq.restore({"active_seq": 99, "passive_seq": -3})
assert seq._active_seq == 99
assert seq._passive_seq == -3
assert seq.next_seq == 99
# ============================================================================
# Test: resolve_send_url (send.py)
# ============================================================================
class TestResolveSendUrl:
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_test_group")
assert "groups" in url
assert "test_group" in url
def test_dm_chat_prefix(self):
from yuxi.channels.adapters.qqbot.send import resolve_send_url
url = resolve_send_url("https://api.sgroup.qq.com", "dm_test_user")
assert "users" in url
assert "test_user" 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_123")
assert "channels" in url
assert "channel_123" in url
def test_empty_chat_id(self):
from yuxi.channels.adapters.qqbot.send import resolve_send_url
url = resolve_send_url("https://api.sgroup.qq.com", "")
assert "@me" in url
# ============================================================================
# Test: render_reply_payload (send.py)
# ============================================================================
class TestRenderReplyPayload:
def test_basic_payload(self):
from yuxi.channels.adapters.qqbot.send import render_reply_payload
payload = render_reply_payload("Hello", msg_type=0)
assert payload["content"] == "Hello"
assert payload["msg_type"] == 0
def test_with_msg_id(self):
from yuxi.channels.adapters.qqbot.send import render_reply_payload
payload = render_reply_payload("Hello", msg_type=0, msg_id="msg_001")
assert payload["msg_id"] == "msg_001"
def test_chunked_payload(self):
from yuxi.channels.adapters.qqbot.send import render_reply_payload
payload = render_reply_payload(
"Hello", msg_type=0, chunk_index=0, total_chunks=3
)
assert payload["chunk_index"] == 0
assert payload["total_chunks"] == 3
def test_single_chunk_no_chunk_fields(self):
from yuxi.channels.adapters.qqbot.send import render_reply_payload
payload = render_reply_payload("Hello", msg_type=0)
assert "chunk_index" not in payload
assert "total_chunks" not in payload
# ============================================================================
# Test: Payload Builders (format.py)
# ============================================================================
class TestBuildTextPayload:
def test_basic_text(self):
from yuxi.channels.adapters.qqbot.format import build_text_payload
resp = MagicMock()
resp.content = "Hello"
resp.identity = MagicMock()
resp.identity.channel_chat_id = "dm_user"
resp.reply_to_message_id = ""
payload = build_text_payload(resp)
assert payload["content"] == "Hello"
assert payload["msg_type"] == 0
def test_group_chat(self):
from yuxi.channels.adapters.qqbot.format import build_text_payload
resp = MagicMock()
resp.content = "Hi"
resp.identity = MagicMock()
resp.identity.channel_chat_id = "group_abc"
resp.reply_to_message_id = ""
payload = build_text_payload(resp)
assert payload["group_openid"] == "abc"
def test_with_reply(self):
from yuxi.channels.adapters.qqbot.format import build_text_payload
resp = MagicMock()
resp.content = "Re: Hello"
resp.identity = MagicMock()
resp.identity.channel_chat_id = "dm_user"
resp.reply_to_message_id = "msg_007"
payload = build_text_payload(resp)
assert payload["msg_id"] == "msg_007"
def test_truncation(self):
from yuxi.channels.adapters.qqbot.format import build_text_payload, TEXT_CONTENT_LIMIT
long_text = "x" * (TEXT_CONTENT_LIMIT + 100)
resp = MagicMock()
resp.content = long_text
resp.identity = MagicMock()
resp.identity.channel_chat_id = "dm_user"
resp.reply_to_message_id = ""
payload = build_text_payload(resp)
assert len(payload["content"]) == TEXT_CONTENT_LIMIT
class TestBuildMarkdownPayload:
def test_markdown_content(self):
from yuxi.channels.adapters.qqbot.format import build_markdown_payload
resp = MagicMock()
resp.content = "**bold**"
resp.metadata = {}
payload = build_markdown_payload(resp)
assert payload["msg_type"] == 2
assert payload["markdown"]["content"] == "**bold**"
def test_markdown_template(self):
from yuxi.channels.adapters.qqbot.format import build_markdown_payload
resp = MagicMock()
resp.content = "Content here"
resp.metadata = {"markdown_template_id": "tpl_001", "title": "My Title"}
payload = build_markdown_payload(resp)
assert payload["msg_type"] == 2
assert payload["markdown"]["template_id"] == "tpl_001"
params = payload["markdown"]["params"]
assert any(p["key"] == "title" for p in params)
assert any(p["key"] == "content" for p in params)
class TestBuildArkPayload:
def test_ark_payload(self):
from yuxi.channels.adapters.qqbot.format import build_ark_payload
resp = MagicMock()
resp.content = ""
resp.metadata = {"ark_template_id": "ark_001", "ark_data": {"key1": "val1"}}
payload = build_ark_payload(resp)
assert payload["msg_type"] == 3
assert payload["ark"]["template_id"] == "ark_001"
class TestBuildEmbedPayload:
def test_embed_payload(self):
from yuxi.channels.adapters.qqbot.format import build_embed_payload
resp = MagicMock()
resp.content = "Description text"
resp.metadata = {"embed": {"title": "Embed Title", "prompt": "Click here"}}
payload = build_embed_payload(resp)
assert payload["msg_type"] == 4
assert payload["embed"]["title"] == "Embed Title"
assert payload["embed"]["description"] == "Description text"
class TestBuildImagePayload:
def test_image_payload(self):
from yuxi.channels.adapters.qqbot.format import build_image_payload
resp = MagicMock()
resp.content = "Image caption"
resp.identity = MagicMock()
resp.identity.channel_chat_id = "dm_user"
payload = build_image_payload(resp, "file_123")
assert payload["msg_type"] == 1
assert payload["file_image"] == "file_123"
assert payload["content"] == "Image caption"
class TestBuildMediaGenericPayload:
def test_media_generic_payload(self):
from yuxi.channels.adapters.qqbot.format import build_media_generic_payload
resp = MagicMock()
resp.content = "Media caption"
resp.identity = MagicMock()
resp.identity.channel_chat_id = "dm_user"
payload = build_media_generic_payload(resp, "file_abc", msg_type=7)
assert payload["msg_type"] == 7
assert payload["media"]["file_info"] == "file_abc"
# ============================================================================
# Test: MarkdownChunker (format.py)
# ============================================================================
class TestMarkdownChunker:
@pytest.fixture
def chunker(self):
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
return MarkdownChunker(max_chars=500, chunk_overlap=50)
def test_short_text_no_chunk(self):
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
chunker = MarkdownChunker()
chunks = chunker.chunk("Hello world")
assert len(chunks) == 1
assert chunks[0] == "Hello world"
def test_long_text_splits(self, chunker):
text = "Paragraph one.\n\nParagraph two.\n\nParagraph three."
chunks = chunker.chunk(text)
assert isinstance(chunks, list)
assert len(chunks) >= 1
def test_code_block_preserved(self):
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
chunker = MarkdownChunker(max_chars=4096)
text = "Normal text.\n\n```python\nprint('hello')\n```\n\nMore text."
chunks = chunker.chunk(text)
combined = "".join(chunks)
assert "```python" in combined
def test_force_split_large(self, chunker):
long_text = "A" * 1000
chunks = chunker.chunk(long_text)
assert len(chunks) > 1
def test_empty_text(self, chunker):
chunks = chunker.chunk("")
assert len(chunks) == 1
assert chunks[0] == ""
def test_markdown_headers_split(self, chunker):
text = "# Header 1\nContent under header 1.\n\n## Header 2\nContent under header 2."
chunks = chunker.chunk(text)
assert len(chunks) >= 1
# ============================================================================
# Test: MessageCache (message_cache.py)
# ============================================================================
class TestMessageCache:
@pytest.fixture
def cache(self):
from yuxi.channels.adapters.qqbot.message_cache import MessageCache
return MessageCache(max_messages=10, ttl_s=3600.0)
def test_record_sent(self, cache):
result = DeliveryResult(success=True, message_id="msg_001")
msg = cache.record_sent(result, "chat_001", "Hello")
assert msg is not None
assert msg.message_id == "msg_001"
assert msg.status == "sent"
def test_record_sent_failure(self, cache):
result = DeliveryResult(success=False, message_id="msg_002", error="Network error")
msg = cache.record_sent(result, "chat_001", "Hello")
assert msg.status == "failed"
assert msg.error == "Network error"
def test_record_sent_no_message_id(self, cache):
result = DeliveryResult(success=True, message_id="")
msg = cache.record_sent(result, "chat_001", "Hello")
assert msg is None
def test_get_message(self, cache):
result = DeliveryResult(success=True, message_id="msg_003")
cache.record_sent(result, "chat_001", "Test")
msg = cache.get("msg_003")
assert msg is not None
assert msg.chat_id == "chat_001"
def test_get_nonexistent(self, cache):
msg = cache.get("nonexistent")
assert msg is None
def test_get_by_chat(self, cache):
result1 = DeliveryResult(success=True, message_id="msg_a")
cache.record_sent(result1, "chat_001", "A")
result2 = DeliveryResult(success=True, message_id="msg_b")
cache.record_sent(result2, "chat_001", "B")
msgs = cache.get_by_chat("chat_001")
assert len(msgs) == 2
def test_record_update(self, cache):
result = DeliveryResult(success=True, message_id="msg_004")
cache.record_sent(result, "chat_001", "Original")
updated = cache.record_update("msg_004", "Updated")
assert updated is not None
assert updated.content == "Updated"
assert updated.update_count == 1
def test_record_update_nonexistent(self, cache):
updated = cache.record_update("no_such_id", "Content")
assert updated is None
def test_max_messages_eviction(self, cache):
for i in range(15):
result = DeliveryResult(success=True, message_id=f"msg_{i:03d}")
cache.record_sent(result, "chat_001", f"Content {i}")
snapshot = cache.snapshot
assert snapshot["total"] <= 10
def test_cleanup_expired(self, cache):
result = DeliveryResult(success=True, message_id="msg_old")
msg = cache.record_sent(result, "chat_001", "Old")
if msg:
msg.sent_at = time.time() - 7200
count = cache.cleanup_expired()
assert count >= 0
def test_clear(self, cache):
result = DeliveryResult(success=True, message_id="msg_001")
cache.record_sent(result, "chat_001", "Test")
count = cache.clear()
assert count == 1
assert cache.snapshot["total"] == 0
def test_snapshot(self, cache):
result = DeliveryResult(success=True, message_id="msg_001")
cache.record_sent(result, "chat_001", "Test")
snap = cache.snapshot
assert "total" in snap
assert "max" in snap
assert "recent" in snap
def test_content_truncation(self, cache):
long_content = "x" * 1000
result = DeliveryResult(success=True, message_id="msg_long")
msg = cache.record_sent(result, "chat_001", long_content)
assert len(msg.content) == 500
# ============================================================================
# Test: MessageQueue (message_queue.py)
# ============================================================================
class TestMessageQueue:
@pytest.fixture
def make_msg(self):
def _make(user_id="user_001", chat_id="dm_user", content="Hello"):
msg = MagicMock(spec=ChannelMessage)
msg.identity = MagicMock()
msg.identity.channel_user_id = user_id
msg.identity.channel_chat_id = chat_id
msg.content = content
return msg
return _make
@pytest.fixture
def queue(self):
from yuxi.channels.adapters.qqbot.message_queue import MessageQueue
return MessageQueue(global_limit=10, per_user_limit=5, per_group_limit=5)
@pytest.mark.asyncio
async def test_enqueue_dequeue(self, queue, make_msg):
msg = make_msg()
result = await queue.enqueue(msg)
assert result is True
assert queue.size == 1
dequeued = await queue.dequeue()
assert dequeued is msg
assert queue.size == 0
@pytest.mark.asyncio
async def test_per_user_limit(self, queue, make_msg):
queue._per_user_limit = 2
for _ in range(3):
await queue.enqueue(make_msg(user_id="user_001"))
assert queue.size <= 2
@pytest.mark.asyncio
async def test_per_group_limit(self, queue, make_msg):
queue._per_group_limit = 2
for _ in range(3):
await queue.enqueue(make_msg(chat_id="group_abc"))
assert queue.size <= 2
@pytest.mark.asyncio
async def test_acquire_release(self, queue):
result = await queue.acquire()
assert result is True
queue.release()
@pytest.mark.asyncio
async def test_acquire_semaphore(self, queue):
queue._max_concurrency = 1
assert await queue.acquire() is True
@pytest.mark.asyncio
async def test_release_no_acquire(self, queue):
queue.release()
@pytest.mark.asyncio
async def test_drain(self, queue, make_msg):
await queue.enqueue(make_msg())
await queue.enqueue(make_msg())
msgs = await queue.drain()
assert len(msgs) == 2
assert queue.size == 0
@pytest.mark.asyncio
async def test_task_done(self, queue, make_msg):
await queue.enqueue(make_msg())
await queue.dequeue()
queue.task_done()
@pytest.mark.asyncio
async def test_snapshot(self, queue, make_msg):
await queue.enqueue(make_msg())
snap = queue.snapshot
assert snap["queue_size"] == 1
assert "per_user_counts" in snap
assert "per_group_counts" in snap
@pytest.mark.asyncio
async def test_global_limit_overwrite(self, queue, make_msg):
queue._global_limit = 2
for i in range(4):
await queue.enqueue(make_msg(user_id=f"user_{i:03d}"))
assert queue.size <= 2
@pytest.mark.asyncio
async def test_group_limit_only_group_chats(self, queue, make_msg):
queue._per_group_limit = 3
await queue.enqueue(make_msg(chat_id="dm_user"))
await queue.enqueue(make_msg(chat_id="channel_123"))
assert queue.size == 2
# ============================================================================
# Test: TokenBucket (rate_limiter.py)
# ============================================================================
class TestTokenBucket:
@pytest.fixture
def bucket(self):
from yuxi.channels.adapters.qqbot.rate_limiter import TokenBucket
return TokenBucket(rate=100.0, burst=10)
@pytest.mark.asyncio
async def test_initial_tokens(self, bucket):
assert bucket._tokens == 10.0
async def test_try_acquire_sufficient(self, bucket):
assert bucket.try_acquire(5.0) is True
assert bucket._tokens <= 5.0
def test_try_acquire_insufficient(self, bucket):
assert bucket.try_acquire(20.0) is False
@pytest.mark.asyncio
async def test_acquire_waits(self, bucket):
bucket._tokens = 0
bucket.rate = 1000.0
async def add_tokens():
await asyncio.sleep(0.05)
bucket._tokens = 5.0
asyncio.create_task(add_tokens())
await bucket.acquire(1.0)
@pytest.mark.asyncio
async def test_refill(self, bucket):
bucket._tokens = 0
bucket.rate = 100.0
await asyncio.sleep(0.02)
bucket._refill()
assert bucket._tokens > 0
def test_empty_bucket_with_zero_rate(self, bucket):
bucket._tokens = 0
bucket.rate = 0.0
assert bucket.try_acquire(1.0) is False
# ============================================================================
# Test: RouteRateLimiter (rate_limiter.py)
# ============================================================================
class TestRouteRateLimiter:
@pytest.fixture
def limiter(self):
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
return RouteRateLimiter()
def test_try_acquire_default(self, limiter):
assert limiter.try_acquire("default", 1.0) is True
def test_try_acquire_unknown_route(self, limiter):
assert limiter.try_acquire("unknown_route", 1.0) is True
@pytest.mark.asyncio
async def test_acquire_default(self, limiter):
await limiter.acquire("default", 1.0)
def test_get_stats(self, limiter):
limiter.try_acquire("send_message", 1.0)
stats = limiter.get_stats()
assert "send_message" in stats
def test_custom_defaults(self):
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
limiter = RouteRateLimiter(defaults={"custom": (100.0, 10), "default": (10.0, 20)})
result = limiter.try_acquire("custom", 1.0)
assert result is True
def test_default_route(self):
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
limiter = RouteRateLimiter()
result = limiter.try_acquire("send_message", 1.0)
assert result is True
# ============================================================================
# Test: RetryTask & MessageRetryQueue (retry_queue.py)
# ============================================================================
class TestRetryTask:
def test_retry_task_creation(self):
from yuxi.channels.adapters.qqbot.retry_queue import RetryTask
task = RetryTask(
task_id="t_001",
chat_id="chat_001",
payload={"content": "Hello"},
max_attempts=3,
)
assert task.task_id == "t_001"
assert task.attempt == 0
assert task.max_attempts == 3
class TestMessageRetryQueue:
@pytest.fixture
def retry_queue(self):
from yuxi.channels.adapters.qqbot.retry_queue import MessageRetryQueue
return MessageRetryQueue(max_concurrent=2, poll_interval=0.01)
@pytest.fixture
def make_task(self):
from yuxi.channels.adapters.qqbot.retry_queue import RetryTask
def _make(task_id="t_001", chat_id="chat_001", max_attempts=3):
return RetryTask(
task_id=task_id,
chat_id=chat_id,
payload={"content": "Hello"},
max_attempts=max_attempts,
base_delay=0.01,
max_delay=0.1,
)
return _make
@pytest.mark.asyncio
async def test_enqueue(self, retry_queue, make_task):
task = make_task()
await retry_queue.enqueue(task)
assert retry_queue.pending_count == 1
@pytest.mark.asyncio
async def test_enqueue_duplicate_id(self, retry_queue, make_task):
task = make_task(task_id="dup_001")
await retry_queue.enqueue(task)
retry_queue._active_tasks.add("dup_001")
await retry_queue.enqueue(make_task(task_id="dup_001"))
assert retry_queue.pending_count == 1
@pytest.mark.asyncio
async def test_start_stop(self, retry_queue):
await retry_queue.start()
assert retry_queue._running is True
await retry_queue.stop()
assert retry_queue._running is False
@pytest.mark.asyncio
async def test_start_twice(self, retry_queue):
await retry_queue.start()
await retry_queue.start()
assert retry_queue._running is True
await retry_queue.stop()
@pytest.mark.asyncio
async def test_worker_processes_task(self, retry_queue, make_task):
call_count = 0
async def send_cb(chat_id, payload):
nonlocal call_count
call_count += 1
return True
retry_queue.set_send_callback(send_cb)
task = make_task()
task.next_retry_at = 0
await retry_queue.enqueue(task)
await retry_queue.start()
await asyncio.sleep(0.05)
await retry_queue.stop()
assert call_count >= 1
@pytest.mark.asyncio
async def test_dead_letter_on_exhausted(self, retry_queue, make_task):
async def send_cb(chat_id, payload):
return False
retry_queue.set_send_callback(send_cb)
task = make_task(task_id="exhaust_001", max_attempts=1)
task.next_retry_at = 0
await retry_queue.enqueue(task)
await retry_queue.start()
await asyncio.sleep(0.1)
await retry_queue.stop()
assert retry_queue.dead_count >= 1
@pytest.mark.asyncio
async def test_clear_dead_letter(self, retry_queue, make_task):
task = make_task(task_id="dead_001")
retry_queue._dead_letter.append(task)
count = retry_queue.clear_dead_letter()
assert count == 1
assert retry_queue.dead_count == 0
@pytest.mark.asyncio
async def test_get_dead_letter_tasks(self, retry_queue, make_task):
task = make_task()
retry_queue._dead_letter.append(task)
tasks = retry_queue.get_dead_letter_tasks()
assert len(tasks) == 1
@pytest.mark.asyncio
async def test_no_send_callback(self, retry_queue, make_task):
task = make_task()
task.next_retry_at = 0
await retry_queue.enqueue(task)
await retry_queue.start()
await asyncio.sleep(0.05)
await retry_queue.stop()
@pytest.mark.asyncio
async def test_send_callback_raises(self, retry_queue, make_task):
async def raising_cb(chat_id, payload):
raise RuntimeError("Send failed")
retry_queue.set_send_callback(raising_cb)
task = make_task()
task.next_retry_at = 0
await retry_queue.enqueue(task)
await retry_queue.start()
await asyncio.sleep(0.05)
await retry_queue.stop()
# ============================================================================
# Test: Media Tags (media_tags.py)
# ============================================================================
class TestParseMediaTags:
def test_parse_qqmedia_image(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags("Check this: <qqmedia:image:file_123>")
assert len(result.media_items) == 1
assert result.media_items[0].media_type == "image"
assert result.media_items[0].reference == "file_123"
def test_parse_qqmedia_voice(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags("<qqmedia:voice:audio_456>")
assert result.media_items[0].media_type == "voice"
assert result.media_items[0].reference == "audio_456"
def test_parse_qqimg(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags("<qqimg:https://example.com/img.png>")
assert result.media_items[0].media_type == "image"
assert result.media_items[0].is_url is True
def test_parse_qqvideo(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags("<qqvideo:https://example.com/vid.mp4>")
assert result.media_items[0].media_type == "video"
assert result.media_items[0].is_url is True
def test_parse_multiple_media(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags(
"<qqmedia:image:f1> text <qqmedia:video:f2>"
)
assert len(result.media_items) == 2
def test_clean_text_from_tags(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
result = parse_media_tags("Hello <qqmedia:image:f1> World")
assert "Hello" in result.text
assert "World" in result.text
assert "<qqmedia" not in result.text
def test_has_media_tags_positive(self):
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
assert has_media_tags("<qqmedia:image:file_123>") is True
def test_has_media_tags_negative(self):
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
assert has_media_tags("Plain text") is False
def test_has_media_tags_empty(self):
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
assert has_media_tags("") is False
def test_parsed_content_has_media(self):
from yuxi.channels.adapters.qqbot.media_tags import ParsedContent, InlineMedia
pc = ParsedContent(text="", media_items=[])
assert pc.has_media is False
pc.media_items.append(InlineMedia(media_type="image", reference="f1"))
assert pc.has_media is True
class TestParseSizeTag:
def test_parse_size_tag(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_size_tag
result = parse_size_tag("<qqsize:1000>Some content</qqsize>", default_limit=2000)
assert result.text == "Some content"
assert result.size_limit == 1000
def test_parse_size_tag_no_tag(self):
from yuxi.channels.adapters.qqbot.media_tags import parse_size_tag
result = parse_size_tag("Plain text", default_limit=2000)
assert result.text == "Plain text"
assert result.size_limit == 2000
class TestBuildMediaTag:
def test_build_media_tag_image_url(self):
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
tag = build_media_tag("image", "https://example.com/img.png", is_url=True)
assert tag == "<qqimg:https://example.com/img.png>"
def test_build_media_tag_video_url(self):
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
tag = build_media_tag("video", "https://example.com/vid.mp4", is_url=True)
assert tag == "<qqvideo:https://example.com/vid.mp4>"
def test_build_media_tag_file(self):
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
tag = build_media_tag("file", "file_123", is_url=False)
assert tag == "<qqmedia:file:file_123>"
def test_build_media_tags_list(self):
from yuxi.channels.adapters.qqbot.media_tags import build_media_tags, InlineMedia
items = [
InlineMedia(media_type="image", reference="f1"),
InlineMedia(media_type="voice", reference="v1"),
]
tags = build_media_tags(items)
assert "<qqmedia:image:f1>" in tags
assert "<qqmedia:voice:v1>" in tags
class TestEmbedMediaInText:
def test_embed_media(self):
from yuxi.channels.adapters.qqbot.media_tags import embed_media_in_text, InlineMedia
items = [InlineMedia(media_type="image", reference="f1")]
result = embed_media_in_text("Hello", items)
assert "Hello" in result
assert "<qqmedia" in result
def test_embed_no_media(self):
from yuxi.channels.adapters.qqbot.media_tags import embed_media_in_text
result = embed_media_in_text("Hello", [])
assert result == "Hello"
# ============================================================================
# Test: InboundPipeline (inbound_pipeline.py)
# ============================================================================
class TestPipelineContext:
def test_pipeline_context_creation(self):
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
ctx = PipelineContext()
assert ctx.stopped is False
def test_stop_sets_reason(self):
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
ctx = PipelineContext()
ctx.stop("security_block")
assert ctx.stopped is True
def test_stop_only_once_effective(self):
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
ctx = PipelineContext()
ctx.stop("first")
ctx.stop("second")
assert ctx.stopped is True
def test_debug_summary(self):
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
ctx = PipelineContext()
ctx.stop("test_reason")
summary = ctx.debug_summary()
assert isinstance(summary, str)
# ============================================================================
# Test: KnownUserTracker edge cases (more thorough)
# ============================================================================
class TestKnownUserTrackerEdgeCases:
@pytest.fixture
def tmp_persist_dir(self, tmp_path):
return str(tmp_path)
def test_get_recent_users_returns_max_n(self, tmp_persist_dir):
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir, max_users=10)
for i in range(20):
tracker.record(f"user_{i:03d}", f"Name_{i}")
recent = tracker.get_recent_users(5)
assert len(recent) <= 5
def test_clear_resets_properties(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_a", "Alice")
tracker.clear()
assert tracker.count == 0
assert tracker.is_known("user_a") is False
# ============================================================================
# Test: ConfigHints (config_hints.py)
# ============================================================================
class TestConfigHints:
def test_import_config_hints(self):
from yuxi.channels.adapters.qqbot import config_hints
assert config_hints is not None
def test_get_config_hints(self):
from yuxi.channels.adapters.qqbot.config_hints import get_config_hints
hints = get_config_hints()
assert isinstance(hints, list)
assert len(hints) > 0
# ============================================================================
# Test: Constants (constants.py)
# ============================================================================
class TestConstants:
def test_chat_prefixes(self):
from yuxi.channels.adapters.qqbot.constants import GROUP_CHAT_PREFIX, DM_CHAT_PREFIX
assert GROUP_CHAT_PREFIX.startswith("group_")
assert DM_CHAT_PREFIX.startswith("dm_")
def test_message_limits(self):
from yuxi.channels.adapters.qqbot.format import (
TEXT_CONTENT_LIMIT,
MARKDOWN_CONTENT_LIMIT,
)
assert TEXT_CONTENT_LIMIT > 0
assert MARKDOWN_CONTENT_LIMIT > 0
def test_qqbot_events(self):
from yuxi.channels.adapters.qqbot.constants import EventType
assert hasattr(EventType, "AT_MESSAGE_CREATE")
assert hasattr(EventType, "DIRECT_MESSAGE_CREATE")
assert hasattr(EventType, "MESSAGE_CREATE")
# ============================================================================
# Test: Send with retry edge cases (send.py)
# ============================================================================
class TestSendWithRetryEdgeCases:
@pytest.mark.asyncio
async def test_resolve_send_url_dm_starts_with(self):
from yuxi.channels.adapters.qqbot.send import resolve_send_url
url = resolve_send_url("https://api.sgroup.qq.com", "dm_user_123")
assert "users" in url
@pytest.mark.asyncio
async def test_resolve_send_url_group_starts_with(self):
from yuxi.channels.adapters.qqbot.send import resolve_send_url
url = resolve_send_url("https://api.sgroup.qq.com", "group_test")
assert "groups" in url
@pytest.mark.asyncio
async def test_resolve_send_url_channel(self):
from yuxi.channels.adapters.qqbot.send import resolve_send_url
url = resolve_send_url("https://api.sgroup.qq.com", "ch_abc")
assert "channels" in url
# ============================================================================
# Test: SessionStore additional (session_store.py)
# ============================================================================
class TestSessionStoreAdditional:
@pytest.fixture
def tmp_dir(self, tmp_path):
return str(tmp_path)
def test_create_session_store(self, tmp_dir):
from yuxi.channels.adapters.qqbot.session_store import SessionStore
store = SessionStore("app_test", store_dir=tmp_dir)
assert store is not None
# ============================================================================
# Test: Reference types (ref/types.py)
# ============================================================================
class TestRefTypes:
def test_ref_item(self):
from yuxi.channels.adapters.qqbot.ref.types import RefItem, RefCategory
ref = RefItem(
ref_id="ref_001",
category=RefCategory.MSG,
value="Hello world",
label="msg_001",
chat_id="chat_001",
user_id="user_001",
)
assert ref.category == RefCategory.MSG
assert ref.label == "msg_001"
def test_ref_index_create(self):
from yuxi.channels.adapters.qqbot.ref.store import RefIndex
idx = RefIndex()
assert idx is not None