Files
aiturk-hermes-ide/tests/gateway/test_slack_plugin_action_handlers.py

266 lines
9.2 KiB
Python

"""Tests for plugin-registered Slack Block Kit action handlers.
Covers:
* ``PluginContext.register_slack_action_handler`` validation + queuing
* ``PluginManager.get_slack_action_handlers`` accessor
* ``SlackAdapter.connect`` wiring those handlers into the AsyncApp
* Defensive wrapping: a plugin handler that raises does NOT take down
the gateway and Slack still gets an ack.
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Ensure the repo root is importable when this test runs directly
# ---------------------------------------------------------------------------
_repo = str(Path(__file__).resolve().parents[2])
if _repo not in sys.path:
sys.path.insert(0, _repo)
# ---------------------------------------------------------------------------
# Mock slack-bolt so SlackAdapter can be imported even without the package
# ---------------------------------------------------------------------------
def _ensure_slack_mock() -> None:
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler",
slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)
sys.modules.setdefault("aiohttp", MagicMock())
_ensure_slack_mock()
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.config import PlatformConfig # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
from hermes_cli.plugins import ( # noqa: E402
PluginContext,
PluginManager,
PluginManifest,
)
# ---------------------------------------------------------------------------
# PluginContext.register_slack_action_handler — input validation + queuing
# ---------------------------------------------------------------------------
def _make_ctx(name: str = "test_plugin") -> tuple[PluginManager, PluginContext]:
"""Build a fresh PluginManager + PluginContext bound to it."""
mgr = PluginManager()
manifest = PluginManifest(
name=name,
version="0.1.0",
description="test",
)
ctx = PluginContext(manifest=manifest, manager=mgr)
return mgr, ctx
class TestRegisterSlackActionHandlerAPI:
"""Behaviour of ctx.register_slack_action_handler()."""
def test_string_action_id_is_queued(self):
mgr, ctx = _make_ctx()
async def cb(ack, body, action): # pragma: no cover - never called here
await ack()
ctx.register_slack_action_handler("inbox_sweep_approve", cb)
handlers = mgr.get_slack_action_handlers()
assert len(handlers) == 1
action_id, callback, plugin_name = handlers[0]
assert action_id == "inbox_sweep_approve"
assert callback is cb
assert plugin_name == "test_plugin"
def test_regex_action_id_is_accepted(self):
"""slack_bolt accepts re.Pattern matchers — so should the plugin API."""
import re as _re
mgr, ctx = _make_ctx()
async def cb(ack, body, action): # pragma: no cover
await ack()
pat = _re.compile(r"^inbox_sweep_.*$")
ctx.register_slack_action_handler(pat, cb)
handlers = mgr.get_slack_action_handlers()
assert handlers[0][0] is pat
def test_constraint_dict_action_id_is_accepted(self):
"""slack_bolt also accepts {"action_id": ..., "block_id": ...} dicts."""
mgr, ctx = _make_ctx()
async def cb(ack, body, action): # pragma: no cover
await ack()
constraint = {"action_id": "approve", "block_id": "row_3"}
ctx.register_slack_action_handler(constraint, cb)
handlers = mgr.get_slack_action_handlers()
assert handlers[0][0] == constraint
# ---------------------------------------------------------------------------
# SlackAdapter.connect wires plugin-registered handlers into AsyncApp
# ---------------------------------------------------------------------------
def _connect_with_recording_app(
adapter: SlackAdapter,
*,
plugin_handlers: list,
) -> tuple[bool, list]:
"""Run adapter.connect() with mocks and return (result, registered_actions).
Captures every action_id passed to ``app.action()`` so tests can
assert that built-in handlers AND plugin-supplied handlers were
wired up.
"""
registered_actions: list = [] # list of (action_id, callback)
def mock_action(action_id):
def decorator(fn):
registered_actions.append((action_id, fn))
return fn
return decorator
def mock_event(_event_type):
def decorator(fn):
return fn
return decorator
def mock_command(_cmd):
def decorator(fn):
return fn
return decorator
mock_app = MagicMock()
mock_app.event = mock_event
mock_app.command = mock_command
mock_app.action = mock_action
mock_app.client = AsyncMock()
mock_web_client = AsyncMock()
mock_web_client.auth_test = AsyncMock(return_value={
"user_id": "U_BOT",
"user": "testbot",
"team_id": "T_FAKE",
"team": "FakeTeam",
})
fake_mgr = MagicMock()
fake_mgr.get_slack_action_handlers.return_value = plugin_handlers
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
patch("gateway.status.release_scoped_lock"), \
patch("hermes_cli.plugins.get_plugin_manager", return_value=fake_mgr), \
patch("asyncio.create_task"):
result = asyncio.run(adapter.connect())
return result, registered_actions
class TestSlackAdapterPluginActionWiring:
"""connect() must register plugin-supplied action handlers on AsyncApp."""
def test_no_plugin_handlers_does_not_break_connect(self):
"""An empty plugin handler list is the common case — must be a no-op."""
config = PlatformConfig(enabled=True, token="xoxb-fake")
adapter = SlackAdapter(config)
result, registered = _connect_with_recording_app(
adapter, plugin_handlers=[],
)
assert result is True
# Built-ins still wired
action_ids = [aid for aid, _cb in registered]
assert "hermes_approve_once" in action_ids
def test_plugin_loader_failure_does_not_break_connect(self):
"""If get_plugin_manager() blows up, connect() must still succeed.
Defensive belt-and-suspenders: the gateway should not refuse to
start because the plugin layer is unhealthy.
"""
config = PlatformConfig(enabled=True, token="xoxb-fake")
adapter = SlackAdapter(config)
registered_actions: list = []
def mock_action(action_id):
def decorator(fn):
registered_actions.append((action_id, fn))
return fn
return decorator
def _noop(_):
def decorator(fn): return fn
return decorator
mock_app = MagicMock()
mock_app.event = _noop
mock_app.command = _noop
mock_app.action = mock_action
mock_app.client = AsyncMock()
mock_web_client = AsyncMock()
mock_web_client.auth_test = AsyncMock(return_value={
"user_id": "U_BOT",
"user": "testbot",
"team_id": "T_FAKE",
"team": "FakeTeam",
})
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
patch("gateway.status.release_scoped_lock"), \
patch("hermes_cli.plugins.get_plugin_manager",
side_effect=RuntimeError("plugins broken")), \
patch("asyncio.create_task"):
result = asyncio.run(adapter.connect())
assert result is True
# Built-ins still wired even when plugin loader failed.
action_ids = [aid for aid, _cb in registered_actions]
assert "hermes_approve_once" in action_ids