mattermost/python-sdk/tests/test_hook_registry.py
Nick Misasi 9cbd52e2ba test(07-01): add hook registry unit tests
- Test hook decorator with enum, string, and inferred names
- Test Plugin base class hook discovery via __init_subclass__
- Test duplicate registration error
- Test implemented_hooks() returns sorted canonical names
- Test invoke_hook() and get_hook_handler() methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:18:33 -05:00

281 lines
9 KiB
Python

# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
# See LICENSE.txt for license information.
"""
Tests for hook registration mechanism.
Tests verify:
- Decorated methods are discovered via __init_subclass__
- Duplicate registration fails loudly
- implemented_hooks() emits canonical names
- Various decorator forms work correctly
"""
import pytest
from mattermost_plugin import Plugin, hook, HookName, HookRegistrationError
class TestHookDecorator:
"""Tests for the @hook decorator."""
def test_hook_with_enum(self) -> None:
"""Test @hook(HookName.X) form."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def my_activate(self) -> None:
pass
assert TestPlugin.has_hook("OnActivate")
assert "OnActivate" in TestPlugin.implemented_hooks()
def test_hook_with_string(self) -> None:
"""Test @hook("HookName") form."""
class TestPlugin(Plugin):
@hook("OnDeactivate")
def handle_deactivate(self) -> None:
pass
assert TestPlugin.has_hook("OnDeactivate")
assert "OnDeactivate" in TestPlugin.implemented_hooks()
def test_hook_inferred_from_name(self) -> None:
"""Test @hook form that infers name from method."""
class TestPlugin(Plugin):
@hook
def OnActivate(self) -> None:
pass
assert TestPlugin.has_hook("OnActivate")
def test_hook_preserves_function_metadata(self) -> None:
"""Test that @hook preserves __name__ and __doc__."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def my_activate(self) -> None:
"""My activation handler."""
pass
# Get the handler from registry
handler = TestPlugin._hook_registry.get("OnActivate")
assert handler is not None
assert handler.__name__ == "my_activate"
assert handler.__doc__ == "My activation handler."
def test_invalid_hook_name_raises(self) -> None:
"""Test that invalid hook names raise HookRegistrationError."""
with pytest.raises(HookRegistrationError, match="Unknown hook name"):
class TestPlugin(Plugin):
@hook("InvalidHookName")
def bad_hook(self) -> None:
pass
class TestPluginRegistration:
"""Tests for Plugin base class hook discovery."""
def test_multiple_hooks_registered(self) -> None:
"""Test that multiple hooks are discovered."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate(self) -> None:
pass
@hook(HookName.OnDeactivate)
def deactivate(self) -> None:
pass
@hook(HookName.MessageWillBePosted)
def filter_message(self, context: object, post: object) -> tuple:
return post, ""
hooks = TestPlugin.implemented_hooks()
assert len(hooks) == 3
assert "OnActivate" in hooks
assert "OnDeactivate" in hooks
assert "MessageWillBePosted" in hooks
def test_duplicate_hook_raises(self) -> None:
"""Test that registering the same hook twice raises error."""
with pytest.raises(HookRegistrationError, match="Duplicate hook registration"):
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate1(self) -> None:
pass
@hook(HookName.OnActivate)
def activate2(self) -> None:
pass
def test_implemented_hooks_is_sorted(self) -> None:
"""Test that implemented_hooks() returns sorted list."""
class TestPlugin(Plugin):
@hook(HookName.UserHasLoggedIn)
def login(self) -> None:
pass
@hook(HookName.OnActivate)
def activate(self) -> None:
pass
@hook(HookName.MessageWillBePosted)
def message(self, c: object, p: object) -> tuple:
return p, ""
hooks = TestPlugin.implemented_hooks()
assert hooks == sorted(hooks)
def test_subclass_does_not_inherit_parent_hooks(self) -> None:
"""Test that subclasses have their own hook registry."""
class ParentPlugin(Plugin):
@hook(HookName.OnActivate)
def parent_activate(self) -> None:
pass
class ChildPlugin(ParentPlugin):
@hook(HookName.OnDeactivate)
def child_deactivate(self) -> None:
pass
# Child should have both inherited and own hooks
# (because dir() sees inherited methods)
child_hooks = ChildPlugin.implemented_hooks()
assert "OnActivate" in child_hooks
assert "OnDeactivate" in child_hooks
# Parent should only have its own hook
parent_hooks = ParentPlugin.implemented_hooks()
assert "OnActivate" in parent_hooks
assert "OnDeactivate" not in parent_hooks
def test_has_hook_returns_false_for_missing(self) -> None:
"""Test has_hook returns False for unimplemented hooks."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate(self) -> None:
pass
assert TestPlugin.has_hook("OnActivate")
assert not TestPlugin.has_hook("OnDeactivate")
assert not TestPlugin.has_hook("NonExistentHook")
class TestPluginInvocation:
"""Tests for hook invocation via Plugin instance."""
def test_invoke_hook_calls_handler(self) -> None:
"""Test that invoke_hook calls the registered handler."""
call_count = [0]
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate(self) -> str:
call_count[0] += 1
return "activated"
plugin = TestPlugin()
result = plugin.invoke_hook("OnActivate")
assert call_count[0] == 1
assert result == "activated"
def test_invoke_hook_returns_none_for_missing(self) -> None:
"""Test that invoke_hook returns None for unimplemented hooks."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate(self) -> None:
pass
plugin = TestPlugin()
result = plugin.invoke_hook("OnDeactivate")
assert result is None
def test_invoke_hook_passes_arguments(self) -> None:
"""Test that invoke_hook passes arguments to handler."""
received_args = []
class TestPlugin(Plugin):
@hook(HookName.MessageWillBePosted)
def filter_message(self, context: object, post: object) -> tuple:
received_args.append((context, post))
return post, ""
plugin = TestPlugin()
ctx = {"request_id": "123"}
post = {"message": "hello"}
plugin.invoke_hook("MessageWillBePosted", ctx, post)
assert len(received_args) == 1
assert received_args[0] == (ctx, post)
def test_get_hook_handler_returns_bound_method(self) -> None:
"""Test that get_hook_handler returns a callable bound method."""
class TestPlugin(Plugin):
@hook(HookName.OnActivate)
def activate(self) -> str:
return "from handler"
plugin = TestPlugin()
handler = plugin.get_hook_handler("OnActivate")
assert handler is not None
assert callable(handler)
assert handler() == "from handler"
def test_get_hook_handler_returns_none_for_missing(self) -> None:
"""Test that get_hook_handler returns None for missing hooks."""
class TestPlugin(Plugin):
pass
plugin = TestPlugin()
handler = plugin.get_hook_handler("OnActivate")
assert handler is None
class TestHookNameEnum:
"""Tests for HookName enum."""
def test_enum_values_are_strings(self) -> None:
"""Test that HookName values are strings."""
assert HookName.OnActivate == "OnActivate"
assert HookName.MessageWillBePosted == "MessageWillBePosted"
def test_enum_can_be_used_in_comparisons(self) -> None:
"""Test that HookName can be compared with strings."""
assert HookName.OnActivate == "OnActivate"
assert "OnActivate" == HookName.OnActivate
def test_all_hooks_from_proto_are_present(self) -> None:
"""Test that key hooks from hooks.proto are in the enum."""
expected_hooks = [
"OnActivate",
"OnDeactivate",
"OnConfigurationChange",
"OnInstall",
"MessageWillBePosted",
"MessageWillBeUpdated",
"MessageHasBeenPosted",
"UserHasBeenCreated",
"UserWillLogIn",
"ChannelHasBeenCreated",
"ExecuteCommand",
"OnWebSocketConnect",
]
for hook_name in expected_hooks:
assert hasattr(HookName, hook_name), f"Missing hook: {hook_name}"
assert HookName[hook_name].value == hook_name