| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- """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
- @pytest.mark.xfail(
- strict=True,
- reason="ChattoAdapter does not override edit_message yet, so the base "
- "class reports 'Not supported' and callers send a new message "
- "instead of editing. chattolib.update_message() exists — drop "
- "this marker once the override lands.",
- )
- 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()
- @pytest.mark.xfail(
- strict=True,
- reason="ChattoAdapter does not override delete_message yet, so the base "
- "class returns False. chattolib.delete_message() exists — drop "
- "this marker once the override lands.",
- )
- 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()
- # -- 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
|