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

559 lines
21 KiB
Python

from __future__ import annotations
import pytest
from yuxi.channels.message_actions import ActionDeclaration, ActionRegistry, ActionStatus, MessageAction
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import (
CAPS_FULL_FEATURED,
CAPS_SIMPLE_TEXT,
ChannelCapabilities,
)
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelType,
DeliveryResult,
HealthStatus,
)
class _EmptyAdapter(BaseChannelAdapter):
channel_id = "test_empty"
channel_type = ChannelType.WEBCHAT
async def connect(self) -> None:
pass
async def disconnect(self) -> None:
pass
async def send(self, response: ChannelResponse) -> DeliveryResult:
return DeliveryResult(success=True)
def normalize_inbound(self, raw) -> ChannelMessage:
raise NotImplementedError
def format_outbound(self, response: ChannelResponse):
return {"content": response.content}
async def health_check(self) -> HealthStatus:
return HealthStatus(status="healthy")
class _FullAdapter(_EmptyAdapter):
channel_id = "test_full"
supports_streaming = True
streaming_modes = ["off", "block"]
supports_markdown = True
async def send_media(self, chat_id, media_type, data) -> DeliveryResult:
return DeliveryResult(success=True)
async def edit_message(self, chat_id, msg_id, content) -> DeliveryResult:
return DeliveryResult(success=True)
async def delete_message(self, chat_id, msg_id) -> DeliveryResult:
return DeliveryResult(success=True)
async def send_reaction(self, chat_id, msg_id, emoji) -> DeliveryResult:
return DeliveryResult(success=True)
class _FeishuLikeAdapter(_EmptyAdapter):
channel_id = "test_feishu_like"
async def send_media(self, chat_id, media_type, data) -> DeliveryResult:
return DeliveryResult(success=True)
async def edit_message(self, chat_id, msg_id, content) -> DeliveryResult:
return DeliveryResult(success=True)
async def delete_message(self, chat_id, msg_id) -> DeliveryResult:
return DeliveryResult(success=True)
async def send_reaction(self, chat_id, msg_id, emoji) -> DeliveryResult:
return DeliveryResult(success=True)
async def pin_message(self, chat_id, msg_id) -> DeliveryResult:
return DeliveryResult(success=True)
async def unpin_message(self, chat_id, msg_id) -> DeliveryResult:
return DeliveryResult(success=True)
async def list_pins(self, chat_id) -> dict:
return {}
class TestMessageActionEnum:
def test_all_values_are_strings(self):
for action in MessageAction:
assert isinstance(action.value, str)
def test_send_is_str_enum(self):
assert MessageAction.SEND == "send"
assert str(MessageAction.SEND) == "send"
def test_contains_expected_actions(self):
assert MessageAction.EDIT == "edit"
assert MessageAction.DELETE == "delete"
assert MessageAction.REACT == "react"
assert MessageAction.STREAM == "stream"
assert MessageAction.PIN == "pin"
assert MessageAction.UNPIN == "unpin"
assert MessageAction.LIST_PINS == "list_pins"
def test_contains_p2_actions(self):
assert MessageAction.KICK == "kick"
assert MessageAction.BAN == "ban"
assert MessageAction.MUTE == "mute"
assert MessageAction.CREATE_POLL == "create_poll"
assert MessageAction.CREATE_THREAD == "create_thread"
class TestActionRegistryDeriveFromAdapter:
def test_empty_adapter_derives_no_capabilities(self):
ActionRegistry.clear()
caps = ActionRegistry.derive_from_adapter_cls(_EmptyAdapter)
assert caps.edit is False
assert caps.unsend is False
assert caps.reactions is False
assert caps.media is False
def test_full_adapter_derives_all_capabilities(self):
caps = ActionRegistry.derive_from_adapter_cls(_FullAdapter)
assert caps.media is True
assert caps.edit is True
assert caps.unsend is True
assert caps.reactions is True
assert caps.supports_streaming is True
def test_feishu_like_derives_pin_capabilities(self):
caps = ActionRegistry.derive_from_adapter_cls(_FeishuLikeAdapter)
assert caps.pin is True
assert caps.unpin is True
assert caps.list_pins is True
def test_feishu_like_has_no_send_ephemeral(self):
caps = ActionRegistry.derive_from_adapter_cls(_FeishuLikeAdapter)
assert caps.send_ephemeral is False
class TestActionRegistryRegisterAndQuery:
@pytest.fixture(autouse=True)
def _clear_registry(self):
ActionRegistry.clear()
yield
ActionRegistry.clear()
def test_register_and_get(self):
caps = CAPS_FULL_FEATURED
ActionRegistry.register_adapter(_FullAdapter)
result = ActionRegistry.get("test_full")
assert result is not None
assert result.edit is True
def test_supports_edit(self):
ActionRegistry.register_adapter(_FullAdapter)
assert ActionRegistry.supports("test_full", "edit") is True
def test_supports_react(self):
ActionRegistry.register_adapter(_FullAdapter)
assert ActionRegistry.supports("test_full", "react") is True
def test_does_not_support_pin_for_full_adapter(self):
ActionRegistry.register_adapter(_FullAdapter)
assert ActionRegistry.supports("test_full", "pin") is False
def test_unknown_channel_returns_false(self):
assert ActionRegistry.supports("nonexistent", "edit") is False
def test_unknown_channel_get_returns_none(self):
assert ActionRegistry.get("nonexistent") is None
def test_find_channels_supporting_edit(self):
ActionRegistry.register_adapter(_FullAdapter)
ActionRegistry.register_adapter(_EmptyAdapter)
channels = ActionRegistry.find_channels_supporting("edit")
assert "test_full" in channels
assert "test_empty" not in channels
def test_find_channels_supporting_pin(self):
ActionRegistry.register_adapter(_FeishuLikeAdapter)
ActionRegistry.register_adapter(_FullAdapter)
channels = ActionRegistry.find_channels_supporting("pin")
assert channels == ["test_feishu_like"]
def test_list_all(self):
ActionRegistry.register_adapter(_FullAdapter)
all_caps = ActionRegistry.list_all()
assert "test_full" in all_caps
def test_clear(self):
ActionRegistry.register_adapter(_FullAdapter)
ActionRegistry.clear()
assert ActionRegistry.get("test_full") is None
class TestActionRegistryExplicitCapabilities:
@pytest.fixture(autouse=True)
def _clear_registry(self):
ActionRegistry.clear()
yield
ActionRegistry.clear()
def test_explicit_caps_take_precedence(self):
caps = ChannelCapabilities(
edit=True,
reactions=True,
)
ActionRegistry.register("test_explicit", caps)
result = ActionRegistry.get("test_explicit")
assert result is not None
assert result.edit is True
def test_register_adapters_batch(self):
ActionRegistry.register_adapters([_FullAdapter, _FeishuLikeAdapter])
assert ActionRegistry.supports("test_full", "edit") is True
assert ActionRegistry.supports("test_feishu_like", "pin") is True
class TestChannelCapabilitiesSupportedActions:
def test_empty_caps_has_no_actions(self):
caps = ChannelCapabilities()
assert caps.supported_actions() == []
def test_simple_text_has_no_actions(self):
actions = CAPS_SIMPLE_TEXT.supported_actions()
assert actions == []
def test_full_featured_has_expected_actions(self):
actions = CAPS_FULL_FEATURED.supported_actions()
assert "send_media" in actions
assert "edit" in actions
assert "delete" in actions
assert "react" in actions
assert "create_poll" in actions
assert "create_thread" in actions
assert "pin" not in actions
def test_caps_with_pin(self):
caps = ChannelCapabilities(pin=True, unpin=True, list_pins=True)
actions = caps.supported_actions()
assert "pin" in actions
assert "unpin" in actions
assert "list_pins" in actions
def test_caps_with_group_management(self):
caps = ChannelCapabilities(group_management=True)
actions = caps.supported_actions()
assert "kick" in actions
assert "ban" in actions
assert "mute" in actions
def test_caps_with_send_ephemeral(self):
caps = ChannelCapabilities(send_ephemeral=True)
actions = caps.supported_actions()
assert "send_ephemeral" in actions
class TestMessageActionEnumExpanded:
def test_enum_has_58_plus_actions(self):
all_actions = list(MessageAction)
assert len(all_actions) >= 58
def test_new_standard_actions(self):
assert MessageAction.BROADCAST == "broadcast"
assert MessageAction.REPLY == "reply"
assert MessageAction.UNSEND == "unsend"
assert MessageAction.READ == "read"
assert MessageAction.LIST_REACTIONS == "list_reactions"
assert MessageAction.SEND_WITH_EFFECT == "send_with_effect"
assert MessageAction.RENAME_GROUP == "rename_group"
assert MessageAction.SET_GROUP_ICON == "set_group_icon"
assert MessageAction.ADD_PARTICIPANT == "add_participant"
assert MessageAction.REMOVE_PARTICIPANT == "remove_participant"
assert MessageAction.LEAVE_GROUP == "leave_group"
assert MessageAction.PERMISSIONS == "permissions"
def test_thread_actions(self):
assert MessageAction.THREAD_CREATE == "thread_create"
assert MessageAction.THREAD_LIST == "thread_list"
assert MessageAction.THREAD_REPLY == "thread_reply"
def test_sticker_emoji_actions(self):
assert MessageAction.STICKER == "sticker"
assert MessageAction.STICKER_SEARCH == "sticker_search"
assert MessageAction.STICKER_UPLOAD == "sticker_upload"
assert MessageAction.EMOJI_LIST == "emoji_list"
assert MessageAction.EMOJI_UPLOAD == "emoji_upload"
def test_member_role_actions(self):
assert MessageAction.MEMBER_INFO == "member_info"
assert MessageAction.ROLE_INFO == "role_info"
assert MessageAction.ROLE_ADD == "role_add"
assert MessageAction.ROLE_REMOVE == "role_remove"
def test_channel_category_actions(self):
assert MessageAction.CHANNEL_INFO == "channel_info"
assert MessageAction.CHANNEL_LIST == "channel_list"
assert MessageAction.CHANNEL_CREATE == "channel_create"
assert MessageAction.CHANNEL_EDIT == "channel_edit"
assert MessageAction.CHANNEL_DELETE == "channel_delete"
assert MessageAction.CHANNEL_MOVE == "channel_move"
assert MessageAction.CATEGORY_CREATE == "category_create"
assert MessageAction.CATEGORY_EDIT == "category_edit"
assert MessageAction.CATEGORY_DELETE == "category_delete"
def test_file_profile_actions(self):
assert MessageAction.DOWNLOAD_FILE == "download_file"
assert MessageAction.UPLOAD_FILE == "upload_file"
assert MessageAction.SEND_ATTACHMENT == "send_attachment"
assert MessageAction.SET_PROFILE == "set_profile"
assert MessageAction.SET_PRESENCE == "set_presence"
def test_event_voice_actions(self):
assert MessageAction.EVENT_LIST == "event_list"
assert MessageAction.EVENT_CREATE == "event_create"
assert MessageAction.VOICE_STATUS == "voice_status"
assert MessageAction.SEARCH == "search"
assert MessageAction.TOPIC_CREATE == "topic_create"
assert MessageAction.TOPIC_EDIT == "topic_edit"
def test_backward_compat_actions(self):
assert MessageAction.SEND_MEDIA == "send_media"
assert MessageAction.STREAM == "stream"
assert MessageAction.SEND_EPHEMERAL == "send_ephemeral"
assert MessageAction.CREATE_THREAD == "create_thread"
assert MessageAction.REPLY_THREAD == "reply_thread"
class TestActionStatus:
def test_supported(self):
assert ActionStatus.SUPPORTED == "supported"
def test_unsupported(self):
assert ActionStatus.UNSUPPORTED == "unsupported"
def test_partial(self):
assert ActionStatus.PARTIAL == "partial"
class TestActionDeclaration:
def test_create_declaration(self):
decl = ActionDeclaration(
action=MessageAction.SEND,
status=ActionStatus.SUPPORTED,
reason="",
impl="adapter.send()",
)
assert decl.action == MessageAction.SEND
assert decl.status == ActionStatus.SUPPORTED
assert decl.impl == "adapter.send()"
def test_create_unsupported_declaration(self):
decl = ActionDeclaration(
action=MessageAction.PIN,
status=ActionStatus.UNSUPPORTED,
reason="WeChat 不支持置顶",
)
assert decl.action == MessageAction.PIN
assert decl.status == ActionStatus.UNSUPPORTED
assert decl.reason == "WeChat 不支持置顶"
assert decl.impl == ""
class TestActionRegistryChannelActions:
@pytest.fixture(autouse=True)
def _clear_registry(self):
ActionRegistry.clear()
yield
ActionRegistry.clear()
def _make_channel_actions(self):
declarations: dict[MessageAction, ActionDeclaration] = {}
declarations[MessageAction.SEND] = ActionDeclaration(
action=MessageAction.SEND,
status=ActionStatus.SUPPORTED,
impl="adapter.send()",
)
declarations[MessageAction.EDIT] = ActionDeclaration(
action=MessageAction.EDIT,
status=ActionStatus.SUPPORTED,
impl="adapter.edit_message()",
)
declarations[MessageAction.PIN] = ActionDeclaration(
action=MessageAction.PIN,
status=ActionStatus.UNSUPPORTED,
reason="Not supported",
)
declarations[MessageAction.BROADCAST] = ActionDeclaration(
action=MessageAction.BROADCAST,
status=ActionStatus.PARTIAL,
reason="Template only",
impl="sendMessageTemplate",
)
return declarations
def test_register_channel_actions(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
assert len(ActionRegistry.list_all_declarations("test_ch")) == 4
def test_is_supported_true(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
assert ActionRegistry.is_supported("test_ch", MessageAction.SEND) is True
assert ActionRegistry.is_supported("test_ch", MessageAction.EDIT) is True
def test_is_supported_false(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
assert ActionRegistry.is_supported("test_ch", MessageAction.PIN) is False
def test_is_supported_falls_back_to_caps(self):
ActionRegistry.register_adapter(_FullAdapter)
assert ActionRegistry.is_supported("test_full", MessageAction.EDIT) is True
assert ActionRegistry.is_supported("test_full", MessageAction.PIN) is False
def test_list_supported_actions(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
supported = ActionRegistry.list_supported_actions("test_ch")
assert MessageAction.SEND in supported
assert MessageAction.EDIT in supported
assert MessageAction.PIN not in supported
assert MessageAction.BROADCAST not in supported
def test_list_supported_actions_falls_back_to_caps(self):
ActionRegistry.register_adapter(_FullAdapter)
supported = ActionRegistry.list_supported_actions("test_full")
assert MessageAction.EDIT in supported
assert MessageAction.REACT in supported
def test_list_channels_for_action(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("channel_a", declarations)
ActionRegistry.register_channel_actions("channel_b", {
MessageAction.SEND: ActionDeclaration(
action=MessageAction.SEND,
status=ActionStatus.SUPPORTED,
impl="send",
),
MessageAction.PIN: ActionDeclaration(
action=MessageAction.PIN,
status=ActionStatus.SUPPORTED,
impl="pin",
),
})
channels = ActionRegistry.list_channels_for_action(MessageAction.PIN)
assert "channel_b" in channels
assert "channel_a" not in channels
assert ActionRegistry.list_channels_for_action(MessageAction.SEND) == ["channel_a", "channel_b"]
def test_list_channels_for_action_includes_caps_channels(self):
ActionRegistry.register_adapter(_FullAdapter)
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
channels = ActionRegistry.list_channels_for_action(MessageAction.EDIT)
assert "test_ch" in channels
assert "test_full" in channels
def test_get_action_declaration(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
decl = ActionRegistry.get_action_declaration("test_ch", MessageAction.PIN)
assert decl is not None
assert decl.status == ActionStatus.UNSUPPORTED
assert decl.reason == "Not supported"
def test_get_action_declaration_nonexistent(self):
assert ActionRegistry.get_action_declaration("nonexistent", MessageAction.SEND) is None
def test_list_all_declarations_empty(self):
assert ActionRegistry.list_all_declarations("nonexistent") == {}
def test_clear_includes_channel_actions(self):
declarations = self._make_channel_actions()
ActionRegistry.register_channel_actions("test_ch", declarations)
ActionRegistry.clear()
assert ActionRegistry.list_all_declarations("test_ch") == {}
class TestChannelRegistrations:
@pytest.fixture(autouse=True)
def _clear_registry(self):
ActionRegistry.clear()
yield
ActionRegistry.clear()
def test_wechat_registration(self):
from yuxi.channels.adapters.wechat.message_actions import register_wechat_actions
register_wechat_actions()
declarations = ActionRegistry.list_all_declarations("wechat")
assert len(declarations) > 0
send_decl = declarations.get(MessageAction.SEND)
assert send_decl is not None
assert send_decl.status == ActionStatus.SUPPORTED
def test_telegram_registration(self):
from yuxi.channels.adapters.telegram.message_actions import register_telegram_actions
register_telegram_actions()
declarations = ActionRegistry.list_all_declarations("telegram")
assert len(declarations) > 0
supported = ActionRegistry.list_supported_actions("telegram")
assert MessageAction.SEND in supported
assert MessageAction.EDIT in supported
assert MessageAction.REACT in supported
def test_discord_registration(self):
from yuxi.channels.adapters.discord.message_actions import register_discord_actions
register_discord_actions()
declarations = ActionRegistry.list_all_declarations("discord")
assert len(declarations) > 0
supported = ActionRegistry.list_supported_actions("discord")
assert MessageAction.SEND in supported
assert MessageAction.EDIT in supported
def test_slack_registration(self):
from yuxi.channels.adapters.slack.message_actions import register_slack_actions
register_slack_actions()
declarations = ActionRegistry.list_all_declarations("slack")
assert len(declarations) > 0
supported = ActionRegistry.list_supported_actions("slack")
assert MessageAction.SEND in supported
def test_whatsapp_registration(self):
from yuxi.channels.adapters.whatsapp.message_actions import register_whatsapp_actions
register_whatsapp_actions()
declarations = ActionRegistry.list_all_declarations("whatsapp")
assert len(declarations) > 0
supported = ActionRegistry.list_supported_actions("whatsapp")
assert MessageAction.SEND in supported
def test_feishu_registration(self):
from yuxi.channels.adapters.feishu.message_actions import register_feishu_actions
register_feishu_actions()
declarations = ActionRegistry.list_all_declarations("feishu")
assert len(declarations) > 0
supported = ActionRegistry.list_supported_actions("feishu")
assert MessageAction.SEND in supported
assert MessageAction.PIN in supported
def test_cross_channel_query_pin(self):
from yuxi.channels.adapters.discord.message_actions import register_discord_actions
from yuxi.channels.adapters.feishu.message_actions import register_feishu_actions
from yuxi.channels.adapters.telegram.message_actions import register_telegram_actions
register_telegram_actions()
register_discord_actions()
register_feishu_actions()
channels = ActionRegistry.list_channels_for_action(MessageAction.PIN)
assert "feishu" in channels
assert "telegram" in channels