mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
- 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>
327 lines
10 KiB
Python
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"
|