test_adapter.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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. PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
  23. sys.path.insert(0, PLUGIN_ROOT)
  24. sys.path.insert(0, "/opt/hermes")
  25. sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
  26. from adapter import (
  27. ChattoAdapter,
  28. hermes_check_fn as check_requirements,
  29. hermes_validate_config as validate_config,
  30. register,
  31. )
  32. from platform_config import ChattoConstants
  33. from gateway.config import PlatformConfig
  34. from gateway.platforms.base import SendResult, MessageEvent, MessageType
  35. _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
  36. _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
  37. _SEEN_CAP = ChattoConstants.SEEN_CAP
  38. # -- Helpers --
  39. class _MockPluginContext:
  40. """Minimal mock for the plugin registration context."""
  41. def __init__(self):
  42. self.registered_names = []
  43. self.registered_kwargs = None
  44. def register_platform(self, **kwargs):
  45. from gateway.platform_registry import platform_registry, PlatformEntry
  46. entry = PlatformEntry(
  47. name=kwargs["name"],
  48. label=kwargs.get("label", kwargs["name"]),
  49. adapter_factory=kwargs.get("adapter_factory"),
  50. check_fn=kwargs.get("check_fn"),
  51. validate_config=kwargs.get("validate_config"),
  52. is_connected=kwargs.get("is_connected"),
  53. required_env=kwargs.get("required_env", []),
  54. source="plugin",
  55. )
  56. platform_registry.register(entry)
  57. self.registered_names.append(kwargs["name"])
  58. self.registered_kwargs = kwargs
  59. def _ensure_chatto_registered():
  60. """Register chatto in the platform registry so Platform('chatto') works."""
  61. from gateway.platform_registry import platform_registry
  62. if not platform_registry.is_registered("chatto"):
  63. ctx = _MockPluginContext()
  64. register(ctx)
  65. _CHATTO_ENV_KEYS = [
  66. "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
  67. "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
  68. "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
  69. "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
  70. "CHATTO_REACTIONS",
  71. ]
  72. def _clear_chatto_env(monkeypatch=None):
  73. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  74. for key in _CHATTO_ENV_KEYS:
  75. if monkeypatch is not None:
  76. monkeypatch.delenv(key, raising=False)
  77. else:
  78. os.environ.pop(key, None)
  79. def _make_config(**extra_overrides):
  80. """Create a minimal PlatformConfig for testing."""
  81. _ensure_chatto_registered()
  82. extra = {"base_url": "https://chat.example.com", "channels": ["room1"]}
  83. extra.update(extra_overrides)
  84. return PlatformConfig(enabled=True, extra=extra)
  85. def _make_adapter(**extra_overrides):
  86. """Create a ChattoAdapter with mocked config."""
  87. _clear_chatto_env()
  88. cfg = _make_config(**extra_overrides)
  89. adapter = ChattoAdapter(cfg)
  90. adapter._chatto_client = MagicMock()
  91. adapter._token = "test-token"
  92. adapter._user_id = "bot-user-id"
  93. adapter._user_login = "hermes_bot"
  94. adapter._user_display = "Hermes Bot"
  95. return adapter
  96. # -- Emoji shortcode conversion --
  97. class TestEmojiShortcode:
  98. """Test emoji to shortcode mapping."""
  99. def test_emoji_to_shortcode_exists(self):
  100. assert isinstance(_EMOJI_TO_SHORTCODE, dict)
  101. assert len(_EMOJI_TO_SHORTCODE) > 0
  102. def test_emoji_to_shortcode_common_emojis(self):
  103. assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
  104. assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
  105. assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
  106. assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
  107. assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
  108. assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
  109. # -- Adapter instantiation and properties --
  110. class TestAdapterInstantiation:
  111. """Test ChattoAdapter creation and basic properties."""
  112. def test_adapter_creation(self):
  113. cfg = _make_config()
  114. adapter = ChattoAdapter(cfg)
  115. assert adapter is not None
  116. assert adapter.platform.name == "chatto"
  117. def test_adapter_max_message_length(self):
  118. cfg = _make_config()
  119. adapter = ChattoAdapter(cfg)
  120. assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
  121. def test_adapter_splits_long_messages(self):
  122. cfg = _make_config()
  123. adapter = ChattoAdapter(cfg)
  124. assert adapter.splits_long_messages is True
  125. def test_adapter_supports_threads(self):
  126. cfg = _make_config()
  127. adapter = ChattoAdapter(cfg)
  128. assert adapter.supports_threads() is True
  129. # -- Registration and requirements --
  130. class TestRegistration:
  131. """Test plugin registration."""
  132. def test_register_called(self):
  133. ctx = _MockPluginContext()
  134. register(ctx)
  135. assert "chatto" in ctx.registered_names
  136. assert ctx.registered_kwargs["name"] == "chatto"
  137. assert ctx.registered_kwargs["label"] == "Chatto"
  138. def test_check_requirements(self):
  139. assert check_requirements() is True
  140. def test_check_requirements_missing(self):
  141. with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
  142. assert check_requirements() is False
  143. def test_validate_config(self):
  144. _clear_chatto_env()
  145. os.environ["CHATTO_BASE_URL"] = "https://chat.test"
  146. os.environ["CHATTO_LOGIN"] = "user"
  147. os.environ["CHATTO_PASSWORD"] = "pass"
  148. cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
  149. assert validate_config(cfg) is True
  150. _clear_chatto_env()
  151. # -- Send functionality --
  152. class TestSend:
  153. """Test message sending functionality."""
  154. @pytest_asyncio.fixture
  155. def adapter(self):
  156. _clear_chatto_env()
  157. cfg = _make_config()
  158. adapter = ChattoAdapter(cfg)
  159. adapter._chatto_client = MagicMock()
  160. adapter._chatto_client.post_message = AsyncMock()
  161. adapter._token = "test-token"
  162. adapter._user_id = "bot-user-id"
  163. return adapter
  164. async def test_send_calls_post_message(self, adapter):
  165. mock_msg = MagicMock()
  166. mock_msg.id = "msg-123"
  167. adapter._chatto_client.post_message.return_value = mock_msg
  168. result = await adapter.send("room-1", "Hello world")
  169. assert result.success is True
  170. assert result.message_id == "msg-123"
  171. adapter._chatto_client.post_message.assert_called_once()
  172. async def test_send_with_thread(self, adapter):
  173. mock_msg = MagicMock()
  174. mock_msg.id = "msg-456"
  175. adapter._chatto_client.post_message.return_value = mock_msg
  176. result = await adapter.send("room-1", "Hello", reply_to="thread-123")
  177. assert result.success is True
  178. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  179. assert call_kwargs["thread_root_event_id"] == "thread-123"
  180. # -- Reactions --
  181. class TestReactions:
  182. """Test reaction functionality."""
  183. @pytest_asyncio.fixture
  184. def adapter(self):
  185. _clear_chatto_env()
  186. cfg = _make_config()
  187. adapter = ChattoAdapter(cfg)
  188. adapter._chatto_client = MagicMock()
  189. adapter._chatto_client.add_reaction = AsyncMock()
  190. adapter._chatto_client.remove_reaction = AsyncMock()
  191. adapter._token = "test-token"
  192. return adapter
  193. async def test_send_reaction(self, adapter):
  194. await adapter.add_reaction("room-1", "msg-1", "👍")
  195. adapter._chatto_client.add_reaction.assert_called_once()
  196. async def test_remove_reaction(self, adapter):
  197. await adapter.remove_reaction("room-1", "msg-1", "👍")
  198. adapter._chatto_client.remove_reaction.assert_called_once()
  199. async def test_on_processing_start_adds_eyes_reaction(self, adapter):
  200. """on_processing_start should call add_reaction with 👀."""
  201. event = MagicMock()
  202. event.message_id = "msg-1"
  203. event.source.chat_id = "room-1"
  204. await adapter.on_processing_start(event)
  205. adapter._chatto_client.add_reaction.assert_called_once()
  206. call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
  207. assert call_kwargs["message_event_id"] == "msg-1"
  208. assert call_kwargs["room_id"] == "room-1"
  209. assert call_kwargs["emoji"] == "eyes"
  210. async def test_on_processing_start_empty_message_id(self, adapter):
  211. """on_processing_start should skip reaction when message_id is empty."""
  212. event = MagicMock()
  213. event.message_id = None
  214. event.source.chat_id = "room-1"
  215. await adapter.on_processing_start(event)
  216. adapter._chatto_client.add_reaction.assert_not_called()
  217. async def test_on_processing_start_reactions_disabled(self, adapter):
  218. """on_processing_start should skip when reactions config is False."""
  219. adapter.chatto_config.reactions.value = False
  220. event = MagicMock()
  221. event.message_id = "msg-1"
  222. event.source.chat_id = "room-1"
  223. await adapter.on_processing_start(event)
  224. adapter._chatto_client.add_reaction.assert_not_called()
  225. # -- Edit and Delete Messages --
  226. class TestMessageEditing:
  227. """Test message editing and deletion."""
  228. @pytest_asyncio.fixture
  229. def adapter(self):
  230. _clear_chatto_env()
  231. cfg = _make_config()
  232. adapter = ChattoAdapter(cfg)
  233. adapter._chatto_client = MagicMock()
  234. adapter._chatto_client.update_message = AsyncMock()
  235. adapter._chatto_client.delete_message = AsyncMock(return_value=True)
  236. adapter._token = "test-token"
  237. return adapter
  238. async def test_edit_message(self, adapter):
  239. result = await adapter.edit_message("room-1", "msg-1", "New content")
  240. assert result is True
  241. adapter._chatto_client.update_message.assert_called_once()
  242. async def test_delete_message(self, adapter):
  243. result = await adapter.delete_message("room-1", "msg-1")
  244. assert result is True
  245. adapter._chatto_client.delete_message.assert_called_once()
  246. # -- User lookup --
  247. class TestUserLookup:
  248. """Test user lookup functionality."""
  249. @pytest_asyncio.fixture
  250. def adapter(self):
  251. _clear_chatto_env()
  252. cfg = _make_config()
  253. adapter = ChattoAdapter(cfg)
  254. adapter._chatto_client = MagicMock()
  255. adapter._token = "test-token"
  256. adapter._user_cache = {}
  257. return adapter
  258. async def test_get_user_calls_chattolib(self, adapter):
  259. # Import chattolib types for testing (try vendored first)
  260. try:
  261. from chattolib_vendor.chattolib.types import User, GetUserResponse
  262. except ImportError:
  263. try:
  264. from chattolib.types import User, GetUserResponse
  265. except ImportError:
  266. # Fallback to mocked types if chattolib not installed
  267. from unittest.mock import MagicMock
  268. User = MagicMock
  269. GetUserResponse = MagicMock
  270. mock_user = User(id="user-1", login="testuser", display_name="Test User")
  271. adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
  272. result = await adapter.get_user("user-1")
  273. assert result is not None
  274. assert result["id"] == "user-1"
  275. assert result["login"] == "testuser"
  276. async def test_get_user_caching(self, adapter):
  277. # Import chattolib types for testing (try vendored first)
  278. try:
  279. from chattolib_vendor.chattolib.types import User, GetUserResponse
  280. except ImportError:
  281. try:
  282. from chattolib.types import User, GetUserResponse
  283. except ImportError:
  284. # Fallback to mocked types if chattolib not installed
  285. from unittest.mock import MagicMock
  286. User = MagicMock
  287. GetUserResponse = MagicMock
  288. mock_user = User(id="user-1", login="testuser", display_name="Test User")
  289. adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
  290. result1 = await adapter.get_user("user-1")
  291. result2 = await adapter.get_user("user-1")
  292. assert result1 == result2
  293. assert adapter._chatto_client.get_user.call_count == 1
  294. # -- Presence and Custom Status --
  295. class TestPresence:
  296. """Test presence functionality."""
  297. @pytest_asyncio.fixture
  298. def adapter(self):
  299. _clear_chatto_env()
  300. cfg = _make_config()
  301. adapter = ChattoAdapter(cfg)
  302. adapter._chatto_client = MagicMock()
  303. adapter._chatto_client.update_presence = AsyncMock()
  304. adapter._token = "test-token"
  305. return adapter
  306. async def test_set_presence(self, adapter):
  307. result = await adapter.set_presence("online")
  308. assert result is True
  309. adapter._chatto_client.update_presence.assert_called_once()
  310. class TestCustomStatus:
  311. """Test custom status functionality."""
  312. @pytest_asyncio.fixture
  313. def adapter(self):
  314. _clear_chatto_env()
  315. cfg = _make_config()
  316. adapter = ChattoAdapter(cfg)
  317. adapter._chatto_client = MagicMock()
  318. adapter._chatto_client.update_custom_status = AsyncMock()
  319. adapter._chatto_client.delete_custom_status = AsyncMock()
  320. adapter._token = "test-token"
  321. return adapter
  322. async def test_set_custom_status(self, adapter):
  323. result = await adapter.set_custom_status("Processing...")
  324. assert result is True
  325. adapter._chatto_client.update_custom_status.assert_called_once()
  326. async def test_clear_custom_status(self, adapter):
  327. result = await adapter.clear_custom_status()
  328. assert result is True
  329. adapter._chatto_client.delete_custom_status.assert_called_once()
  330. # -- Room operations --
  331. class TestRoomOperations:
  332. """Test room creation and DM initiation."""
  333. @pytest_asyncio.fixture
  334. def adapter(self):
  335. _clear_chatto_env()
  336. cfg = _make_config()
  337. adapter = ChattoAdapter(cfg)
  338. adapter._chatto_client = MagicMock()
  339. adapter._token = "test-token"
  340. adapter._room_names = {}
  341. adapter._room_kinds = {}
  342. return adapter
  343. async def test_create_room(self, adapter):
  344. # Import chattolib types for testing (try vendored first)
  345. try:
  346. from chattolib_vendor.chattolib.types import Room
  347. except ImportError:
  348. try:
  349. from chattolib.types import Room
  350. except ImportError:
  351. from unittest.mock import MagicMock
  352. Room = MagicMock
  353. mock_room = Room(id="room-123", name="Test Room", kind="ROOM_KIND_GROUP",
  354. description="", archived=False, group_id="", universal=True)
  355. adapter._chatto_client.create_room.return_value = mock_room
  356. result = await adapter.create_room("Test Room", "A test room")
  357. assert result == "room-123"
  358. adapter._chatto_client.create_room.assert_called_once()
  359. async def test_start_dm(self, adapter):
  360. # Import chattolib types for testing (try vendored first)
  361. try:
  362. from chattolib_vendor.chattolib.types import Room
  363. except ImportError:
  364. try:
  365. from chattolib.types import Room
  366. except ImportError:
  367. from unittest.mock import MagicMock
  368. Room = MagicMock
  369. mock_room = Room(id="dm-123", name="DM with user", kind="ROOM_KIND_DM",
  370. description="", archived=False, group_id="", universal=False)
  371. adapter._chatto_client.start_dm.return_value = mock_room
  372. result = await adapter.start_dm("user-123")
  373. assert result == "dm-123"
  374. adapter._chatto_client.start_dm.assert_called_once()
  375. # -- Constants --
  376. class TestConstants:
  377. """Test that constants are properly defined."""
  378. def test_max_message_length(self):
  379. assert _MAX_MESSAGE_LENGTH == 10000
  380. def test_seen_cap(self):
  381. assert _SEEN_CAP == 500