test_adapter.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """Unit tests for the Chatto platform adapter.
  2. Covers:
  3. - Emoji shortcode conversion
  4. - Adapter instantiation and properties
  5. - Registration and requirements
  6. - Basic functionality with chattolib
  7. - Message sending and reactions
  8. - User lookup (with caching)
  9. - Presence and custom status
  10. All network calls are mocked — no real HTTP or WebSocket connections.
  11. """
  12. import asyncio
  13. import os
  14. import sys
  15. import tempfile
  16. from unittest.mock import AsyncMock, MagicMock, patch, call
  17. from collections import OrderedDict
  18. import pytest
  19. import pytest_asyncio
  20. # Import chattolib types for tests - using vendored chattolib from adapter
  21. # -- Path setup --
  22. # The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
  23. # at a checkout to run these tests outside a deployed agent.
  24. PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
  25. sys.path.insert(0, PLUGIN_ROOT)
  26. sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
  27. sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
  28. from adapter import (
  29. ChattoAdapter,
  30. hermes_check_fn as check_requirements,
  31. hermes_validate_config as validate_config,
  32. register,
  33. )
  34. from chattolib.types import Room, RoomKind
  35. from platform_config import ChattoConstants
  36. from gateway.config import PlatformConfig
  37. from gateway.platforms.base import SendResult, MessageEvent, MessageType
  38. _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
  39. _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
  40. _SEEN_CAP = ChattoConstants.SEEN_CAP
  41. # -- Helpers --
  42. class _MockPluginContext:
  43. """Minimal mock for the plugin registration context."""
  44. def __init__(self):
  45. self.registered_names = []
  46. self.registered_kwargs = None
  47. def register_platform(self, **kwargs):
  48. from gateway.platform_registry import platform_registry, PlatformEntry
  49. entry = PlatformEntry(
  50. name=kwargs["name"],
  51. label=kwargs.get("label", kwargs["name"]),
  52. adapter_factory=kwargs.get("adapter_factory"),
  53. check_fn=kwargs.get("check_fn"),
  54. validate_config=kwargs.get("validate_config"),
  55. is_connected=kwargs.get("is_connected"),
  56. required_env=kwargs.get("required_env", []),
  57. source="plugin",
  58. )
  59. platform_registry.register(entry)
  60. self.registered_names.append(kwargs["name"])
  61. self.registered_kwargs = kwargs
  62. def _ensure_chatto_registered():
  63. """Register the platform so Platform(PLATFORM_NAME) resolves."""
  64. from gateway.platform_registry import platform_registry
  65. if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
  66. ctx = _MockPluginContext()
  67. register(ctx)
  68. _CHATTO_ENV_KEYS = [
  69. "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
  70. "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
  71. "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
  72. "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
  73. "CHATTO_REACTIONS",
  74. ]
  75. def _clear_chatto_env(monkeypatch=None):
  76. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  77. for key in _CHATTO_ENV_KEYS:
  78. if monkeypatch is not None:
  79. monkeypatch.delenv(key, raising=False)
  80. else:
  81. os.environ.pop(key, None)
  82. def _make_config(**extra_overrides):
  83. """Create a minimal PlatformConfig for testing."""
  84. _ensure_chatto_registered()
  85. extra = {"base_url": "https://chat.example.com", "channels": ["room1"]}
  86. extra.update(extra_overrides)
  87. return PlatformConfig(enabled=True, extra=extra)
  88. def _make_room(room_id, name, kind):
  89. """Build a real chattolib Room, as the client would return."""
  90. return Room(id=room_id, name=name, kind=kind, description="",
  91. archived=False, group_id="", universal=kind != RoomKind.DM)
  92. def _make_adapter(**extra_overrides):
  93. """Create a ChattoAdapter with mocked config."""
  94. _clear_chatto_env()
  95. cfg = _make_config(**extra_overrides)
  96. adapter = ChattoAdapter(cfg)
  97. adapter._chatto_client = MagicMock()
  98. adapter._token = "test-token"
  99. adapter._user_id = "bot-user-id"
  100. adapter._user_login = "hermes_bot"
  101. adapter._user_display = "Hermes Bot"
  102. return adapter
  103. # -- Emoji shortcode conversion --
  104. class TestEmojiShortcode:
  105. """Test emoji to shortcode mapping."""
  106. def test_emoji_to_shortcode_exists(self):
  107. assert isinstance(_EMOJI_TO_SHORTCODE, dict)
  108. assert len(_EMOJI_TO_SHORTCODE) > 0
  109. def test_emoji_to_shortcode_common_emojis(self):
  110. assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
  111. assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
  112. assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
  113. assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
  114. assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
  115. assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
  116. # -- Adapter instantiation and properties --
  117. class TestAdapterInstantiation:
  118. """Test ChattoAdapter creation and basic properties."""
  119. def test_adapter_creation(self):
  120. cfg = _make_config()
  121. adapter = ChattoAdapter(cfg)
  122. assert adapter is not None
  123. # Platform members created dynamically from a plugin name carry the
  124. # name upper-cased; the registered identity is the value.
  125. assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
  126. def test_adapter_max_message_length(self):
  127. """The framework chunks via max_message_length_for_chat(), which reads
  128. the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
  129. when it is missing."""
  130. cfg = _make_config()
  131. adapter = ChattoAdapter(cfg)
  132. assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
  133. assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
  134. def test_adapter_splits_long_messages(self):
  135. cfg = _make_config()
  136. adapter = ChattoAdapter(cfg)
  137. assert adapter.splits_long_messages is True
  138. def test_adapter_threads_enabled_by_default(self):
  139. """There is no capability flag for threads — Chatto threading is driven
  140. by the auto_thread setting, which defaults to on."""
  141. cfg = _make_config()
  142. adapter = ChattoAdapter(cfg)
  143. assert adapter.chatto_config.auto_thread.value is True
  144. # -- Registration and requirements --
  145. class TestRegistration:
  146. """Test plugin registration."""
  147. def test_register_called(self):
  148. ctx = _MockPluginContext()
  149. register(ctx)
  150. assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
  151. assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
  152. assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
  153. assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
  154. def test_check_requirements(self):
  155. assert check_requirements() is True
  156. def test_check_requirements_missing(self):
  157. with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
  158. assert check_requirements() is False
  159. def test_validate_config(self):
  160. _clear_chatto_env()
  161. os.environ["CHATTO_BASE_URL"] = "https://chat.test"
  162. os.environ["CHATTO_LOGIN"] = "user"
  163. os.environ["CHATTO_PASSWORD"] = "pass"
  164. cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
  165. assert validate_config(cfg) is True
  166. _clear_chatto_env()
  167. # -- Send functionality --
  168. class TestSend:
  169. """Test message sending functionality."""
  170. @pytest_asyncio.fixture
  171. def adapter(self):
  172. _clear_chatto_env()
  173. cfg = _make_config()
  174. adapter = ChattoAdapter(cfg)
  175. adapter._chatto_client = MagicMock()
  176. adapter._chatto_client.post_message = AsyncMock()
  177. adapter._token = "test-token"
  178. adapter._user_id = "bot-user-id"
  179. return adapter
  180. async def test_send_calls_post_message(self, adapter):
  181. mock_msg = MagicMock()
  182. mock_msg.id = "msg-123"
  183. adapter._chatto_client.post_message.return_value = mock_msg
  184. result = await adapter.send("room-1", "Hello world")
  185. assert result.success is True
  186. assert result.message_id == "msg-123"
  187. adapter._chatto_client.post_message.assert_called_once()
  188. async def test_send_with_thread(self, adapter):
  189. mock_msg = MagicMock()
  190. mock_msg.id = "msg-456"
  191. adapter._chatto_client.post_message.return_value = mock_msg
  192. result = await adapter.send("room-1", "Hello", reply_to="thread-123")
  193. assert result.success is True
  194. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  195. assert call_kwargs["thread_root_event_id"] == "thread-123"
  196. # -- Reactions --
  197. class TestReactions:
  198. """Test reaction functionality."""
  199. @pytest_asyncio.fixture
  200. def adapter(self):
  201. _clear_chatto_env()
  202. cfg = _make_config()
  203. adapter = ChattoAdapter(cfg)
  204. adapter._chatto_client = MagicMock()
  205. adapter._chatto_client.add_reaction = AsyncMock()
  206. adapter._chatto_client.remove_reaction = AsyncMock()
  207. adapter._token = "test-token"
  208. return adapter
  209. async def test_send_reaction(self, adapter):
  210. await adapter.add_reaction("room-1", "msg-1", "👍")
  211. adapter._chatto_client.add_reaction.assert_called_once()
  212. async def test_remove_reaction(self, adapter):
  213. await adapter.remove_reaction("room-1", "msg-1", "👍")
  214. adapter._chatto_client.remove_reaction.assert_called_once()
  215. async def test_on_processing_start_adds_eyes_reaction(self, adapter):
  216. """on_processing_start should call add_reaction with 👀."""
  217. event = MagicMock()
  218. event.message_id = "msg-1"
  219. event.source.chat_id = "room-1"
  220. await adapter.on_processing_start(event)
  221. adapter._chatto_client.add_reaction.assert_called_once()
  222. call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
  223. assert call_kwargs["message_event_id"] == "msg-1"
  224. assert call_kwargs["room_id"] == "room-1"
  225. assert call_kwargs["emoji"] == "eyes"
  226. async def test_on_processing_start_empty_message_id(self, adapter):
  227. """on_processing_start should skip reaction when message_id is empty."""
  228. event = MagicMock()
  229. event.message_id = None
  230. event.source.chat_id = "room-1"
  231. await adapter.on_processing_start(event)
  232. adapter._chatto_client.add_reaction.assert_not_called()
  233. async def test_on_processing_start_reactions_disabled(self, adapter):
  234. """on_processing_start should skip when reactions config is False."""
  235. adapter.chatto_config.reactions.value = False
  236. event = MagicMock()
  237. event.message_id = "msg-1"
  238. event.source.chat_id = "room-1"
  239. await adapter.on_processing_start(event)
  240. adapter._chatto_client.add_reaction.assert_not_called()
  241. # -- Edit and Delete Messages --
  242. class TestMessageEditing:
  243. """Test message editing and deletion."""
  244. @pytest_asyncio.fixture
  245. def adapter(self):
  246. _clear_chatto_env()
  247. cfg = _make_config()
  248. adapter = ChattoAdapter(cfg)
  249. adapter._chatto_client = MagicMock()
  250. adapter._chatto_client.update_message = AsyncMock()
  251. adapter._chatto_client.delete_message = AsyncMock(return_value=True)
  252. adapter._token = "test-token"
  253. return adapter
  254. @pytest.mark.xfail(
  255. strict=True,
  256. reason="ChattoAdapter does not override edit_message yet, so the base "
  257. "class reports 'Not supported' and callers send a new message "
  258. "instead of editing. chattolib.update_message() exists — drop "
  259. "this marker once the override lands.",
  260. )
  261. async def test_edit_message(self, adapter):
  262. result = await adapter.edit_message("room-1", "msg-1", "New content")
  263. assert result.success is True
  264. adapter._chatto_client.update_message.assert_called_once()
  265. @pytest.mark.xfail(
  266. strict=True,
  267. reason="ChattoAdapter does not override delete_message yet, so the base "
  268. "class returns False. chattolib.delete_message() exists — drop "
  269. "this marker once the override lands.",
  270. )
  271. async def test_delete_message(self, adapter):
  272. result = await adapter.delete_message("room-1", "msg-1")
  273. assert result is True
  274. adapter._chatto_client.delete_message.assert_called_once()
  275. # NOTE: there are deliberately no tests for get_user(), set_presence() or
  276. # set_custom_status() on the adapter. Those are not adapter responsibilities —
  277. # callers use the chattolib client directly, which exposes them (client.get_user,
  278. # client.update_presence, client.update_custom_status). The adapter only touches
  279. # presence in connect()/disconnect().
  280. # -- Room operations --
  281. class TestRoomOperations:
  282. """Test room creation and DM initiation."""
  283. @pytest_asyncio.fixture
  284. def adapter(self):
  285. _clear_chatto_env()
  286. cfg = _make_config()
  287. adapter = ChattoAdapter(cfg)
  288. adapter._chatto_client = MagicMock()
  289. # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
  290. # plain MagicMock raises TypeError, which create_room()/start_dm()
  291. # swallow into a None return.
  292. adapter._chatto_client.create_room = AsyncMock()
  293. adapter._chatto_client.start_dm = AsyncMock()
  294. adapter._token = "test-token"
  295. adapter._room_names = {}
  296. adapter._room_kinds = {}
  297. return adapter
  298. async def test_create_room(self, adapter):
  299. adapter._chatto_client.create_room.return_value = _make_room(
  300. "room-123", "Test Room", RoomKind.CHANNEL,
  301. )
  302. result = await adapter.create_room("Test Room", "A test room")
  303. assert result == "room-123"
  304. adapter._chatto_client.create_room.assert_called_once()
  305. assert adapter._room_names["room-123"] == "Test Room"
  306. async def test_start_dm(self, adapter):
  307. adapter._chatto_client.start_dm.return_value = _make_room(
  308. "dm-123", "DM with user", RoomKind.DM,
  309. )
  310. result = await adapter.start_dm("user-123")
  311. assert result == "dm-123"
  312. adapter._chatto_client.start_dm.assert_called_once()
  313. assert adapter._room_kinds["dm-123"] == RoomKind.DM
  314. # -- Constants --
  315. class TestConstants:
  316. """Test that constants are properly defined."""
  317. def test_max_message_length(self):
  318. assert _MAX_MESSAGE_LENGTH == 10000
  319. def test_seen_cap(self):
  320. assert _SEEN_CAP == 500