新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
405 lines
15 KiB
Python
405 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChannelType, ChatType
|
|
from yuxi.channels.policy.context_policy import ContextCommand, ContextPolicy
|
|
from yuxi.channels.policy.dedup_policy import DedupPolicy
|
|
from yuxi.channels.policy.group_chat_policy import GroupChatMode, GroupChatPolicy
|
|
from yuxi.channels.policy.media_policy import MediaPolicy
|
|
from yuxi.channels.policy.schedule_policy import ScheduleConfig, SchedulePolicy
|
|
from yuxi.channels.policy.voice_policy import STTProvider, TTSProvider, VoicePolicy, VoiceSegment
|
|
from yuxi.channels.policy.welcome_policy import WelcomePolicy
|
|
|
|
|
|
def _make_message(
|
|
channel_id: str = "telegram",
|
|
channel_chat_id: str = "chat-001",
|
|
channel_message_id: str = "msg-001",
|
|
content: str = "你好",
|
|
chat_type: ChatType = ChatType.DIRECT,
|
|
) -> ChannelMessage:
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.TELEGRAM,
|
|
channel_user_id="user-001",
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=channel_message_id,
|
|
),
|
|
content=content,
|
|
chat_type=chat_type,
|
|
)
|
|
|
|
|
|
class TestDedupPolicy:
|
|
@pytest.mark.asyncio
|
|
async def test_normal_message_not_blocked(self):
|
|
policy = DedupPolicy()
|
|
msg = _make_message(channel_message_id="msg-001")
|
|
assert await policy.is_duplicate(msg) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_message_blocked(self):
|
|
policy = DedupPolicy()
|
|
msg = _make_message(channel_message_id="msg-001")
|
|
assert await policy.is_duplicate(msg) is False
|
|
assert await policy.is_duplicate(msg) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_channel_same_msg_id_not_blocked(self):
|
|
policy = DedupPolicy()
|
|
msg1 = _make_message(channel_id="telegram", channel_message_id="msg-001")
|
|
msg2 = _make_message(channel_id="discord", channel_message_id="msg-001")
|
|
assert await policy.is_duplicate(msg1) is False
|
|
assert await policy.is_duplicate(msg2) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_message_id_not_blocked(self):
|
|
policy = DedupPolicy()
|
|
msg = _make_message(channel_message_id=None)
|
|
assert await policy.is_duplicate(msg) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_resets_cache(self):
|
|
policy = DedupPolicy()
|
|
msg = _make_message(channel_message_id="msg-001")
|
|
await policy.is_duplicate(msg)
|
|
await policy.clear()
|
|
assert await policy.is_duplicate(msg) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ttl_expiry(self):
|
|
policy = DedupPolicy(ttl=1, maxsize=100)
|
|
msg = _make_message(channel_message_id="msg-001")
|
|
assert await policy.is_duplicate(msg) is False
|
|
time.sleep(1.5)
|
|
assert await policy.is_duplicate(msg) is False
|
|
|
|
|
|
class TestContextPolicy:
|
|
def test_normal_message_not_handled(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="你好")
|
|
result = policy.parse(msg)
|
|
assert result.handled is False
|
|
|
|
def test_reset_command(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/reset")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.RESET
|
|
assert result.args == ""
|
|
|
|
def test_history_with_args(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/history 3")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.HISTORY
|
|
assert result.args == "3"
|
|
|
|
def test_context_command(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/context")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.CONTEXT
|
|
|
|
def test_summary_command(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/summary")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.SUMMARY
|
|
|
|
def test_unknown_command_ignored(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/unknown")
|
|
result = policy.parse(msg)
|
|
assert result.handled is False
|
|
assert result.is_unknown_command is True
|
|
|
|
def test_command_case_insensitive(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content="/RESET")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.RESET
|
|
|
|
def test_command_with_extra_spaces(self):
|
|
policy = ContextPolicy()
|
|
msg = _make_message(content=" /reset ")
|
|
result = policy.parse(msg)
|
|
assert result.handled is True
|
|
assert result.command == ContextCommand.RESET
|
|
|
|
|
|
class TestGroupChatPolicy:
|
|
def test_direct_chat_always_responds(self):
|
|
policy = GroupChatPolicy()
|
|
msg = _make_message(chat_type=ChatType.DIRECT)
|
|
assert policy.should_respond(msg, is_at_bot=False) is True
|
|
|
|
def test_mention_only_with_at(self):
|
|
policy = GroupChatPolicy()
|
|
msg = _make_message(chat_type=ChatType.GROUP)
|
|
assert policy.should_respond(msg, is_at_bot=True) is True
|
|
|
|
def test_mention_only_without_at(self):
|
|
policy = GroupChatPolicy()
|
|
msg = _make_message(chat_type=ChatType.GROUP)
|
|
assert policy.should_respond(msg, is_at_bot=False) is False
|
|
|
|
def test_all_mode(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.ALL)
|
|
msg = _make_message(chat_type=ChatType.GROUP)
|
|
assert policy.should_respond(msg, is_at_bot=False) is True
|
|
|
|
def test_whitelist_hit(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-001"])
|
|
msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001")
|
|
assert policy.should_respond(msg, is_at_bot=False) is True
|
|
|
|
def test_whitelist_miss(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-002"])
|
|
msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001")
|
|
assert policy.should_respond(msg, is_at_bot=False) is False
|
|
|
|
def test_blacklist_hit(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.BLACKLIST, blacklist=["chat-001"])
|
|
msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001")
|
|
assert policy.should_respond(msg, is_at_bot=False) is False
|
|
|
|
def test_blacklist_miss(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.BLACKLIST, blacklist=["chat-002"])
|
|
msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001")
|
|
assert policy.should_respond(msg, is_at_bot=False) is True
|
|
|
|
def test_clear_whitelist_with_empty_list(self):
|
|
policy = GroupChatPolicy()
|
|
policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-001"])
|
|
msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001")
|
|
assert policy.should_respond(msg, is_at_bot=False) is True
|
|
policy.configure(mode=GroupChatMode.WHITELIST, whitelist=[])
|
|
assert policy.should_respond(msg, is_at_bot=False) is False
|
|
|
|
|
|
class TestWelcomePolicy:
|
|
def test_new_user_triggers_welcome(self):
|
|
policy = WelcomePolicy()
|
|
assert policy.mark_welcomed("user-001") is True
|
|
|
|
def test_existing_user_not_triggered(self):
|
|
policy = WelcomePolicy()
|
|
policy.mark_welcomed("user-001")
|
|
assert policy.mark_welcomed("user-001") is False
|
|
|
|
def test_default_welcome_message(self):
|
|
policy = WelcomePolicy()
|
|
assert policy.get_welcome_message() == WelcomePolicy.DEFAULT_WELCOME_MESSAGE
|
|
|
|
def test_custom_welcome_message(self):
|
|
policy = WelcomePolicy()
|
|
policy.configure(message_template="自定义欢迎语")
|
|
assert policy.get_welcome_message() == "自定义欢迎语"
|
|
|
|
|
|
class TestSchedulePolicy:
|
|
def test_is_working_hours(self):
|
|
policy = SchedulePolicy()
|
|
dt = datetime(2026, 5, 8, 2, 0, 0)
|
|
assert policy.is_working_hours(now=dt) is True
|
|
|
|
def test_after_hours(self):
|
|
policy = SchedulePolicy()
|
|
dt = datetime(2026, 5, 8, 12, 0, 0)
|
|
assert policy.is_working_hours(now=dt) is False
|
|
|
|
def test_weekend(self):
|
|
policy = SchedulePolicy()
|
|
dt = datetime(2026, 5, 9, 2, 0, 0)
|
|
assert policy.is_working_hours(now=dt) is False
|
|
|
|
def test_custom_timezone(self):
|
|
config = ScheduleConfig(timezone_offset_hours=0)
|
|
policy = SchedulePolicy()
|
|
policy.configure(config)
|
|
dt = datetime(2026, 5, 8, 10, 0, 0)
|
|
assert policy.is_working_hours(now=dt) is True
|
|
|
|
def test_off_hours_reply_default(self):
|
|
policy = SchedulePolicy()
|
|
dt = datetime(2026, 5, 8, 12, 0, 0)
|
|
reply = policy.get_off_hours_reply(now=dt)
|
|
assert reply == SchedulePolicy.DEFAULT_OFF_HOURS_REPLY
|
|
|
|
def test_working_hours_no_reply(self):
|
|
policy = SchedulePolicy()
|
|
dt = datetime(2026, 5, 8, 2, 0, 0)
|
|
assert policy.get_off_hours_reply(now=dt) is None
|
|
|
|
def test_custom_off_hours_reply(self):
|
|
config = ScheduleConfig(off_hours_reply="休息中")
|
|
policy = SchedulePolicy()
|
|
policy.configure(config)
|
|
dt = datetime(2026, 5, 8, 12, 0, 0)
|
|
assert policy.get_off_hours_reply(now=dt) == "休息中"
|
|
|
|
def test_overnight_window(self):
|
|
from datetime import time
|
|
|
|
from yuxi.channels.policy.schedule_policy import TimeWindow
|
|
|
|
config = ScheduleConfig(
|
|
work_hours=TimeWindow(start=time(22, 0), end=time(6, 0)),
|
|
)
|
|
policy = SchedulePolicy()
|
|
policy.configure(config)
|
|
dt_evening = datetime(2026, 5, 8, 14, 0, 0)
|
|
assert policy.is_working_hours(now=dt_evening) is True
|
|
dt_midnight = datetime(2026, 5, 8, 15, 0, 0)
|
|
assert policy.is_working_hours(now=dt_midnight) is True
|
|
dt_daytime = datetime(2026, 5, 8, 4, 0, 0)
|
|
assert policy.is_working_hours(now=dt_daytime) is False
|
|
|
|
|
|
class TestMediaPolicy:
|
|
def test_detect_png(self):
|
|
policy = MediaPolicy()
|
|
data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "png"
|
|
|
|
def test_detect_jpeg(self):
|
|
policy = MediaPolicy()
|
|
data = b"\xff\xd8\xff\xe0" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "jpg"
|
|
|
|
def test_detect_gif87a(self):
|
|
policy = MediaPolicy()
|
|
data = b"GIF87a" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "gif"
|
|
|
|
def test_detect_gif89a(self):
|
|
policy = MediaPolicy()
|
|
data = b"GIF89a" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "gif"
|
|
|
|
def test_detect_webp(self):
|
|
policy = MediaPolicy()
|
|
data = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "webp"
|
|
|
|
def test_detect_unknown(self):
|
|
policy = MediaPolicy()
|
|
data = b"\x00\x00\x00\x00" + b"\x00" * 100
|
|
assert policy.detect_format(data) is None
|
|
|
|
def test_detect_bmp(self):
|
|
policy = MediaPolicy()
|
|
data = b"BM" + b"\x00" * 100
|
|
assert policy.detect_format(data) == "bmp"
|
|
|
|
def test_is_supported_format(self):
|
|
policy = MediaPolicy()
|
|
assert policy.is_supported_format("png") is True
|
|
assert policy.is_supported_format("bmp") is True
|
|
assert policy.is_supported_format("tiff") is False
|
|
|
|
def test_validate_size_ok(self):
|
|
policy = MediaPolicy()
|
|
data = b"x" * (5 * 1024 * 1024)
|
|
assert policy.validate_size(data, max_size_mb=10) is True
|
|
|
|
def test_validate_size_exceeded(self):
|
|
policy = MediaPolicy()
|
|
data = b"x" * (50 * 1024 * 1024)
|
|
assert policy.validate_size(data, max_size_mb=10) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_url_content_invalid_url(self):
|
|
policy = MediaPolicy()
|
|
result = await policy.cache_url_content("http://invalid.test.local/notfound")
|
|
assert result is None
|
|
|
|
|
|
class TestVoicePolicy:
|
|
@pytest.mark.asyncio
|
|
async def test_process_voice_no_stt_returns_none(self):
|
|
policy = VoicePolicy()
|
|
result = await policy.process_voice(VoiceSegment(audio_data=b"\x00" * 100))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_to_speech_no_tts_returns_none(self):
|
|
policy = VoicePolicy()
|
|
result = await policy.text_to_speech("hello")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_voice_stt_exception_returns_none(self):
|
|
class FailingSTT(STTProvider):
|
|
async def transcribe(self, segment: VoiceSegment):
|
|
raise RuntimeError("STT service unavailable")
|
|
|
|
policy = VoicePolicy()
|
|
policy.configure_stt(FailingSTT())
|
|
result = await policy.process_voice(VoiceSegment(audio_data=b"\x00" * 100))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_to_speech_tts_exception_returns_none(self):
|
|
class FailingTTS(TTSProvider):
|
|
async def synthesize(self, text: str, voice: str = "default"):
|
|
raise RuntimeError("TTS service unavailable")
|
|
|
|
policy = VoicePolicy()
|
|
policy.configure_tts(FailingTTS())
|
|
result = await policy.text_to_speech("hello")
|
|
assert result is None
|
|
|
|
|
|
class TestPackageImports:
|
|
def test_import_all_policies(self):
|
|
from yuxi.channels.policy import (
|
|
ApprovalAction,
|
|
ApprovalConfig,
|
|
ApprovalRequest,
|
|
ApprovalState,
|
|
BaseApprovalCapability,
|
|
BaseDirectoryAdapter,
|
|
ContextPolicy,
|
|
DedupPolicy,
|
|
GroupChatPolicy,
|
|
MediaPolicy,
|
|
QueueDebounce,
|
|
SchedulePolicy,
|
|
VoicePolicy,
|
|
WelcomePolicy,
|
|
)
|
|
assert DedupPolicy is not None
|
|
assert ContextPolicy is not None
|
|
assert GroupChatPolicy is not None
|
|
assert WelcomePolicy is not None
|
|
assert SchedulePolicy is not None
|
|
assert MediaPolicy is not None
|
|
assert VoicePolicy is not None
|
|
assert ApprovalAction is not None
|
|
assert BaseApprovalCapability is not None
|
|
assert QueueDebounce is not None
|
|
assert BaseDirectoryAdapter is not None
|
|
|
|
def test_import_voice_components(self):
|
|
from yuxi.channels.policy.voice_policy import STTProvider, TTSProvider
|
|
assert STTProvider is not None
|
|
assert TTSProvider is not None
|