mattermost/python-sdk/tests/test_hooks_command_config.py
Nick Misasi 4813f47b3f test(07-03): add comprehensive tests for remaining hooks
- Add test_hooks_command_config.py: ExecuteCommand and
  ConfigurationWillBeSaved hook tests
- Add test_hooks_user_channel.py: User lifecycle, channel/team,
  and SAML login hook tests
- Add test_hooks_notifications.py: Reaction, notification, and
  preferences hook tests
- Add test_hooks_system.py: System, WebSocket, cluster, shared
  channels, and support data hook tests
- All 65 new tests verify correct hook semantics including:
  - Fire-and-forget vs return-value hooks
  - Rejection string semantics for UserWillLogIn
  - Modify/reject semantics for notifications
  - Error handling for all hook types

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

306 lines
11 KiB
Python

# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
# See LICENSE.txt for license information.
"""
Tests for command and configuration hook implementations in the hook servicer.
Tests verify:
- ExecuteCommand: returns CommandResponse on success, AppError on failure
- ConfigurationWillBeSaved: allow/reject/modify semantics
"""
import pytest
from unittest.mock import MagicMock
from mattermost_plugin import Plugin, hook, HookName
from mattermost_plugin.servicers.hooks_servicer import PluginHooksServicerImpl
from mattermost_plugin.grpc import hooks_command_pb2
from mattermost_plugin.grpc import hooks_lifecycle_pb2
from mattermost_plugin.grpc import hooks_common_pb2
from mattermost_plugin.grpc import api_remaining_pb2
def make_plugin_context() -> hooks_common_pb2.PluginContext:
"""Create a test PluginContext."""
return hooks_common_pb2.PluginContext(
session_id="session123",
request_id="request123",
)
def make_command_args(command: str = "/test hello") -> api_remaining_pb2.CommandArgs:
"""Create test CommandArgs."""
return api_remaining_pb2.CommandArgs(
command=command,
user_id="user123",
channel_id="channel123",
team_id="team123",
root_id="",
trigger_id="trigger123",
)
class TestExecuteCommand:
"""Tests for ExecuteCommand hook."""
@pytest.mark.asyncio
async def test_error_when_not_implemented(self) -> None:
"""Test that error is returned when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_command_pb2.ExecuteCommandRequest(
plugin_context=make_plugin_context(),
args=make_command_args(),
)
response = await servicer.ExecuteCommand(request, context)
# Not implemented = error
assert response.HasField("error")
assert response.error.id == "plugin.execute_command.not_implemented"
assert response.error.status_code == 501
@pytest.mark.asyncio
async def test_success_with_response(self) -> None:
"""Test returning a CommandResponse."""
class TestPlugin(Plugin):
@hook(HookName.ExecuteCommand)
def handle_command(self, ctx, args):
return api_remaining_pb2.CommandResponse(
response_type="ephemeral",
text=f"Received: {args.command}",
)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_command_pb2.ExecuteCommandRequest(
plugin_context=make_plugin_context(),
args=make_command_args("/test hello"),
)
response = await servicer.ExecuteCommand(request, context)
assert not response.HasField("error")
assert response.HasField("response")
assert response.response.response_type == "ephemeral"
assert "Received: /test hello" in response.response.text
@pytest.mark.asyncio
async def test_handler_exception_returns_error(self) -> None:
"""Test that handler exceptions return an AppError."""
class TestPlugin(Plugin):
@hook(HookName.ExecuteCommand)
def handle_command(self, ctx, args):
raise ValueError("Command handler crashed")
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_command_pb2.ExecuteCommandRequest(
plugin_context=make_plugin_context(),
args=make_command_args(),
)
response = await servicer.ExecuteCommand(request, context)
assert response.HasField("error")
assert "Command handler crashed" in response.error.message
@pytest.mark.asyncio
async def test_receives_args(self) -> None:
"""Test that handler receives command arguments."""
received = []
class TestPlugin(Plugin):
@hook(HookName.ExecuteCommand)
def handle_command(self, ctx, args):
received.append(args)
return api_remaining_pb2.CommandResponse(text="ok")
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
args = make_command_args("/mycommand arg1 arg2")
request = hooks_command_pb2.ExecuteCommandRequest(
plugin_context=make_plugin_context(),
args=args,
)
await servicer.ExecuteCommand(request, context)
assert len(received) == 1
assert received[0].command == "/mycommand arg1 arg2"
assert received[0].user_id == "user123"
@pytest.mark.asyncio
async def test_async_handler_works(self) -> None:
"""Test that async handlers work correctly."""
class TestPlugin(Plugin):
@hook(HookName.ExecuteCommand)
async def handle_command(self, ctx, args):
return api_remaining_pb2.CommandResponse(
response_type="in_channel",
text="Async response",
)
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_command_pb2.ExecuteCommandRequest(
plugin_context=make_plugin_context(),
args=make_command_args(),
)
response = await servicer.ExecuteCommand(request, context)
assert response.response.text == "Async response"
class TestConfigurationWillBeSaved:
"""Tests for ConfigurationWillBeSaved hook."""
@pytest.mark.asyncio
async def test_allow_when_not_implemented(self) -> None:
"""Test that config is allowed when hook is not implemented."""
class EmptyPlugin(Plugin):
pass
plugin = EmptyPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=b'{"key": "value"}'),
)
response = await servicer.ConfigurationWillBeSaved(request, context)
# Not implemented = allow unchanged
assert not response.HasField("error")
assert not response.HasField("modified_config")
@pytest.mark.asyncio
async def test_allow_unchanged(self) -> None:
"""Test allowing config unchanged by returning None."""
class TestPlugin(Plugin):
@hook(HookName.ConfigurationWillBeSaved)
def validate_config(self, config):
return None # Allow unchanged
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=b'{"key": "value"}'),
)
response = await servicer.ConfigurationWillBeSaved(request, context)
assert not response.HasField("error")
assert not response.HasField("modified_config")
@pytest.mark.asyncio
async def test_reject_with_error(self) -> None:
"""Test rejecting config by returning (None, 'error')."""
class TestPlugin(Plugin):
@hook(HookName.ConfigurationWillBeSaved)
def validate_config(self, config):
return None, "Invalid configuration: missing required field"
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=b'{}'),
)
response = await servicer.ConfigurationWillBeSaved(request, context)
assert response.HasField("error")
assert "Invalid configuration" in response.error.message
@pytest.mark.asyncio
async def test_modify_config(self) -> None:
"""Test modifying config by returning a modified config."""
import json
class TestPlugin(Plugin):
@hook(HookName.ConfigurationWillBeSaved)
def validate_config(self, config):
# Parse, modify, and return
data = json.loads(config.config_json)
data["modified"] = True
modified = hooks_lifecycle_pb2.ConfigJson(
config_json=json.dumps(data).encode()
)
return modified, None
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=b'{"key": "value"}'),
)
response = await servicer.ConfigurationWillBeSaved(request, context)
assert not response.HasField("error")
assert response.HasField("modified_config")
data = json.loads(response.modified_config.config_json)
assert data["modified"] is True
@pytest.mark.asyncio
async def test_handler_exception_rejects(self) -> None:
"""Test that handler exceptions result in rejection."""
class TestPlugin(Plugin):
@hook(HookName.ConfigurationWillBeSaved)
def validate_config(self, config):
raise ValueError("Config validation failed")
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=b'{}'),
)
response = await servicer.ConfigurationWillBeSaved(request, context)
assert response.HasField("error")
assert "Config validation failed" in response.error.message
@pytest.mark.asyncio
async def test_receives_config(self) -> None:
"""Test that handler receives the configuration."""
received = []
class TestPlugin(Plugin):
@hook(HookName.ConfigurationWillBeSaved)
def validate_config(self, config):
received.append(config.config_json)
return None
plugin = TestPlugin()
servicer = PluginHooksServicerImpl(plugin)
context = MagicMock()
config_data = b'{"setting": "test_value"}'
request = hooks_lifecycle_pb2.ConfigurationWillBeSavedRequest(
new_config=hooks_lifecycle_pb2.ConfigJson(config_json=config_data),
)
await servicer.ConfigurationWillBeSaved(request, context)
assert len(received) == 1
assert received[0] == config_data