diff --git a/tests_v4/test_api_event_filter.py b/tests_v4/test_api_event_filter.py new file mode 100644 index 000000000..da38ad266 --- /dev/null +++ b/tests_v4/test_api_event_filter.py @@ -0,0 +1,171 @@ +""" +Tests for api/event/filter.py - Event filter decorators and utilities. +""" +from __future__ import annotations + +import pytest + +from astrbot_sdk.api.event.filter import ADMIN, command, filter, permission, regex +from astrbot_sdk.decorators import get_handler_meta +from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger + + +class TestCommandFilter: + """Tests for command() filter function.""" + + def test_command_creates_command_trigger(self): + """command() should create a CommandTrigger.""" + + @command("hello") + async def hello_handler(): + pass + + meta = get_handler_meta(hello_handler) + assert meta is not None + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "hello" + + def test_command_is_decorator(self): + """command() should be usable as a decorator.""" + + @command("test") + async def test_handler(): + pass + + assert hasattr(test_handler, "__astrbot_handler_meta__") + + +class TestRegexFilter: + """Tests for regex() filter function.""" + + def test_regex_creates_message_trigger_with_regex(self): + """regex() should create a MessageTrigger with regex pattern.""" + + @regex(r"\d+") + async def number_handler(): + pass + + meta = get_handler_meta(number_handler) + assert meta is not None + assert isinstance(meta.trigger, MessageTrigger) + assert meta.trigger.regex == r"\d+" + + def test_regex_pattern_is_stored(self): + """regex() should store the pattern correctly.""" + + @regex(r"hello\s+world") + async def greeting_handler(): + pass + + meta = get_handler_meta(greeting_handler) + assert meta.trigger.regex == r"hello\s+world" + + +class TestPermissionFilter: + """Tests for permission() filter function.""" + + def test_permission_admin_sets_require_admin(self): + """permission(ADMIN) should set require_admin permission.""" + + @permission(ADMIN) + async def admin_handler(): + pass + + meta = get_handler_meta(admin_handler) + assert meta is not None + assert meta.permissions.require_admin is True + + def test_permission_non_admin_passes_through(self): + """permission() with non-ADMIN level should pass through.""" + + @permission("user") + async def user_handler(): + pass + + # Should not set admin permission + meta = get_handler_meta(user_handler) + assert meta is None or not meta.permissions.require_admin + + def test_permission_can_be_combined_with_command(self): + """permission() can be combined with other decorators.""" + + @command("admin") + @permission(ADMIN) + async def admin_command(): + pass + + meta = get_handler_meta(admin_command) + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "admin" + assert meta.permissions.require_admin is True + + +class TestFilterNamespace: + """Tests for filter namespace object.""" + + def test_filter_namespace_has_command(self): + """filter namespace should have command method.""" + assert hasattr(filter, "command") + assert callable(filter.command) + + def test_filter_namespace_has_regex(self): + """filter namespace should have regex method.""" + assert hasattr(filter, "regex") + assert callable(filter.regex) + + def test_filter_namespace_has_permission(self): + """filter namespace should have permission method.""" + assert hasattr(filter, "permission") + assert callable(filter.permission) + + def test_filter_command_works_as_decorator(self): + """filter.command() should work as a decorator.""" + + @filter.command("ping") + async def ping_handler(): + pass + + meta = get_handler_meta(ping_handler) + assert meta.trigger.command == "ping" + + def test_filter_regex_works_as_decorator(self): + """filter.regex() should work as a decorator.""" + + @filter.regex(r"test") + async def test_handler(): + pass + + meta = get_handler_meta(test_handler) + assert meta.trigger.regex == r"test" + + def test_filter_permission_admin_works(self): + """filter.permission(ADMIN) should set admin permission.""" + + @filter.permission(ADMIN) + async def admin_handler(): + pass + + meta = get_handler_meta(admin_handler) + assert meta.permissions.require_admin is True + + +class TestAdminConstant: + """Tests for ADMIN constant.""" + + def test_admin_constant_value(self): + """ADMIN constant should be 'admin'.""" + assert ADMIN == "admin" + + +class TestModuleExports: + """Tests for module exports.""" + + def test_all_exports(self): + """Module should export expected names.""" + from astrbot_sdk.api.event.filter import __all__ + + assert "ADMIN" in __all__ + assert "command" in __all__ + assert "regex" in __all__ + assert "permission" in __all__ + assert "filter" in __all__ diff --git a/tests_v4/test_api_legacy_context.py b/tests_v4/test_api_legacy_context.py new file mode 100644 index 000000000..f10eda757 --- /dev/null +++ b/tests_v4/test_api_legacy_context.py @@ -0,0 +1,364 @@ +""" +Tests for _legacy_api.py - Legacy compatibility layer. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot_sdk._legacy_api import ( + MIGRATION_DOC_URL, + CommandComponent, + Context, + LegacyContext, + LegacyConversationManager, +) +from astrbot_sdk.star import Star + + +class TestLegacyContext: + """Tests for LegacyContext.""" + + def test_init_with_plugin_id(self): + """LegacyContext should store plugin_id.""" + ctx = LegacyContext("test_plugin") + assert ctx.plugin_id == "test_plugin" + + def test_has_conversation_manager(self): + """LegacyContext should have conversation_manager.""" + ctx = LegacyContext("test_plugin") + assert hasattr(ctx, "conversation_manager") + assert isinstance(ctx.conversation_manager, LegacyConversationManager) + + def test_require_runtime_context_raises_when_not_bound(self): + """require_runtime_context() should raise when not bound.""" + ctx = LegacyContext("test_plugin") + with pytest.raises(RuntimeError, match="尚未绑定运行时 Context"): + ctx.require_runtime_context() + + def test_bind_runtime_context(self): + """bind_runtime_context() should bind a NewContext.""" + mock_ctx = MagicMock() + mock_ctx.plugin_id = "test" + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx.bind_runtime_context(mock_ctx) + + assert legacy_ctx.require_runtime_context() is mock_ctx + + def test_context_alias_is_legacy_context(self): + """Context should be an alias for LegacyContext.""" + assert Context is LegacyContext + + +class TestLegacyConversationManager: + """Tests for LegacyConversationManager.""" + + def test_init_with_parent(self): + """LegacyConversationManager should store parent reference.""" + parent = LegacyContext("test_plugin") + manager = LegacyConversationManager(parent) + assert manager._parent is parent + + @pytest.mark.asyncio + async def test_new_conversation_creates_id(self): + """new_conversation() should create a conversation ID.""" + mock_ctx = MagicMock() + mock_ctx.plugin_id = "my_plugin" + mock_ctx.db = AsyncMock() + mock_ctx.db.get = AsyncMock(return_value=None) + mock_ctx.db.set = AsyncMock() + + legacy_ctx = LegacyContext("my_plugin") + legacy_ctx._runtime_context = mock_ctx + + conv_id = await legacy_ctx.conversation_manager.new_conversation( + unified_msg_origin="session-1" + ) + + assert conv_id.startswith("my_plugin-conv-") + assert conv_id.endswith("-1") + + @pytest.mark.asyncio + async def test_new_conversation_stores_metadata(self): + """new_conversation() should store conversation metadata.""" + stored_data = {} + + async def mock_get(key): + return stored_data.get(key) + + async def mock_set(key, value): + stored_data[key] = value + + mock_ctx = MagicMock() + mock_ctx.plugin_id = "my_plugin" + mock_ctx.db = MagicMock() + mock_ctx.db.get = mock_get + mock_ctx.db.set = mock_set + + legacy_ctx = LegacyContext("my_plugin") + legacy_ctx._runtime_context = mock_ctx + + conv_id = await legacy_ctx.conversation_manager.new_conversation( + unified_msg_origin="session-1", + platform_id="telegram", + title="Test Chat", + persona_id="assistant", + ) + + # Verify stored data + assert "__compat_conversations__" in stored_data + assert conv_id in stored_data["__compat_conversations__"] + data = stored_data["__compat_conversations__"][conv_id] + assert data["platform_id"] == "telegram" + assert data["title"] == "Test Chat" + assert data["persona_id"] == "assistant" + + @pytest.mark.asyncio + async def test_new_conversation_increments_counter(self): + """new_conversation() should increment counter per origin.""" + stored_data = {} + + async def mock_get(key): + return stored_data.get(key) + + async def mock_set(key, value): + stored_data[key] = value + + mock_ctx = MagicMock() + mock_ctx.plugin_id = "my_plugin" + mock_ctx.db = MagicMock() + mock_ctx.db.get = mock_get + mock_ctx.db.set = mock_set + + legacy_ctx = LegacyContext("my_plugin") + legacy_ctx._runtime_context = mock_ctx + + id1 = await legacy_ctx.conversation_manager.new_conversation( + unified_msg_origin="session-1" + ) + id2 = await legacy_ctx.conversation_manager.new_conversation( + unified_msg_origin="session-1" + ) + id3 = await legacy_ctx.conversation_manager.new_conversation( + unified_msg_origin="session-2" + ) + + assert id1.endswith("-1") + assert id2.endswith("-2") + assert id3.endswith("-1") # Different session, starts at 1 + + +class TestLegacyContextMethods: + """Tests for LegacyContext methods that delegate to NewContext.""" + + @pytest.mark.asyncio + async def test_put_kv_data(self): + """put_kv_data() should delegate to db.set().""" + mock_db = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.db = mock_db + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.put_kv_data("test_key", {"value": 123}) + + mock_db.set.assert_called_once_with("test_key", {"value": 123}) + + @pytest.mark.asyncio + async def test_get_kv_data(self): + """get_kv_data() should delegate to db.get().""" + mock_db = AsyncMock() + mock_db.get = AsyncMock(return_value={"data": "hello"}) + + mock_ctx = MagicMock() + mock_ctx.db = mock_db + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + result = await legacy_ctx.get_kv_data("my_key") + + mock_db.get.assert_called_once_with("my_key") + assert result == {"data": "hello"} + + @pytest.mark.asyncio + async def test_delete_kv_data(self): + """delete_kv_data() should delegate to db.delete().""" + mock_db = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.db = mock_db + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.delete_kv_data("to_delete") + + mock_db.delete.assert_called_once_with("to_delete") + + +class TestLegacyContextSendMessage: + """Tests for LegacyContext.send_message().""" + + @pytest.mark.asyncio + async def test_send_message_with_plain_string(self): + """send_message() should handle plain string.""" + mock_platform = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.platform = mock_platform + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.send_message("session-1", "hello world") + + mock_platform.send.assert_called_once_with("session-1", "hello world") + + @pytest.mark.asyncio + async def test_send_message_with_get_plain_text_method(self): + """send_message() should use get_plain_text() if available.""" + + class MockMessageChain: + def get_plain_text(self): + return "extracted text" + + mock_platform = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.platform = mock_platform + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.send_message("session-1", MockMessageChain()) + + mock_platform.send.assert_called_once_with("session-1", "extracted text") + + @pytest.mark.asyncio + async def test_send_message_with_to_text_method(self): + """send_message() should use to_text() if get_plain_text() not available.""" + + class MockMessageChain: + def to_text(self): + return "to_text result" + + mock_platform = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.platform = mock_platform + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.send_message("session-1", MockMessageChain()) + + mock_platform.send.assert_called_once_with("session-1", "to_text result") + + @pytest.mark.asyncio + async def test_send_message_falls_back_to_str(self): + """send_message() should fall back to str() if no text method.""" + + class MockObject: + def __str__(self): + return "stringified" + + mock_platform = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.platform = mock_platform + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + await legacy_ctx.send_message("session-1", MockObject()) + + mock_platform.send.assert_called_once_with("session-1", "stringified") + + +class TestLegacyContextLLMMethods: + """Tests for LegacyContext LLM methods.""" + + @pytest.mark.asyncio + async def test_llm_generate_delegates_to_chat_raw(self): + """llm_generate() should delegate to ctx.llm.chat_raw().""" + mock_llm = AsyncMock() + mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response")) + + mock_ctx = MagicMock() + mock_ctx.llm = mock_llm + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + result = await legacy_ctx.llm_generate( + chat_provider_id="provider-1", + prompt="hello", + system_prompt="be helpful", + contexts=[{"role": "user", "content": "hi"}], + ) + + mock_llm.chat_raw.assert_called_once() + assert result is not None + + @pytest.mark.asyncio + async def test_tool_loop_agent_delegates_to_chat_raw(self): + """tool_loop_agent() should delegate to ctx.llm.chat_raw().""" + mock_llm = AsyncMock() + mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response")) + + mock_ctx = MagicMock() + mock_ctx.llm = mock_llm + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + result = await legacy_ctx.tool_loop_agent( + chat_provider_id="provider-1", + prompt="hello", + max_steps=10, + ) + + mock_llm.chat_raw.assert_called_once() + call_kwargs = mock_llm.chat_raw.call_args[1] + assert call_kwargs["max_steps"] == 10 + + @pytest.mark.asyncio + async def test_add_llm_tools_returns_none(self): + """add_llm_tools() should return None (deprecated).""" + legacy_ctx = LegacyContext("test_plugin") + + result = await legacy_ctx.add_llm_tools("tool1", "tool2") + + assert result is None + + +class TestCommandComponent: + """Tests for CommandComponent.""" + + def test_is_star_subclass(self): + """CommandComponent should be a Star subclass.""" + assert issubclass(CommandComponent, Star) + + def test_is_not_new_star(self): + """CommandComponent should NOT be recognized as new-style star.""" + assert CommandComponent.__astrbot_is_new_star__() is False + + def test_create_legacy_context(self): + """_astrbot_create_legacy_context() should create LegacyContext.""" + ctx = CommandComponent._astrbot_create_legacy_context("my_plugin") + assert isinstance(ctx, LegacyContext) + assert ctx.plugin_id == "my_plugin" + + +class TestMigrationDocUrl: + """Tests for migration documentation URL.""" + + def test_migration_doc_url_exists(self): + """MIGRATION_DOC_URL should be defined.""" + assert MIGRATION_DOC_URL is not None + assert "docs.astrbot.app" in MIGRATION_DOC_URL diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py new file mode 100644 index 000000000..2158244f7 --- /dev/null +++ b/tests_v4/test_api_modules.py @@ -0,0 +1,77 @@ +""" +Tests for API module exports and re-exports. +""" +from __future__ import annotations + +import pytest + + +class TestApiStarModule: + """Tests for api/star module exports.""" + + def test_star_module_exports_context(self): + """api.star should export Context.""" + from astrbot_sdk.api.star import Context + + assert Context is not None + + def test_star_context_is_legacy_context(self): + """api.star.Context should be LegacyContext.""" + from astrbot_sdk._legacy_api import LegacyContext + from astrbot_sdk.api.star import Context + + assert Context is LegacyContext + + +class TestApiComponentsModule: + """Tests for api/components module exports.""" + + def test_components_module_exports_command_component(self): + """api.components should export CommandComponent.""" + from astrbot_sdk.api.components import CommandComponent + + assert CommandComponent is not None + + def test_command_component_is_from_legacy_api(self): + """api.components.CommandComponent should be from _legacy_api.""" + from astrbot_sdk._legacy_api import CommandComponent as LegacyCommandComponent + from astrbot_sdk.api.components import CommandComponent + + assert CommandComponent is LegacyCommandComponent + + +class TestApiEventModule: + """Tests for api/event module exports.""" + + def test_event_module_exports(self): + """api.event should export expected names.""" + from astrbot_sdk.api.event import ADMIN, AstrMessageEvent, filter + + assert ADMIN == "admin" + assert filter is not None + assert AstrMessageEvent is not None + + def test_astr_message_event_is_message_event(self): + """AstrMessageEvent should be MessageEvent.""" + from astrbot_sdk.api.event import AstrMessageEvent + from astrbot_sdk.events import MessageEvent + + assert AstrMessageEvent is MessageEvent + + def test_all_exports(self): + """api.event should export all expected names.""" + from astrbot_sdk.api.event import __all__ + + assert "ADMIN" in __all__ + assert "AstrMessageEvent" in __all__ + assert "filter" in __all__ + + +class TestApiModule: + """Tests for top-level api module.""" + + def test_api_module_exists(self): + """api module should be importable.""" + import astrbot_sdk.api + + assert astrbot_sdk.api is not None diff --git a/tests_v4/test_decorators.py b/tests_v4/test_decorators.py new file mode 100644 index 000000000..cceff5691 --- /dev/null +++ b/tests_v4/test_decorators.py @@ -0,0 +1,331 @@ +""" +Tests for decorators.py - Handler decorator infrastructure. +""" +from __future__ import annotations + +import pytest + +from astrbot_sdk.decorators import ( + HANDLER_META_ATTR, + HandlerMeta, + get_handler_meta, + on_command, + on_event, + on_message, + on_schedule, + require_admin, +) +from astrbot_sdk.protocol.descriptors import ( + CommandTrigger, + EventTrigger, + MessageTrigger, + Permissions, + ScheduleTrigger, +) + + +class TestHandlerMeta: + """Tests for HandlerMeta dataclass.""" + + def test_default_values(self): + """HandlerMeta should have default values.""" + meta = HandlerMeta() + assert meta.trigger is None + assert meta.priority == 0 + assert isinstance(meta.permissions, Permissions) + + def test_trigger_assignment(self): + """HandlerMeta should accept trigger assignment.""" + trigger = CommandTrigger(command="test") + meta = HandlerMeta(trigger=trigger) + assert meta.trigger is trigger + + def test_priority_assignment(self): + """HandlerMeta should accept priority assignment.""" + meta = HandlerMeta(priority=10) + assert meta.priority == 10 + + def test_permissions_assignment(self): + """HandlerMeta should accept permissions assignment.""" + permissions = Permissions(require_admin=True) + meta = HandlerMeta(permissions=permissions) + assert meta.permissions.require_admin is True + + +class TestGetHandlerMeta: + """Tests for get_handler_meta function.""" + + def test_returns_none_for_undecorated_function(self): + """get_handler_meta should return None for undecorated functions.""" + async def plain_function(): + pass + + assert get_handler_meta(plain_function) is None + + def test_returns_meta_for_decorated_function(self): + """get_handler_meta should return meta for decorated functions.""" + + @on_command("test") + async def decorated(): + pass + + meta = get_handler_meta(decorated) + assert meta is not None + assert isinstance(meta, HandlerMeta) + + +class TestOnCommandDecorator: + """Tests for @on_command decorator.""" + + def test_sets_handler_meta_attribute(self): + """@on_command should set __astrbot_handler_meta__ attribute.""" + + @on_command("hello") + async def handler(): + pass + + assert hasattr(handler, HANDLER_META_ATTR) + + def test_creates_command_trigger(self): + """@on_command should create CommandTrigger.""" + + @on_command("hello") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "hello" + + def test_supports_aliases(self): + """@on_command should store aliases.""" + + @on_command("hello", aliases=["hi", "hey"]) + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.aliases == ["hi", "hey"] + + def test_supports_description(self): + """@on_command should store description.""" + + @on_command("hello", description="Say hello") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.description == "Say hello" + + def test_default_empty_aliases(self): + """@on_command should default to empty aliases.""" + + @on_command("test") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.aliases == [] + + def test_preserves_function(self): + """@on_command should preserve the original function.""" + + @on_command("test") + async def handler(): + return "result" + + assert handler.__name__ == "handler" + + +class TestOnMessageDecorator: + """Tests for @on_message decorator.""" + + def test_creates_message_trigger(self): + """@on_message should create MessageTrigger.""" + + @on_message() + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, MessageTrigger) + + def test_supports_regex(self): + """@on_message should store regex pattern.""" + + @on_message(regex=r"\d+") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.regex == r"\d+" + + def test_supports_keywords(self): + """@on_message should store keywords.""" + + @on_message(keywords=["hello", "hi"]) + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.keywords == ["hello", "hi"] + + def test_supports_platforms(self): + """@on_message should store platforms.""" + + @on_message(platforms=["telegram", "discord"]) + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.platforms == ["telegram", "discord"] + + def test_defaults_empty_lists(self): + """@on_message should default to empty lists.""" + + @on_message() + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.keywords == [] + assert meta.trigger.platforms == [] + + +class TestOnEventDecorator: + """Tests for @on_event decorator.""" + + def test_creates_event_trigger(self): + """@on_event should create EventTrigger.""" + + @on_event("message_received") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, EventTrigger) + assert meta.trigger.event_type == "message_received" + + def test_various_event_types(self): + """@on_event should handle various event types.""" + + event_types = ["message_received", "user_joined", "custom_event"] + + for event_type in event_types: + @on_event(event_type) + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.event_type == event_type + + +class TestOnScheduleDecorator: + """Tests for @on_schedule decorator.""" + + def test_creates_cron_trigger(self): + """@on_schedule with cron should create ScheduleTrigger.""" + + @on_schedule(cron="* * * * *") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, ScheduleTrigger) + assert meta.trigger.cron == "* * * * *" + + def test_creates_interval_trigger(self): + """@on_schedule with interval should create ScheduleTrigger.""" + + @on_schedule(interval_seconds=60) + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.trigger.interval_seconds == 60 + + +class TestRequireAdminDecorator: + """Tests for @require_admin decorator.""" + + def test_sets_require_admin_permission(self): + """@require_admin should set require_admin in permissions.""" + + @require_admin + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.permissions.require_admin is True + + def test_can_combine_with_other_decorators(self): + """@require_admin can be combined with other decorators.""" + + @on_command("admin") + @require_admin + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "admin" + assert meta.permissions.require_admin is True + + def test_order_does_not_matter_for_permissions(self): + """Decorator order should not affect permissions.""" + + @require_admin + @on_command("admin") + async def handler1(): + pass + + @on_command("admin") + @require_admin + async def handler2(): + pass + + meta1 = get_handler_meta(handler1) + meta2 = get_handler_meta(handler2) + + assert meta1.permissions.require_admin is True + assert meta2.permissions.require_admin is True + + +class TestDecoratorChaining: + """Tests for chaining multiple decorators.""" + + def test_multiple_decorators_share_meta(self): + """Multiple decorators should share the same meta object.""" + + @on_command("test") + @require_admin + async def handler(): + pass + + meta = get_handler_meta(handler) + assert isinstance(meta.trigger, CommandTrigger) + assert meta.permissions.require_admin is True + + def test_last_trigger_wins(self): + """Last trigger decorator should override previous ones.""" + + # Decorators are applied bottom-up, so on_message wins + @on_message() + @on_command("override") + async def handler(): + pass + + meta = get_handler_meta(handler) + # on_message is applied last (outermost), so it wins + assert isinstance(meta.trigger, MessageTrigger) + + def test_permissions_accumulate(self): + """Permissions should accumulate across decorators.""" + + @require_admin + @on_command("test") + async def handler(): + pass + + meta = get_handler_meta(handler) + assert meta.permissions.require_admin is True