mattermost/python-sdk/tests/test_hook_runner.py
Nick Misasi cf89dd1b32 test(07-01): add hook runner unit tests
- Test sync and async handler execution
- Test timeout handling with HookTimeoutError
- Test exception wrapping in HookInvocationError
- Test gRPC status code conversion
- Test HookRunner class invocation

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

327 lines
10 KiB
Python

# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
# See LICENSE.txt for license information.
"""
Tests for hook invocation runner.
Tests verify:
- Sync handlers are executed without blocking (via to_thread)
- Async handlers are awaited directly
- Timeouts produce expected errors
- Exception conversion to gRPC status codes
"""
import asyncio
import time
from typing import Any
from unittest.mock import MagicMock
import grpc
import pytest
from mattermost_plugin._internal.hook_runner import (
DEFAULT_HOOK_TIMEOUT,
HookInvocationError,
HookRunner,
HookTimeoutError,
convert_hook_error_to_grpc_status,
invoke_hook_safe,
run_hook_async,
)
class TestRunHookAsync:
"""Tests for run_hook_async function."""
@pytest.mark.asyncio
async def test_sync_handler_executed(self) -> None:
"""Test that sync handlers are executed successfully."""
call_count = [0]
def sync_handler(arg: str) -> str:
call_count[0] += 1
return f"result: {arg}"
result = await run_hook_async(
sync_handler, "test", timeout=5.0, hook_name="TestHook"
)
assert call_count[0] == 1
assert result == "result: test"
@pytest.mark.asyncio
async def test_async_handler_awaited(self) -> None:
"""Test that async handlers are awaited directly."""
call_count = [0]
async def async_handler(arg: str) -> str:
call_count[0] += 1
await asyncio.sleep(0.01) # Small delay to prove it's awaited
return f"async result: {arg}"
result = await run_hook_async(
async_handler, "test", timeout=5.0, hook_name="TestHook"
)
assert call_count[0] == 1
assert result == "async result: test"
@pytest.mark.asyncio
async def test_sync_handler_timeout(self) -> None:
"""Test that slow sync handlers trigger timeout."""
def slow_handler() -> str:
time.sleep(1.0) # Blocking sleep
return "never reached"
with pytest.raises(HookTimeoutError) as exc_info:
await run_hook_async(
slow_handler, timeout=0.1, hook_name="SlowHook"
)
assert exc_info.value.hook_name == "SlowHook"
assert exc_info.value.timeout == 0.1
@pytest.mark.asyncio
async def test_async_handler_timeout(self) -> None:
"""Test that slow async handlers trigger timeout."""
async def slow_async_handler() -> str:
await asyncio.sleep(1.0)
return "never reached"
with pytest.raises(HookTimeoutError) as exc_info:
await run_hook_async(
slow_async_handler, timeout=0.1, hook_name="SlowAsyncHook"
)
assert exc_info.value.hook_name == "SlowAsyncHook"
@pytest.mark.asyncio
async def test_handler_exception_wrapped(self) -> None:
"""Test that handler exceptions are wrapped in HookInvocationError."""
def failing_handler() -> None:
raise ValueError("Something went wrong")
with pytest.raises(HookInvocationError) as exc_info:
await run_hook_async(
failing_handler, timeout=5.0, hook_name="FailingHook"
)
assert exc_info.value.hook_name == "FailingHook"
assert isinstance(exc_info.value.original_error, ValueError)
@pytest.mark.asyncio
async def test_handler_with_kwargs(self) -> None:
"""Test that kwargs are passed to handler."""
def handler_with_kwargs(*, name: str, value: int) -> str:
return f"{name}={value}"
result = await run_hook_async(
handler_with_kwargs,
timeout=5.0,
hook_name="TestHook",
name="test",
value=42,
)
assert result == "test=42"
class TestConvertHookErrorToGrpcStatus:
"""Tests for exception to gRPC status conversion."""
def test_timeout_error_to_deadline_exceeded(self) -> None:
"""Test HookTimeoutError maps to DEADLINE_EXCEEDED."""
error = HookTimeoutError("TestHook", 30.0)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.DEADLINE_EXCEEDED
assert "timed out" in details.lower()
def test_value_error_to_invalid_argument(self) -> None:
"""Test ValueError maps to INVALID_ARGUMENT."""
original = ValueError("bad value")
error = HookInvocationError("TestHook", original)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.INVALID_ARGUMENT
def test_permission_error_to_permission_denied(self) -> None:
"""Test PermissionError maps to PERMISSION_DENIED."""
original = PermissionError("access denied")
error = HookInvocationError("TestHook", original)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.PERMISSION_DENIED
def test_file_not_found_to_not_found(self) -> None:
"""Test FileNotFoundError maps to NOT_FOUND."""
original = FileNotFoundError("file missing")
error = HookInvocationError("TestHook", original)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.NOT_FOUND
def test_not_implemented_to_unimplemented(self) -> None:
"""Test NotImplementedError maps to UNIMPLEMENTED."""
original = NotImplementedError("not supported")
error = HookInvocationError("TestHook", original)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.UNIMPLEMENTED
def test_generic_exception_to_internal(self) -> None:
"""Test generic exceptions map to INTERNAL."""
original = RuntimeError("unexpected error")
error = HookInvocationError("TestHook", original)
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.INTERNAL
def test_unknown_error_type_to_internal(self) -> None:
"""Test unknown error types map to INTERNAL."""
error = Exception("unknown")
status_code, details = convert_hook_error_to_grpc_status(error)
assert status_code == grpc.StatusCode.INTERNAL
class TestInvokeHookSafe:
"""Tests for invoke_hook_safe function."""
@pytest.mark.asyncio
async def test_returns_result_on_success(self) -> None:
"""Test successful invocation returns result."""
def handler() -> str:
return "success"
result, error = await invoke_hook_safe(
handler, timeout=5.0, hook_name="TestHook"
)
assert result == "success"
assert error is None
@pytest.mark.asyncio
async def test_returns_default_on_none_handler(self) -> None:
"""Test None handler returns default value."""
result, error = await invoke_hook_safe(
None, timeout=5.0, hook_name="TestHook", default="default_value"
)
assert result == "default_value"
assert error is None
@pytest.mark.asyncio
async def test_returns_default_on_failure(self) -> None:
"""Test failed invocation returns default and error."""
def failing_handler() -> None:
raise ValueError("failed")
result, error = await invoke_hook_safe(
failing_handler,
timeout=5.0,
hook_name="TestHook",
default="fallback",
)
assert result == "fallback"
assert error is not None
assert isinstance(error, HookInvocationError)
@pytest.mark.asyncio
async def test_sets_grpc_context_on_failure(self) -> None:
"""Test that gRPC context is set on failure."""
def failing_handler() -> None:
raise ValueError("bad input")
# Create mock context
mock_context = MagicMock()
result, error = await invoke_hook_safe(
failing_handler,
timeout=5.0,
hook_name="TestHook",
context=mock_context,
)
mock_context.set_code.assert_called_once()
mock_context.set_details.assert_called_once()
# Check the status code
call_args = mock_context.set_code.call_args[0]
assert call_args[0] == grpc.StatusCode.INVALID_ARGUMENT
class TestHookRunner:
"""Tests for HookRunner class."""
@pytest.mark.asyncio
async def test_invoke_with_default_timeout(self) -> None:
"""Test runner uses default timeout."""
runner = HookRunner(timeout=10.0)
def handler() -> str:
return "done"
result, error = await runner.invoke(handler, hook_name="TestHook")
assert result == "done"
assert error is None
@pytest.mark.asyncio
async def test_invoke_with_override_timeout(self) -> None:
"""Test runner respects timeout override."""
runner = HookRunner(timeout=60.0)
async def slow_handler() -> str:
await asyncio.sleep(1.0)
return "never"
result, error = await runner.invoke(
slow_handler, hook_name="SlowHook", timeout=0.1
)
assert result is None
assert isinstance(error, HookTimeoutError)
@pytest.mark.asyncio
async def test_invoke_with_context(self) -> None:
"""Test runner passes context to error handler."""
runner = HookRunner()
mock_context = MagicMock()
def failing() -> None:
raise RuntimeError("oops")
result, error = await runner.invoke(
failing, hook_name="FailHook", context=mock_context
)
mock_context.set_code.assert_called_once()
class TestDefaultTimeout:
"""Tests for default timeout behavior."""
def test_default_timeout_value(self) -> None:
"""Test that default timeout is 30 seconds."""
assert DEFAULT_HOOK_TIMEOUT == 30.0
@pytest.mark.asyncio
async def test_uses_default_timeout_when_not_specified(self) -> None:
"""Test that default timeout is used when not specified."""
# This is more of a behavioral test - we can't easily verify
# the actual timeout without making it fail, but we can verify
# it doesn't fail immediately
def fast_handler() -> str:
return "fast"
result = await run_hook_async(fast_handler, hook_name="FastHook")
assert result == "fast"