Files
hermes-agent/tests/gateway/test_slack.py
teknium1 115dd17b3c test: fix gateway async test event loop usage
Use asyncio.run in sync tests that were relying on an implicit current event loop. This makes the gateway send-image and Slack connect tests pass reliably under Python 3.11+ and xdist workers.
2026-03-14 02:52:47 -07:00

949 lines
34 KiB
Python

"""
Tests for Slack platform adapter.
Covers: app_mention handler, send_document, send_video,
incoming document handling, message routing.
Note: slack-bolt may not be installed in the test environment.
We mock the slack modules at import time to avoid collection errors.
"""
import asyncio
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
)
# ---------------------------------------------------------------------------
# Mock the slack-bolt package if it's not installed
# ---------------------------------------------------------------------------
def _ensure_slack_mock():
"""Install mock slack modules so SlackAdapter can be imported."""
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return # Real library installed
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)
_ensure_slack_mock()
# Patch SLACK_AVAILABLE before importing the adapter
import gateway.platforms.slack as _slack_mod
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def adapter():
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
a = SlackAdapter(config)
# Mock the Slack app client
a._app = MagicMock()
a._app.client = AsyncMock()
a._bot_user_id = "U_BOT"
a._running = True
# Capture events instead of processing them
a.handle_message = AsyncMock()
return a
@pytest.fixture(autouse=True)
def _redirect_cache(tmp_path, monkeypatch):
"""Point document cache to tmp_path so tests don't touch ~/.hermes."""
monkeypatch.setattr(
"gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
)
# ---------------------------------------------------------------------------
# TestAppMentionHandler
# ---------------------------------------------------------------------------
class TestAppMentionHandler:
"""Verify that the app_mention event handler is registered."""
def test_app_mention_registered_on_connect(self):
"""connect() should register both 'message' and 'app_mention' handlers."""
config = PlatformConfig(enabled=True, token="xoxb-fake")
adapter = SlackAdapter(config)
# Track which events get registered
registered_events = []
registered_commands = []
mock_app = MagicMock()
def mock_event(event_type):
def decorator(fn):
registered_events.append(event_type)
return fn
return decorator
def mock_command(cmd):
def decorator(fn):
registered_commands.append(cmd)
return fn
return decorator
mock_app.event = mock_event
mock_app.command = mock_command
mock_app.client = AsyncMock()
mock_app.client.auth_test = AsyncMock(return_value={
"user_id": "U_BOT",
"user": "testbot",
})
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
patch("asyncio.create_task"):
asyncio.run(adapter.connect())
assert "message" in registered_events
assert "app_mention" in registered_events
assert "/hermes" in registered_commands
# ---------------------------------------------------------------------------
# TestSendDocument
# ---------------------------------------------------------------------------
class TestSendDocument:
@pytest.mark.asyncio
async def test_send_document_success(self, adapter, tmp_path):
test_file = tmp_path / "report.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content")
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
result = await adapter.send_document(
chat_id="C123",
file_path=str(test_file),
caption="Here's the report",
)
assert result.success
adapter._app.client.files_upload_v2.assert_called_once()
call_kwargs = adapter._app.client.files_upload_v2.call_args[1]
assert call_kwargs["channel"] == "C123"
assert call_kwargs["file"] == str(test_file)
assert call_kwargs["filename"] == "report.pdf"
assert call_kwargs["initial_comment"] == "Here's the report"
@pytest.mark.asyncio
async def test_send_document_custom_name(self, adapter, tmp_path):
test_file = tmp_path / "data.csv"
test_file.write_bytes(b"a,b,c\n1,2,3")
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
result = await adapter.send_document(
chat_id="C123",
file_path=str(test_file),
file_name="quarterly-report.csv",
)
assert result.success
call_kwargs = adapter._app.client.files_upload_v2.call_args[1]
assert call_kwargs["filename"] == "quarterly-report.csv"
@pytest.mark.asyncio
async def test_send_document_missing_file(self, adapter):
result = await adapter.send_document(
chat_id="C123",
file_path="/nonexistent/file.pdf",
)
assert not result.success
assert "not found" in result.error.lower()
@pytest.mark.asyncio
async def test_send_document_not_connected(self, adapter):
adapter._app = None
result = await adapter.send_document(
chat_id="C123",
file_path="/some/file.pdf",
)
assert not result.success
assert "Not connected" in result.error
@pytest.mark.asyncio
async def test_send_document_api_error_falls_back(self, adapter, tmp_path):
test_file = tmp_path / "doc.pdf"
test_file.write_bytes(b"content")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=RuntimeError("Slack API error")
)
# Should fall back to base class (text message)
result = await adapter.send_document(
chat_id="C123",
file_path=str(test_file),
)
# Base class send() is also mocked, so check it was attempted
adapter._app.client.chat_postMessage.assert_called_once()
@pytest.mark.asyncio
async def test_send_document_with_thread(self, adapter, tmp_path):
test_file = tmp_path / "notes.txt"
test_file.write_bytes(b"some notes")
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
result = await adapter.send_document(
chat_id="C123",
file_path=str(test_file),
reply_to="1234567890.123456",
)
assert result.success
call_kwargs = adapter._app.client.files_upload_v2.call_args[1]
assert call_kwargs["thread_ts"] == "1234567890.123456"
# ---------------------------------------------------------------------------
# TestSendVideo
# ---------------------------------------------------------------------------
class TestSendVideo:
@pytest.mark.asyncio
async def test_send_video_success(self, adapter, tmp_path):
video = tmp_path / "clip.mp4"
video.write_bytes(b"fake video data")
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
result = await adapter.send_video(
chat_id="C123",
video_path=str(video),
caption="Check this out",
)
assert result.success
call_kwargs = adapter._app.client.files_upload_v2.call_args[1]
assert call_kwargs["filename"] == "clip.mp4"
assert call_kwargs["initial_comment"] == "Check this out"
@pytest.mark.asyncio
async def test_send_video_missing_file(self, adapter):
result = await adapter.send_video(
chat_id="C123",
video_path="/nonexistent/video.mp4",
)
assert not result.success
assert "not found" in result.error.lower()
@pytest.mark.asyncio
async def test_send_video_not_connected(self, adapter):
adapter._app = None
result = await adapter.send_video(
chat_id="C123",
video_path="/some/video.mp4",
)
assert not result.success
assert "Not connected" in result.error
@pytest.mark.asyncio
async def test_send_video_api_error_falls_back(self, adapter, tmp_path):
video = tmp_path / "clip.mp4"
video.write_bytes(b"fake video")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=RuntimeError("Slack API error")
)
# Should fall back to base class (text message)
result = await adapter.send_video(
chat_id="C123",
video_path=str(video),
)
adapter._app.client.chat_postMessage.assert_called_once()
# ---------------------------------------------------------------------------
# TestIncomingDocumentHandling
# ---------------------------------------------------------------------------
class TestIncomingDocumentHandling:
def _make_event(self, files=None, text="hello", channel_type="im"):
"""Build a mock Slack message event with file attachments."""
return {
"text": text,
"user": "U_USER",
"channel": "C123",
"channel_type": channel_type,
"ts": "1234567890.000001",
"files": files or [],
}
@pytest.mark.asyncio
async def test_pdf_document_cached(self, adapter):
"""A PDF attachment should be downloaded, cached, and set as DOCUMENT type."""
pdf_bytes = b"%PDF-1.4 fake content"
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
dl.return_value = pdf_bytes
event = self._make_event(files=[{
"mimetype": "application/pdf",
"name": "report.pdf",
"url_private_download": "https://files.slack.com/report.pdf",
"size": len(pdf_bytes),
}])
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.DOCUMENT
assert len(msg_event.media_urls) == 1
assert os.path.exists(msg_event.media_urls[0])
assert msg_event.media_types == ["application/pdf"]
@pytest.mark.asyncio
async def test_txt_document_injects_content(self, adapter):
"""A .txt file under 100KB should have its content injected into event text."""
content = b"Hello from a text file"
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
dl.return_value = content
event = self._make_event(
text="summarize this",
files=[{
"mimetype": "text/plain",
"name": "notes.txt",
"url_private_download": "https://files.slack.com/notes.txt",
"size": len(content),
}],
)
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert "Hello from a text file" in msg_event.text
assert "[Content of notes.txt]" in msg_event.text
assert "summarize this" in msg_event.text
@pytest.mark.asyncio
async def test_md_document_injects_content(self, adapter):
"""A .md file under 100KB should have its content injected."""
content = b"# Title\nSome markdown content"
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
dl.return_value = content
event = self._make_event(files=[{
"mimetype": "text/markdown",
"name": "readme.md",
"url_private_download": "https://files.slack.com/readme.md",
"size": len(content),
}], text="")
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert "# Title" in msg_event.text
@pytest.mark.asyncio
async def test_large_txt_not_injected(self, adapter):
"""A .txt file over 100KB should be cached but NOT injected."""
content = b"x" * (200 * 1024)
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
dl.return_value = content
event = self._make_event(files=[{
"mimetype": "text/plain",
"name": "big.txt",
"url_private_download": "https://files.slack.com/big.txt",
"size": len(content),
}], text="")
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert len(msg_event.media_urls) == 1
assert "[Content of" not in (msg_event.text or "")
@pytest.mark.asyncio
async def test_unsupported_file_type_skipped(self, adapter):
"""A .zip file should be silently skipped."""
event = self._make_event(files=[{
"mimetype": "application/zip",
"name": "archive.zip",
"url_private_download": "https://files.slack.com/archive.zip",
"size": 1024,
}])
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.TEXT
assert len(msg_event.media_urls) == 0
@pytest.mark.asyncio
async def test_oversized_document_skipped(self, adapter):
"""A document over 20MB should be skipped."""
event = self._make_event(files=[{
"mimetype": "application/pdf",
"name": "huge.pdf",
"url_private_download": "https://files.slack.com/huge.pdf",
"size": 25 * 1024 * 1024,
}])
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert len(msg_event.media_urls) == 0
@pytest.mark.asyncio
async def test_document_download_error_handled(self, adapter):
"""If document download fails, handler should not crash."""
with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl:
dl.side_effect = RuntimeError("download failed")
event = self._make_event(files=[{
"mimetype": "application/pdf",
"name": "report.pdf",
"url_private_download": "https://files.slack.com/report.pdf",
"size": 1024,
}])
await adapter._handle_slack_message(event)
# Handler should still be called (the exception is caught)
adapter.handle_message.assert_called_once()
@pytest.mark.asyncio
async def test_image_still_handled(self, adapter):
"""Image attachments should still go through the image path, not document."""
with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl:
dl.return_value = "/tmp/cached_image.jpg"
event = self._make_event(files=[{
"mimetype": "image/jpeg",
"name": "photo.jpg",
"url_private_download": "https://files.slack.com/photo.jpg",
"size": 1024,
}])
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.PHOTO
# ---------------------------------------------------------------------------
# TestMessageRouting
# ---------------------------------------------------------------------------
class TestMessageRouting:
@pytest.mark.asyncio
async def test_dm_processed_without_mention(self, adapter):
"""DM messages should be processed without requiring a bot mention."""
event = {
"text": "hello",
"user": "U_USER",
"channel": "D123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_called_once()
@pytest.mark.asyncio
async def test_channel_message_requires_mention(self, adapter):
"""Channel messages without a bot mention should be ignored."""
event = {
"text": "just talking",
"user": "U_USER",
"channel": "C123",
"channel_type": "channel",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_channel_mention_strips_bot_id(self, adapter):
"""When mentioned in a channel, the bot mention should be stripped."""
event = {
"text": "<@U_BOT> what's the weather?",
"user": "U_USER",
"channel": "C123",
"channel_type": "channel",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "what's the weather?"
assert "<@U_BOT>" not in msg_event.text
@pytest.mark.asyncio
async def test_bot_messages_ignored(self, adapter):
"""Messages from bots should be ignored."""
event = {
"text": "bot response",
"bot_id": "B_OTHER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_message_edits_ignored(self, adapter):
"""Message edits should be ignored."""
event = {
"text": "edited message",
"user": "U_USER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
"subtype": "message_changed",
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_not_called()
# ---------------------------------------------------------------------------
# TestSendTyping — assistant.threads.setStatus
# ---------------------------------------------------------------------------
class TestSendTyping:
"""Test typing indicator via assistant.threads.setStatus."""
@pytest.mark.asyncio
async def test_sets_status_in_thread(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"})
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="parent_ts",
status="is thinking...",
)
@pytest.mark.asyncio
async def test_noop_without_thread(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing("C123")
adapter._app.client.assistant_threads_setStatus.assert_not_called()
@pytest.mark.asyncio
async def test_handles_missing_scope_gracefully(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock(
side_effect=Exception("missing_scope")
)
# Should not raise
await adapter.send_typing("C123", metadata={"thread_id": "ts1"})
@pytest.mark.asyncio
async def test_uses_thread_ts_fallback(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing("C123", metadata={"thread_ts": "fallback_ts"})
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="fallback_ts",
status="is thinking...",
)
# ---------------------------------------------------------------------------
# TestFormatMessage — Markdown → mrkdwn conversion
# ---------------------------------------------------------------------------
class TestFormatMessage:
"""Test markdown to Slack mrkdwn conversion."""
def test_bold_conversion(self, adapter):
assert adapter.format_message("**hello**") == "*hello*"
def test_italic_asterisk_conversion(self, adapter):
assert adapter.format_message("*hello*") == "_hello_"
def test_italic_underscore_preserved(self, adapter):
assert adapter.format_message("_hello_") == "_hello_"
def test_header_to_bold(self, adapter):
assert adapter.format_message("## Section Title") == "*Section Title*"
def test_header_with_bold_content(self, adapter):
# **bold** inside a header should not double-wrap
assert adapter.format_message("## **Title**") == "*Title*"
def test_link_conversion(self, adapter):
result = adapter.format_message("[click here](https://example.com)")
assert result == "<https://example.com|click here>"
def test_strikethrough(self, adapter):
assert adapter.format_message("~~deleted~~") == "~deleted~"
def test_code_block_preserved(self, adapter):
code = "```python\nx = **not bold**\n```"
assert adapter.format_message(code) == code
def test_inline_code_preserved(self, adapter):
text = "Use `**raw**` syntax"
assert adapter.format_message(text) == "Use `**raw**` syntax"
def test_mixed_content(self, adapter):
text = "**Bold** and *italic* with `code`"
result = adapter.format_message(text)
assert "*Bold*" in result
assert "_italic_" in result
assert "`code`" in result
def test_empty_string(self, adapter):
assert adapter.format_message("") == ""
def test_none_passthrough(self, adapter):
assert adapter.format_message(None) is None
# ---------------------------------------------------------------------------
# TestReactions
# ---------------------------------------------------------------------------
class TestReactions:
"""Test emoji reaction methods."""
@pytest.mark.asyncio
async def test_add_reaction_calls_api(self, adapter):
adapter._app.client.reactions_add = AsyncMock()
result = await adapter._add_reaction("C123", "ts1", "eyes")
assert result is True
adapter._app.client.reactions_add.assert_called_once_with(
channel="C123", timestamp="ts1", name="eyes"
)
@pytest.mark.asyncio
async def test_add_reaction_handles_error(self, adapter):
adapter._app.client.reactions_add = AsyncMock(side_effect=Exception("already_reacted"))
result = await adapter._add_reaction("C123", "ts1", "eyes")
assert result is False
@pytest.mark.asyncio
async def test_remove_reaction_calls_api(self, adapter):
adapter._app.client.reactions_remove = AsyncMock()
result = await adapter._remove_reaction("C123", "ts1", "eyes")
assert result is True
@pytest.mark.asyncio
async def test_reactions_in_message_flow(self, adapter):
"""Reactions should be added on receipt and swapped on completion."""
adapter._app.client.reactions_add = AsyncMock()
adapter._app.client.reactions_remove = AsyncMock()
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
event = {
"text": "hello",
"user": "U_USER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
# Should have added 👀, then removed 👀, then added ✅
add_calls = adapter._app.client.reactions_add.call_args_list
remove_calls = adapter._app.client.reactions_remove.call_args_list
assert len(add_calls) == 2
assert add_calls[0].kwargs["name"] == "eyes"
assert add_calls[1].kwargs["name"] == "white_check_mark"
assert len(remove_calls) == 1
assert remove_calls[0].kwargs["name"] == "eyes"
# ---------------------------------------------------------------------------
# TestUserNameResolution
# ---------------------------------------------------------------------------
class TestUserNameResolution:
"""Test user identity resolution."""
@pytest.mark.asyncio
async def test_resolves_display_name(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}}
})
name = await adapter._resolve_user_name("U123")
assert name == "Tyler"
@pytest.mark.asyncio
async def test_falls_back_to_real_name(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "", "real_name": "Tyler B"}}
})
name = await adapter._resolve_user_name("U123")
assert name == "Tyler B"
@pytest.mark.asyncio
async def test_caches_result(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
await adapter._resolve_user_name("U123")
await adapter._resolve_user_name("U123")
# Only one API call despite two lookups
assert adapter._app.client.users_info.call_count == 1
@pytest.mark.asyncio
async def test_handles_api_error(self, adapter):
adapter._app.client.users_info = AsyncMock(side_effect=Exception("rate limited"))
name = await adapter._resolve_user_name("U123")
assert name == "U123" # Falls back to user_id
@pytest.mark.asyncio
async def test_user_name_in_message_source(self, adapter):
"""Message source should include resolved user name."""
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
adapter._app.client.reactions_add = AsyncMock()
adapter._app.client.reactions_remove = AsyncMock()
event = {
"text": "hello",
"user": "U_USER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
# Check the source in the MessageEvent passed to handle_message
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.user_name == "Tyler"
# ---------------------------------------------------------------------------
# TestSlashCommands — expanded command set
# ---------------------------------------------------------------------------
class TestSlashCommands:
"""Test slash command routing."""
@pytest.mark.asyncio
async def test_compact_maps_to_compress(self, adapter):
command = {"text": "compact", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/compress"
@pytest.mark.asyncio
async def test_resume_command(self, adapter):
command = {"text": "resume my session", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/resume my session"
@pytest.mark.asyncio
async def test_background_command(self, adapter):
command = {"text": "background run tests", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/background run tests"
@pytest.mark.asyncio
async def test_usage_command(self, adapter):
command = {"text": "usage", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/usage"
@pytest.mark.asyncio
async def test_reasoning_command(self, adapter):
command = {"text": "reasoning", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/reasoning"
# ---------------------------------------------------------------------------
# TestMessageSplitting
# ---------------------------------------------------------------------------
class TestMessageSplitting:
"""Test that long messages are split before sending."""
@pytest.mark.asyncio
async def test_long_message_split_into_chunks(self, adapter):
"""Messages over MAX_MESSAGE_LENGTH should be split."""
long_text = "x" * 45000 # Over Slack's 40k API limit
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", long_text)
# Should have been called multiple times
assert adapter._app.client.chat_postMessage.call_count >= 2
@pytest.mark.asyncio
async def test_short_message_single_send(self, adapter):
"""Short messages should be sent in one call."""
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hello world")
assert adapter._app.client.chat_postMessage.call_count == 1
# ---------------------------------------------------------------------------
# TestReplyBroadcast
# ---------------------------------------------------------------------------
class TestReplyBroadcast:
"""Test reply_broadcast config option."""
@pytest.mark.asyncio
async def test_broadcast_disabled_by_default(self, adapter):
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"})
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert "reply_broadcast" not in kwargs
@pytest.mark.asyncio
async def test_broadcast_enabled_via_config(self, adapter):
adapter.config.extra["reply_broadcast"] = True
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"})
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert kwargs.get("reply_broadcast") is True
# ---------------------------------------------------------------------------
# TestFallbackPreservesThreadContext
# ---------------------------------------------------------------------------
class TestFallbackPreservesThreadContext:
"""Bug fix: file upload fallbacks lost thread context (metadata) when
calling super() without metadata, causing replies to appear outside
the thread."""
@pytest.mark.asyncio
async def test_send_image_file_fallback_preserves_thread(self, adapter, tmp_path):
test_file = tmp_path / "photo.jpg"
test_file.write_bytes(b"\xff\xd8\xff\xe0")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=Exception("upload failed")
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "msg_ts"}
)
metadata = {"thread_id": "parent_ts_123"}
await adapter.send_image_file(
chat_id="C123",
image_path=str(test_file),
caption="test image",
metadata=metadata,
)
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert call_kwargs.get("thread_ts") == "parent_ts_123"
@pytest.mark.asyncio
async def test_send_video_fallback_preserves_thread(self, adapter, tmp_path):
test_file = tmp_path / "clip.mp4"
test_file.write_bytes(b"\x00\x00\x00\x1c")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=Exception("upload failed")
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "msg_ts"}
)
metadata = {"thread_id": "parent_ts_456"}
await adapter.send_video(
chat_id="C123",
video_path=str(test_file),
metadata=metadata,
)
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert call_kwargs.get("thread_ts") == "parent_ts_456"
@pytest.mark.asyncio
async def test_send_document_fallback_preserves_thread(self, adapter, tmp_path):
test_file = tmp_path / "report.pdf"
test_file.write_bytes(b"%PDF-1.4")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=Exception("upload failed")
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "msg_ts"}
)
metadata = {"thread_id": "parent_ts_789"}
await adapter.send_document(
chat_id="C123",
file_path=str(test_file),
caption="report",
metadata=metadata,
)
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert call_kwargs.get("thread_ts") == "parent_ts_789"
@pytest.mark.asyncio
async def test_send_image_file_fallback_includes_caption(self, adapter, tmp_path):
test_file = tmp_path / "photo.jpg"
test_file.write_bytes(b"\xff\xd8\xff\xe0")
adapter._app.client.files_upload_v2 = AsyncMock(
side_effect=Exception("upload failed")
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "msg_ts"}
)
await adapter.send_image_file(
chat_id="C123",
image_path=str(test_file),
caption="important screenshot",
)
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert "important screenshot" in call_kwargs["text"]