"""Unit tests for the Chatto platform adapter. Covers: - Emoji shortcode conversion - Adapter instantiation and properties - Registration and requirements - Basic functionality with chattolib - Message sending and reactions - User lookup (with caching) - Presence and custom status All network calls are mocked — no real HTTP or WebSocket connections. """ import asyncio import os import sys import tempfile from unittest.mock import AsyncMock, MagicMock, patch, call from collections import OrderedDict import pytest import pytest_asyncio # Import chattolib types for tests - using vendored chattolib from adapter # -- Path setup -- # The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT # at a checkout to run these tests outside a deployed agent. PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__)) sys.path.insert(0, PLUGIN_ROOT) sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes")) sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto") from adapter import ( ChattoAdapter, hermes_check_fn as check_requirements, hermes_validate_config as validate_config, register, ) from chattolib.realtime_types import ReactionPayload from chattolib.types import ( AssetUrl, Message, MessageAttachment, Room, RoomKind, User, ) from platform_config import ChattoConstants from gateway.config import PlatformConfig from gateway.platforms.base import ( CachedMedia, MessageEvent, MessageType, SendResult, get_inbound_media_max_bytes, ) _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH _SEEN_CAP = ChattoConstants.SEEN_CAP # -- Helpers -- class _MockPluginContext: """Minimal mock for the plugin registration context.""" def __init__(self): self.registered_names = [] self.registered_kwargs = None def register_platform(self, **kwargs): from gateway.platform_registry import platform_registry, PlatformEntry entry = PlatformEntry( name=kwargs["name"], label=kwargs.get("label", kwargs["name"]), adapter_factory=kwargs.get("adapter_factory"), check_fn=kwargs.get("check_fn"), validate_config=kwargs.get("validate_config"), is_connected=kwargs.get("is_connected"), required_env=kwargs.get("required_env", []), source="plugin", ) platform_registry.register(entry) self.registered_names.append(kwargs["name"]) self.registered_kwargs = kwargs def _ensure_chatto_registered(): """Register the platform so Platform(PLATFORM_NAME) resolves.""" from gateway.platform_registry import platform_registry if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME): ctx = _MockPluginContext() register(ctx) _CHATTO_ENV_KEYS = [ "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD", "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL", "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS", "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD", "CHATTO_REACTIONS", ] def _clear_chatto_env(monkeypatch=None): """Remove all CHATTO_* env vars so tests start from a clean slate.""" for key in _CHATTO_ENV_KEYS: if monkeypatch is not None: monkeypatch.delenv(key, raising=False) else: os.environ.pop(key, None) def _make_config(**extra_overrides): """Create a minimal PlatformConfig for testing.""" _ensure_chatto_registered() extra = {"base_url": "https://chat.example.com", "channels": ["room1"]} extra.update(extra_overrides) return PlatformConfig(enabled=True, extra=extra) def _make_room(room_id, name, kind): """Build a real chattolib Room, as the client would return.""" return Room(id=room_id, name=name, kind=kind, description="", archived=False, group_id="", universal=kind != RoomKind.DM) def _make_user(user_id, login): """Build a real chattolib User, as the member directory would return.""" return User(id=user_id, login=login, display_name=login.replace("_", " ").title()) def _make_attachment(filename, content_type, url="https://cdn.example.com/a"): """Build a real MessageAttachment carrying a (pre-signed) asset URL.""" return MessageAttachment( id="asset-" + filename, filename=filename, content_type=content_type, asset_url=AssetUrl(url=url), ) def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"): """Build a real chattolib Message, as fetch_message() would return.""" return Message( id=message_id, room_id=room_id, created_at=None, actor_id="user-1", body=body, attachments=list(attachments or []), ) def _make_posted_payload(room_id="room-1", message_event_id="msg-1"): """A message_posted payload whose fetch_message() the caller stubs.""" payload = MagicMock() payload.room_id = room_id payload.message_event_id = message_event_id payload.thread_root_event_id = None return payload def _cached(path, media_type, kind): """The CachedMedia that cache_media_bytes() would return for an attachment.""" return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f") def _make_adapter(**extra_overrides): """Create a ChattoAdapter with mocked config.""" _clear_chatto_env() cfg = _make_config(**extra_overrides) adapter = ChattoAdapter(cfg) adapter._chatto_client = MagicMock() adapter._token = "test-token" adapter._user_id = "bot-user-id" adapter._user_login = "hermes_bot" adapter._user_display = "Hermes Bot" return adapter # -- Emoji shortcode conversion -- class TestEmojiShortcode: """Test emoji to shortcode mapping.""" def test_emoji_to_shortcode_exists(self): assert isinstance(_EMOJI_TO_SHORTCODE, dict) assert len(_EMOJI_TO_SHORTCODE) > 0 def test_emoji_to_shortcode_common_emojis(self): assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup" assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown" assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart" assert _EMOJI_TO_SHORTCODE.get("❤") == "heart" assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark" assert _EMOJI_TO_SHORTCODE.get("❌") == "x" # -- Adapter instantiation and properties -- class TestAdapterInstantiation: """Test ChattoAdapter creation and basic properties.""" def test_adapter_creation(self): cfg = _make_config() adapter = ChattoAdapter(cfg) assert adapter is not None # Platform members created dynamically from a plugin name carry the # name upper-cased; the registered identity is the value. assert adapter.platform.value == ChattoConstants.PLATFORM_NAME def test_adapter_max_message_length(self): """The framework chunks via max_message_length_for_chat(), which reads the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096 when it is missing.""" cfg = _make_config() adapter = ChattoAdapter(cfg) assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH def test_adapter_splits_long_messages(self): cfg = _make_config() adapter = ChattoAdapter(cfg) assert adapter.splits_long_messages is True def test_adapter_threads_enabled_by_default(self): """There is no capability flag for threads — Chatto threading is driven by the auto_thread setting, which defaults to on.""" cfg = _make_config() adapter = ChattoAdapter(cfg) assert adapter.chatto_config.auto_thread.value is True # -- Registration and requirements -- class TestRegistration: """Test plugin registration.""" def test_register_called(self): ctx = _MockPluginContext() register(ctx) assert ChattoConstants.PLATFORM_NAME in ctx.registered_names assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH def test_check_requirements(self): assert check_requirements() is True def test_check_requirements_missing(self): with patch("builtins.__import__", side_effect=ImportError("no chattolib")): assert check_requirements() is False def test_validate_config(self): _clear_chatto_env() os.environ["CHATTO_BASE_URL"] = "https://chat.test" os.environ["CHATTO_LOGIN"] = "user" os.environ["CHATTO_PASSWORD"] = "pass" cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"}) assert validate_config(cfg) is True _clear_chatto_env() # -- Send functionality -- class TestSend: """Test message sending functionality.""" @pytest_asyncio.fixture def adapter(self): _clear_chatto_env() cfg = _make_config() adapter = ChattoAdapter(cfg) adapter._chatto_client = MagicMock() adapter._chatto_client.post_message = AsyncMock() adapter._token = "test-token" adapter._user_id = "bot-user-id" return adapter async def test_send_calls_post_message(self, adapter): mock_msg = MagicMock() mock_msg.id = "msg-123" adapter._chatto_client.post_message.return_value = mock_msg result = await adapter.send("room-1", "Hello world") assert result.success is True assert result.message_id == "msg-123" adapter._chatto_client.post_message.assert_called_once() async def test_send_with_thread(self, adapter): mock_msg = MagicMock() mock_msg.id = "msg-456" adapter._chatto_client.post_message.return_value = mock_msg result = await adapter.send("room-1", "Hello", reply_to="thread-123") assert result.success is True call_kwargs = adapter._chatto_client.post_message.call_args.kwargs assert call_kwargs["thread_root_event_id"] == "thread-123" # -- Reactions -- class TestReactions: """Test reaction functionality.""" @pytest_asyncio.fixture def adapter(self): _clear_chatto_env() cfg = _make_config() adapter = ChattoAdapter(cfg) adapter._chatto_client = MagicMock() adapter._chatto_client.add_reaction = AsyncMock() adapter._chatto_client.remove_reaction = AsyncMock() adapter._token = "test-token" return adapter async def test_send_reaction(self, adapter): await adapter.add_reaction("room-1", "msg-1", "👍") adapter._chatto_client.add_reaction.assert_called_once() async def test_remove_reaction(self, adapter): await adapter.remove_reaction("room-1", "msg-1", "👍") adapter._chatto_client.remove_reaction.assert_called_once() async def test_on_processing_start_adds_eyes_reaction(self, adapter): """on_processing_start should call add_reaction with 👀.""" event = MagicMock() event.message_id = "msg-1" event.source.chat_id = "room-1" await adapter.on_processing_start(event) adapter._chatto_client.add_reaction.assert_called_once() call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs assert call_kwargs["message_event_id"] == "msg-1" assert call_kwargs["room_id"] == "room-1" assert call_kwargs["emoji"] == "eyes" async def test_on_processing_start_empty_message_id(self, adapter): """on_processing_start should skip reaction when message_id is empty.""" event = MagicMock() event.message_id = None event.source.chat_id = "room-1" await adapter.on_processing_start(event) adapter._chatto_client.add_reaction.assert_not_called() async def test_on_processing_start_reactions_disabled(self, adapter): """on_processing_start should skip when reactions config is False.""" adapter.chatto_config.reactions.value = False event = MagicMock() event.message_id = "msg-1" event.source.chat_id = "room-1" await adapter.on_processing_start(event) adapter._chatto_client.add_reaction.assert_not_called() # -- Edit and Delete Messages -- class TestMessageEditing: """Test message editing and deletion.""" @pytest_asyncio.fixture def adapter(self): _clear_chatto_env() cfg = _make_config() adapter = ChattoAdapter(cfg) adapter._chatto_client = MagicMock() adapter._chatto_client.update_message = AsyncMock() adapter._chatto_client.delete_message = AsyncMock(return_value=True) adapter._token = "test-token" return adapter async def test_edit_message(self, adapter): result = await adapter.edit_message("room-1", "msg-1", "New content") assert result.success is True adapter._chatto_client.update_message.assert_called_once() call_kwargs = adapter._chatto_client.update_message.call_args.kwargs assert call_kwargs["room_id"] == "room-1" assert call_kwargs["event_id"] == "msg-1" assert call_kwargs["body"] == "New content" async def test_edit_message_marks_own_edit_seen(self, adapter): """The edit echoes back as message_edited — it must not look inbound.""" mock_msg = MagicMock() mock_msg.id = "msg-1" adapter._chatto_client.update_message.return_value = mock_msg await adapter.edit_message("room-1", "msg-1", "New content") assert adapter._is_seen("msg-1") is True async def test_edit_message_too_long_refuses(self, adapter): """Overlong content must fall back to send() (which splits), not be silently truncated into a lossy edit.""" result = await adapter.edit_message( "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1), ) assert result.success is False adapter._chatto_client.update_message.assert_not_called() async def test_edit_message_empty_content(self, adapter): result = await adapter.edit_message("room-1", "msg-1", "") assert result.success is False adapter._chatto_client.update_message.assert_not_called() async def test_edit_message_error_is_retryable(self, adapter): from chattolib.exceptions import ChattoError adapter._chatto_client.update_message.side_effect = ChattoError("boom") result = await adapter.edit_message("room-1", "msg-1", "New content") assert result.success is False assert result.retryable is True async def test_delete_message(self, adapter): result = await adapter.delete_message("room-1", "msg-1") assert result is True adapter._chatto_client.delete_message.assert_called_once() call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs assert call_kwargs["room_id"] == "room-1" assert call_kwargs["event_id"] == "msg-1" async def test_delete_message_missing_ids(self, adapter): assert await adapter.delete_message("", "msg-1") is False assert await adapter.delete_message("room-1", "") is False adapter._chatto_client.delete_message.assert_not_called() async def test_delete_message_error_returns_false(self, adapter): from chattolib.exceptions import ChattoError adapter._chatto_client.delete_message.side_effect = ChattoError("nope") assert await adapter.delete_message("room-1", "msg-1") is False # -- Outgoing text formatting -- class TestFormatMessage: """format_message() only fixes what renders wrong in Chatto.""" def test_normalises_crlf(self): adapter = _make_adapter() assert adapter.format_message("a\r\nb\rc") == "a\nb\nc" def test_collapses_excess_blank_lines(self): adapter = _make_adapter() assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb" def test_leaves_markdown_untouched(self): adapter = _make_adapter() text = "**bold** `code`\n\n```py\nx = 1\n```\n- item" assert adapter.format_message(text) == text def test_empty_content(self): adapter = _make_adapter() assert adapter.format_message("") == "" # -- Handoff threads -- class TestHandoffThread: """create_handoff_thread() anchors a handoff on a seed message.""" @pytest_asyncio.fixture def adapter(self): adapter = _make_adapter() adapter._chatto_client.post_message = AsyncMock() adapter._chatto_client.follow_thread = AsyncMock() return adapter async def test_returns_seed_message_id(self, adapter): mock_msg = MagicMock() mock_msg.id = "seed-1" adapter._chatto_client.post_message.return_value = mock_msg adapter._room_kinds["room-1"] = RoomKind.CHANNEL result = await adapter.create_handoff_thread("room-1", "Refactor run") assert result == "seed-1" assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1" adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1") # Our own seed must not come back in as inbound traffic. assert adapter._is_seen("seed-1") is True async def test_dm_has_no_threads(self, adapter): adapter._room_kinds["dm-1"] = RoomKind.DM assert await adapter.create_handoff_thread("dm-1", "x") is None adapter._chatto_client.post_message.assert_not_called() async def test_seed_post_failure(self, adapter): adapter._chatto_client.post_message.side_effect = RuntimeError("down") adapter._room_kinds["room-1"] = RoomKind.CHANNEL assert await adapter.create_handoff_thread("room-1", "x") is None # -- Reaction event forwarding -- class TestReactionForwarding: """Human reactions reach the gateway's reaction hook surface.""" @pytest_asyncio.fixture def adapter(self): adapter = _make_adapter() adapter.me = _make_user("bot-user-id", "hermes_bot") return adapter def _event(self, kind, actor_id="human-1"): event = MagicMock() event.id = "evt-1" event.kind = kind event.actor_id = actor_id payload = ReactionPayload( room_id="room-1", message_event_id="msg-1", emoji="thumbsup", ) # RealtimeEvent.get() only yields the payload for its own kind. event.get = MagicMock(side_effect=lambda k: payload if k == kind else None) return event async def test_forwards_added_reaction(self, adapter): handler = AsyncMock() adapter.set_reaction_handler(handler) await adapter._handle_realtime_event(self._event("reaction_added")) handler.assert_called_once() payload = handler.call_args.args[0] assert payload["event_name"] == "reaction:added" assert payload["reaction"] == "thumbsup" assert payload["channel_id"] == "room-1" assert payload["message_ts"] == "msg-1" assert payload["user_id"] == "human-1" assert payload["item_type"] == "message" async def test_forwards_removed_reaction(self, adapter): handler = AsyncMock() adapter.set_reaction_handler(handler) await adapter._handle_realtime_event(self._event("reaction_removed")) assert handler.call_args.args[0]["event_name"] == "reaction:removed" async def test_ignores_own_lifecycle_reactions(self, adapter): """👀/✅/❌ are ours — forwarding them would feed the agent its own markers.""" handler = AsyncMock() adapter.set_reaction_handler(handler) await adapter._handle_realtime_event( self._event("reaction_added", actor_id="bot-user-id"), ) handler.assert_not_called() async def test_no_handler_registered_is_harmless(self, adapter): await adapter._handle_realtime_event(self._event("reaction_added")) async def test_handler_exception_does_not_propagate(self, adapter): adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom"))) await adapter._handle_realtime_event(self._event("reaction_added")) # -- Inbound attachments -- class TestInboundAttachments: """Messages carrying files must reach the agent, body or not.""" @pytest_asyncio.fixture def adapter(self): adapter = _make_adapter() adapter.chatto_config.allow_all_users.value = True adapter.me = _make_user("bot-user-id", "hermes_bot") adapter._room_kinds["room-1"] = RoomKind.DM adapter._user_cache["user-1"] = _make_user("user-1", "alice") adapter.handle_message = AsyncMock() adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest") return adapter async def test_image_attachment_becomes_media_url(self, adapter): payload = _make_posted_payload() adapter._chatto_client.get_room = AsyncMock() message = _make_message( body="look at this", attachments=[_make_attachment("shot.png", "image/png")], ) payload.fetch_message = AsyncMock(return_value=message) with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")): await adapter._dispatch_message_posted(payload) event = adapter.handle_message.call_args.args[0] assert event.media_urls == ["/cache/shot.png"] assert event.media_types == ["image/png"] assert event.message_type == MessageType.PHOTO async def test_attachment_only_message_is_not_dropped(self, adapter): """The empty-body early return is what silently ate file uploads.""" payload = _make_posted_payload() message = _make_message( body="", attachments=[_make_attachment("report.pdf", "application/pdf")], ) payload.fetch_message = AsyncMock(return_value=message) with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")): await adapter._dispatch_message_posted(payload) adapter.handle_message.assert_called_once() event = adapter.handle_message.call_args.args[0] assert event.message_type == MessageType.DOCUMENT assert event.media_urls == ["/cache/report.pdf"] async def test_empty_message_without_attachments_is_dropped(self, adapter): payload = _make_posted_payload() payload.fetch_message = AsyncMock(return_value=_make_message(body="")) await adapter._dispatch_message_posted(payload) adapter.handle_message.assert_not_called() async def test_download_failure_still_delivers_the_text(self, adapter): adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404")) payload = _make_posted_payload() payload.fetch_message = AsyncMock(return_value=_make_message( body="see attached", attachments=[_make_attachment("a.png", "image/png")], )) await adapter._dispatch_message_posted(payload) event = adapter.handle_message.call_args.args[0] assert event.text == "see attached" assert event.media_urls == [] assert event.message_type == MessageType.TEXT async def test_attachment_without_asset_url_is_skipped(self, adapter): """Videos are announced before transcoding finishes.""" payload = _make_posted_payload() att = _make_attachment("clip.mp4", "video/mp4") att.asset_url = None payload.fetch_message = AsyncMock(return_value=_make_message( body="clip", attachments=[att], )) await adapter._dispatch_message_posted(payload) event = adapter.handle_message.call_args.args[0] assert event.media_urls == [] adapter._download_attachment_bytes.assert_not_called() async def test_document_wins_over_image(self, adapter): """Mixed batches classify as DOCUMENT — that gates context injection.""" assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT async def test_oversized_attachment_is_rejected(self, adapter): """The gateway media cap must bound what a hostile upload can buffer.""" import httpx big = get_inbound_media_max_bytes() + 1 transport = httpx.MockTransport(lambda request: httpx.Response( 200, headers={"content-length": str(big)}, content=b"x", )) real_adapter = _make_adapter() real_client_cls = httpx.AsyncClient with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)): with pytest.raises(ValueError): await real_adapter._download_attachment_bytes("https://chat.example.com/a.png") # NOTE: there are deliberately no tests for get_user(), set_presence() or # set_custom_status() on the adapter. Those are not adapter responsibilities — # callers use the chattolib client directly, which exposes them (client.get_user, # client.update_presence, client.update_custom_status). The adapter only touches # presence in connect()/disconnect(). # -- Room operations -- class TestRoomOperations: """Test room creation and DM initiation.""" @pytest_asyncio.fixture def adapter(self): _clear_chatto_env() cfg = _make_config() adapter = ChattoAdapter(cfg) adapter._chatto_client = MagicMock() # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a # plain MagicMock raises TypeError, which create_room()/start_dm() # swallow into a None return. adapter._chatto_client.create_room = AsyncMock() adapter._chatto_client.start_dm = AsyncMock() adapter._token = "test-token" adapter._room_names = {} adapter._room_kinds = {} return adapter async def test_create_room(self, adapter): adapter._chatto_client.create_room.return_value = _make_room( "room-123", "Test Room", RoomKind.CHANNEL, ) result = await adapter.create_room("Test Room", "A test room") assert result == "room-123" adapter._chatto_client.create_room.assert_called_once() assert adapter._room_names["room-123"] == "Test Room" async def test_start_dm(self, adapter): adapter._chatto_client.start_dm.return_value = _make_room( "dm-123", "DM with user", RoomKind.DM, ) result = await adapter.start_dm("user-123") assert result == "dm-123" adapter._chatto_client.start_dm.assert_called_once() assert adapter._room_kinds["dm-123"] == RoomKind.DM # -- Constants -- class TestConstants: """Test that constants are properly defined.""" def test_max_message_length(self): assert _MAX_MESSAGE_LENGTH == 10000 def test_seen_cap(self): assert _SEEN_CAP == 500