ForcePilot/backend/test/unit/channels/test_channels_policy.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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