test_adapter.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  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.realtime_types import ReactionPayload
  35. from chattolib.types import (
  36. AssetUrl,
  37. Message,
  38. MessageAttachment,
  39. Room,
  40. RoomKind,
  41. User,
  42. )
  43. from platform_config import ChattoConstants
  44. from gateway.config import PlatformConfig
  45. from gateway.platforms.base import (
  46. CachedMedia,
  47. MessageEvent,
  48. MessageType,
  49. SendResult,
  50. get_inbound_media_max_bytes,
  51. )
  52. _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
  53. _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
  54. _SEEN_CAP = ChattoConstants.SEEN_CAP
  55. # -- Helpers --
  56. class _MockPluginContext:
  57. """Minimal mock for the plugin registration context."""
  58. def __init__(self):
  59. self.registered_names = []
  60. self.registered_kwargs = None
  61. def register_platform(self, **kwargs):
  62. from gateway.platform_registry import platform_registry, PlatformEntry
  63. entry = PlatformEntry(
  64. name=kwargs["name"],
  65. label=kwargs.get("label", kwargs["name"]),
  66. adapter_factory=kwargs.get("adapter_factory"),
  67. check_fn=kwargs.get("check_fn"),
  68. validate_config=kwargs.get("validate_config"),
  69. is_connected=kwargs.get("is_connected"),
  70. required_env=kwargs.get("required_env", []),
  71. source="plugin",
  72. )
  73. platform_registry.register(entry)
  74. self.registered_names.append(kwargs["name"])
  75. self.registered_kwargs = kwargs
  76. def _ensure_chatto_registered():
  77. """Register the platform so Platform(PLATFORM_NAME) resolves."""
  78. from gateway.platform_registry import platform_registry
  79. if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
  80. ctx = _MockPluginContext()
  81. register(ctx)
  82. _CHATTO_ENV_KEYS = [
  83. "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
  84. "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
  85. "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
  86. "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
  87. "CHATTO_REACTIONS",
  88. ]
  89. def _clear_chatto_env(monkeypatch=None):
  90. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  91. for key in _CHATTO_ENV_KEYS:
  92. if monkeypatch is not None:
  93. monkeypatch.delenv(key, raising=False)
  94. else:
  95. os.environ.pop(key, None)
  96. def _make_config(**extra_overrides):
  97. """Create a minimal PlatformConfig for testing."""
  98. _ensure_chatto_registered()
  99. extra = {"base_url": "https://chat.example.com", "channels": ["room1"]}
  100. extra.update(extra_overrides)
  101. return PlatformConfig(enabled=True, extra=extra)
  102. def _make_room(room_id, name, kind):
  103. """Build a real chattolib Room, as the client would return."""
  104. return Room(id=room_id, name=name, kind=kind, description="",
  105. archived=False, group_id="", universal=kind != RoomKind.DM)
  106. def _make_user(user_id, login):
  107. """Build a real chattolib User, as the member directory would return."""
  108. return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
  109. def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
  110. """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
  111. return MessageAttachment(
  112. id="asset-" + filename,
  113. filename=filename,
  114. content_type=content_type,
  115. asset_url=AssetUrl(url=url),
  116. )
  117. def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
  118. """Build a real chattolib Message, as fetch_message() would return."""
  119. return Message(
  120. id=message_id,
  121. room_id=room_id,
  122. created_at=None,
  123. actor_id="user-1",
  124. body=body,
  125. attachments=list(attachments or []),
  126. )
  127. def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
  128. """A message_posted payload whose fetch_message() the caller stubs."""
  129. payload = MagicMock()
  130. payload.room_id = room_id
  131. payload.message_event_id = message_event_id
  132. payload.thread_root_event_id = None
  133. return payload
  134. def _cached(path, media_type, kind):
  135. """The CachedMedia that cache_media_bytes() would return for an attachment."""
  136. return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
  137. def _make_adapter(**extra_overrides):
  138. """Create a ChattoAdapter with mocked config."""
  139. _clear_chatto_env()
  140. cfg = _make_config(**extra_overrides)
  141. adapter = ChattoAdapter(cfg)
  142. adapter._chatto_client = MagicMock()
  143. adapter._token = "test-token"
  144. adapter._user_id = "bot-user-id"
  145. adapter._user_login = "hermes_bot"
  146. adapter._user_display = "Hermes Bot"
  147. return adapter
  148. # -- Emoji shortcode conversion --
  149. class TestEmojiShortcode:
  150. """Test emoji to shortcode mapping."""
  151. def test_emoji_to_shortcode_exists(self):
  152. assert isinstance(_EMOJI_TO_SHORTCODE, dict)
  153. assert len(_EMOJI_TO_SHORTCODE) > 0
  154. def test_emoji_to_shortcode_common_emojis(self):
  155. assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
  156. assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
  157. assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
  158. assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
  159. assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
  160. assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
  161. # -- Adapter instantiation and properties --
  162. class TestAdapterInstantiation:
  163. """Test ChattoAdapter creation and basic properties."""
  164. def test_adapter_creation(self):
  165. cfg = _make_config()
  166. adapter = ChattoAdapter(cfg)
  167. assert adapter is not None
  168. # Platform members created dynamically from a plugin name carry the
  169. # name upper-cased; the registered identity is the value.
  170. assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
  171. def test_adapter_max_message_length(self):
  172. """The framework chunks via max_message_length_for_chat(), which reads
  173. the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
  174. when it is missing."""
  175. cfg = _make_config()
  176. adapter = ChattoAdapter(cfg)
  177. assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
  178. assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
  179. def test_adapter_splits_long_messages(self):
  180. cfg = _make_config()
  181. adapter = ChattoAdapter(cfg)
  182. assert adapter.splits_long_messages is True
  183. def test_adapter_threads_enabled_by_default(self):
  184. """There is no capability flag for threads — Chatto threading is driven
  185. by the auto_thread setting, which defaults to on."""
  186. cfg = _make_config()
  187. adapter = ChattoAdapter(cfg)
  188. assert adapter.chatto_config.auto_thread.value is True
  189. # -- Registration and requirements --
  190. class TestRegistration:
  191. """Test plugin registration."""
  192. def test_register_called(self):
  193. ctx = _MockPluginContext()
  194. register(ctx)
  195. assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
  196. assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
  197. assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
  198. assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
  199. def test_check_requirements(self):
  200. assert check_requirements() is True
  201. def test_check_requirements_missing(self):
  202. with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
  203. assert check_requirements() is False
  204. def test_validate_config(self):
  205. _clear_chatto_env()
  206. os.environ["CHATTO_BASE_URL"] = "https://chat.test"
  207. os.environ["CHATTO_LOGIN"] = "user"
  208. os.environ["CHATTO_PASSWORD"] = "pass"
  209. cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
  210. assert validate_config(cfg) is True
  211. _clear_chatto_env()
  212. # -- Send functionality --
  213. class TestSend:
  214. """Test message sending functionality."""
  215. @pytest_asyncio.fixture
  216. def adapter(self):
  217. _clear_chatto_env()
  218. cfg = _make_config()
  219. adapter = ChattoAdapter(cfg)
  220. adapter._chatto_client = MagicMock()
  221. adapter._chatto_client.post_message = AsyncMock()
  222. adapter._token = "test-token"
  223. adapter._user_id = "bot-user-id"
  224. return adapter
  225. async def test_send_calls_post_message(self, adapter):
  226. mock_msg = MagicMock()
  227. mock_msg.id = "msg-123"
  228. adapter._chatto_client.post_message.return_value = mock_msg
  229. result = await adapter.send("room-1", "Hello world")
  230. assert result.success is True
  231. assert result.message_id == "msg-123"
  232. adapter._chatto_client.post_message.assert_called_once()
  233. async def test_send_with_thread(self, adapter):
  234. mock_msg = MagicMock()
  235. mock_msg.id = "msg-456"
  236. adapter._chatto_client.post_message.return_value = mock_msg
  237. result = await adapter.send("room-1", "Hello", reply_to="thread-123")
  238. assert result.success is True
  239. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  240. assert call_kwargs["thread_root_event_id"] == "thread-123"
  241. # -- Reactions --
  242. class TestReactions:
  243. """Test reaction functionality."""
  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.add_reaction = AsyncMock()
  251. adapter._chatto_client.remove_reaction = AsyncMock()
  252. adapter._token = "test-token"
  253. return adapter
  254. async def test_send_reaction(self, adapter):
  255. await adapter.add_reaction("room-1", "msg-1", "👍")
  256. adapter._chatto_client.add_reaction.assert_called_once()
  257. async def test_remove_reaction(self, adapter):
  258. await adapter.remove_reaction("room-1", "msg-1", "👍")
  259. adapter._chatto_client.remove_reaction.assert_called_once()
  260. async def test_on_processing_start_adds_eyes_reaction(self, adapter):
  261. """on_processing_start should call add_reaction with 👀."""
  262. event = MagicMock()
  263. event.message_id = "msg-1"
  264. event.source.chat_id = "room-1"
  265. await adapter.on_processing_start(event)
  266. adapter._chatto_client.add_reaction.assert_called_once()
  267. call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
  268. assert call_kwargs["message_event_id"] == "msg-1"
  269. assert call_kwargs["room_id"] == "room-1"
  270. assert call_kwargs["emoji"] == "eyes"
  271. async def test_on_processing_start_empty_message_id(self, adapter):
  272. """on_processing_start should skip reaction when message_id is empty."""
  273. event = MagicMock()
  274. event.message_id = None
  275. event.source.chat_id = "room-1"
  276. await adapter.on_processing_start(event)
  277. adapter._chatto_client.add_reaction.assert_not_called()
  278. async def test_on_processing_start_reactions_disabled(self, adapter):
  279. """on_processing_start should skip when reactions config is False."""
  280. adapter.chatto_config.reactions.value = False
  281. event = MagicMock()
  282. event.message_id = "msg-1"
  283. event.source.chat_id = "room-1"
  284. await adapter.on_processing_start(event)
  285. adapter._chatto_client.add_reaction.assert_not_called()
  286. # -- Edit and Delete Messages --
  287. class TestMessageEditing:
  288. """Test message editing and deletion."""
  289. @pytest_asyncio.fixture
  290. def adapter(self):
  291. _clear_chatto_env()
  292. cfg = _make_config()
  293. adapter = ChattoAdapter(cfg)
  294. adapter._chatto_client = MagicMock()
  295. adapter._chatto_client.update_message = AsyncMock()
  296. adapter._chatto_client.delete_message = AsyncMock(return_value=True)
  297. adapter._token = "test-token"
  298. return adapter
  299. async def test_edit_message(self, adapter):
  300. result = await adapter.edit_message("room-1", "msg-1", "New content")
  301. assert result.success is True
  302. adapter._chatto_client.update_message.assert_called_once()
  303. call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
  304. assert call_kwargs["room_id"] == "room-1"
  305. assert call_kwargs["event_id"] == "msg-1"
  306. assert call_kwargs["body"] == "New content"
  307. async def test_edit_message_marks_own_edit_seen(self, adapter):
  308. """The edit echoes back as message_edited — it must not look inbound."""
  309. mock_msg = MagicMock()
  310. mock_msg.id = "msg-1"
  311. adapter._chatto_client.update_message.return_value = mock_msg
  312. await adapter.edit_message("room-1", "msg-1", "New content")
  313. assert adapter._is_seen("msg-1") is True
  314. async def test_edit_message_too_long_refuses(self, adapter):
  315. """Overlong content must fall back to send() (which splits), not be
  316. silently truncated into a lossy edit."""
  317. result = await adapter.edit_message(
  318. "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
  319. )
  320. assert result.success is False
  321. adapter._chatto_client.update_message.assert_not_called()
  322. async def test_edit_message_empty_content(self, adapter):
  323. result = await adapter.edit_message("room-1", "msg-1", "")
  324. assert result.success is False
  325. adapter._chatto_client.update_message.assert_not_called()
  326. async def test_edit_message_error_is_retryable(self, adapter):
  327. from chattolib.exceptions import ChattoError
  328. adapter._chatto_client.update_message.side_effect = ChattoError("boom")
  329. result = await adapter.edit_message("room-1", "msg-1", "New content")
  330. assert result.success is False
  331. assert result.retryable is True
  332. async def test_delete_message(self, adapter):
  333. result = await adapter.delete_message("room-1", "msg-1")
  334. assert result is True
  335. adapter._chatto_client.delete_message.assert_called_once()
  336. call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
  337. assert call_kwargs["room_id"] == "room-1"
  338. assert call_kwargs["event_id"] == "msg-1"
  339. async def test_delete_message_missing_ids(self, adapter):
  340. assert await adapter.delete_message("", "msg-1") is False
  341. assert await adapter.delete_message("room-1", "") is False
  342. adapter._chatto_client.delete_message.assert_not_called()
  343. async def test_delete_message_error_returns_false(self, adapter):
  344. from chattolib.exceptions import ChattoError
  345. adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
  346. assert await adapter.delete_message("room-1", "msg-1") is False
  347. # -- Outgoing text formatting --
  348. class TestFormatMessage:
  349. """format_message() only fixes what renders wrong in Chatto."""
  350. def test_normalises_crlf(self):
  351. adapter = _make_adapter()
  352. assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
  353. def test_collapses_excess_blank_lines(self):
  354. adapter = _make_adapter()
  355. assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
  356. def test_leaves_markdown_untouched(self):
  357. adapter = _make_adapter()
  358. text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
  359. assert adapter.format_message(text) == text
  360. def test_empty_content(self):
  361. adapter = _make_adapter()
  362. assert adapter.format_message("") == ""
  363. # -- Handoff threads --
  364. class TestHandoffThread:
  365. """create_handoff_thread() anchors a handoff on a seed message."""
  366. @pytest_asyncio.fixture
  367. def adapter(self):
  368. adapter = _make_adapter()
  369. adapter._chatto_client.post_message = AsyncMock()
  370. adapter._chatto_client.follow_thread = AsyncMock()
  371. return adapter
  372. async def test_returns_seed_message_id(self, adapter):
  373. mock_msg = MagicMock()
  374. mock_msg.id = "seed-1"
  375. adapter._chatto_client.post_message.return_value = mock_msg
  376. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  377. result = await adapter.create_handoff_thread("room-1", "Refactor run")
  378. assert result == "seed-1"
  379. assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
  380. adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
  381. # Our own seed must not come back in as inbound traffic.
  382. assert adapter._is_seen("seed-1") is True
  383. async def test_dm_has_no_threads(self, adapter):
  384. adapter._room_kinds["dm-1"] = RoomKind.DM
  385. assert await adapter.create_handoff_thread("dm-1", "x") is None
  386. adapter._chatto_client.post_message.assert_not_called()
  387. async def test_seed_post_failure(self, adapter):
  388. adapter._chatto_client.post_message.side_effect = RuntimeError("down")
  389. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  390. assert await adapter.create_handoff_thread("room-1", "x") is None
  391. # -- Reaction event forwarding --
  392. class TestReactionForwarding:
  393. """Human reactions reach the gateway's reaction hook surface."""
  394. @pytest_asyncio.fixture
  395. def adapter(self):
  396. adapter = _make_adapter()
  397. adapter.me = _make_user("bot-user-id", "hermes_bot")
  398. return adapter
  399. def _event(self, kind, actor_id="human-1"):
  400. event = MagicMock()
  401. event.id = "evt-1"
  402. event.kind = kind
  403. event.actor_id = actor_id
  404. payload = ReactionPayload(
  405. room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
  406. )
  407. # RealtimeEvent.get() only yields the payload for its own kind.
  408. event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
  409. return event
  410. async def test_forwards_added_reaction(self, adapter):
  411. handler = AsyncMock()
  412. adapter.set_reaction_handler(handler)
  413. await adapter._handle_realtime_event(self._event("reaction_added"))
  414. handler.assert_called_once()
  415. payload = handler.call_args.args[0]
  416. assert payload["event_name"] == "reaction:added"
  417. assert payload["reaction"] == "thumbsup"
  418. assert payload["channel_id"] == "room-1"
  419. assert payload["message_ts"] == "msg-1"
  420. assert payload["user_id"] == "human-1"
  421. assert payload["item_type"] == "message"
  422. async def test_forwards_removed_reaction(self, adapter):
  423. handler = AsyncMock()
  424. adapter.set_reaction_handler(handler)
  425. await adapter._handle_realtime_event(self._event("reaction_removed"))
  426. assert handler.call_args.args[0]["event_name"] == "reaction:removed"
  427. async def test_ignores_own_lifecycle_reactions(self, adapter):
  428. """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
  429. handler = AsyncMock()
  430. adapter.set_reaction_handler(handler)
  431. await adapter._handle_realtime_event(
  432. self._event("reaction_added", actor_id="bot-user-id"),
  433. )
  434. handler.assert_not_called()
  435. async def test_no_handler_registered_is_harmless(self, adapter):
  436. await adapter._handle_realtime_event(self._event("reaction_added"))
  437. async def test_handler_exception_does_not_propagate(self, adapter):
  438. adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
  439. await adapter._handle_realtime_event(self._event("reaction_added"))
  440. # -- Inbound attachments --
  441. class TestInboundAttachments:
  442. """Messages carrying files must reach the agent, body or not."""
  443. @pytest_asyncio.fixture
  444. def adapter(self):
  445. adapter = _make_adapter()
  446. adapter.chatto_config.allow_all_users.value = True
  447. adapter.me = _make_user("bot-user-id", "hermes_bot")
  448. adapter._room_kinds["room-1"] = RoomKind.DM
  449. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  450. adapter.handle_message = AsyncMock()
  451. adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
  452. return adapter
  453. async def test_image_attachment_becomes_media_url(self, adapter):
  454. payload = _make_posted_payload()
  455. adapter._chatto_client.get_room = AsyncMock()
  456. message = _make_message(
  457. body="look at this",
  458. attachments=[_make_attachment("shot.png", "image/png")],
  459. )
  460. payload.fetch_message = AsyncMock(return_value=message)
  461. with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
  462. await adapter._dispatch_message_posted(payload)
  463. event = adapter.handle_message.call_args.args[0]
  464. assert event.media_urls == ["/cache/shot.png"]
  465. assert event.media_types == ["image/png"]
  466. assert event.message_type == MessageType.PHOTO
  467. async def test_attachment_only_message_is_not_dropped(self, adapter):
  468. """The empty-body early return is what silently ate file uploads."""
  469. payload = _make_posted_payload()
  470. message = _make_message(
  471. body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
  472. )
  473. payload.fetch_message = AsyncMock(return_value=message)
  474. with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
  475. await adapter._dispatch_message_posted(payload)
  476. adapter.handle_message.assert_called_once()
  477. event = adapter.handle_message.call_args.args[0]
  478. assert event.message_type == MessageType.DOCUMENT
  479. assert event.media_urls == ["/cache/report.pdf"]
  480. async def test_empty_message_without_attachments_is_dropped(self, adapter):
  481. payload = _make_posted_payload()
  482. payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
  483. await adapter._dispatch_message_posted(payload)
  484. adapter.handle_message.assert_not_called()
  485. async def test_download_failure_still_delivers_the_text(self, adapter):
  486. adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
  487. payload = _make_posted_payload()
  488. payload.fetch_message = AsyncMock(return_value=_make_message(
  489. body="see attached", attachments=[_make_attachment("a.png", "image/png")],
  490. ))
  491. await adapter._dispatch_message_posted(payload)
  492. event = adapter.handle_message.call_args.args[0]
  493. assert event.text == "see attached"
  494. assert event.media_urls == []
  495. assert event.message_type == MessageType.TEXT
  496. async def test_attachment_without_asset_url_is_skipped(self, adapter):
  497. """Videos are announced before transcoding finishes."""
  498. payload = _make_posted_payload()
  499. att = _make_attachment("clip.mp4", "video/mp4")
  500. att.asset_url = None
  501. payload.fetch_message = AsyncMock(return_value=_make_message(
  502. body="clip", attachments=[att],
  503. ))
  504. await adapter._dispatch_message_posted(payload)
  505. event = adapter.handle_message.call_args.args[0]
  506. assert event.media_urls == []
  507. adapter._download_attachment_bytes.assert_not_called()
  508. async def test_document_wins_over_image(self, adapter):
  509. """Mixed batches classify as DOCUMENT — that gates context injection."""
  510. assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
  511. assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
  512. assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
  513. assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
  514. assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
  515. async def test_oversized_attachment_is_rejected(self, adapter):
  516. """The gateway media cap must bound what a hostile upload can buffer."""
  517. import httpx
  518. big = get_inbound_media_max_bytes() + 1
  519. transport = httpx.MockTransport(lambda request: httpx.Response(
  520. 200, headers={"content-length": str(big)}, content=b"x",
  521. ))
  522. real_adapter = _make_adapter()
  523. real_client_cls = httpx.AsyncClient
  524. with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
  525. with pytest.raises(ValueError):
  526. await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
  527. # NOTE: there are deliberately no tests for get_user(), set_presence() or
  528. # set_custom_status() on the adapter. Those are not adapter responsibilities —
  529. # callers use the chattolib client directly, which exposes them (client.get_user,
  530. # client.update_presence, client.update_custom_status). The adapter only touches
  531. # presence in connect()/disconnect().
  532. # -- Room operations --
  533. class TestRoomOperations:
  534. """Test room creation and DM initiation."""
  535. @pytest_asyncio.fixture
  536. def adapter(self):
  537. _clear_chatto_env()
  538. cfg = _make_config()
  539. adapter = ChattoAdapter(cfg)
  540. adapter._chatto_client = MagicMock()
  541. # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
  542. # plain MagicMock raises TypeError, which create_room()/start_dm()
  543. # swallow into a None return.
  544. adapter._chatto_client.create_room = AsyncMock()
  545. adapter._chatto_client.start_dm = AsyncMock()
  546. adapter._token = "test-token"
  547. adapter._room_names = {}
  548. adapter._room_kinds = {}
  549. return adapter
  550. async def test_create_room(self, adapter):
  551. adapter._chatto_client.create_room.return_value = _make_room(
  552. "room-123", "Test Room", RoomKind.CHANNEL,
  553. )
  554. result = await adapter.create_room("Test Room", "A test room")
  555. assert result == "room-123"
  556. adapter._chatto_client.create_room.assert_called_once()
  557. assert adapter._room_names["room-123"] == "Test Room"
  558. async def test_start_dm(self, adapter):
  559. adapter._chatto_client.start_dm.return_value = _make_room(
  560. "dm-123", "DM with user", RoomKind.DM,
  561. )
  562. result = await adapter.start_dm("user-123")
  563. assert result == "dm-123"
  564. adapter._chatto_client.start_dm.assert_called_once()
  565. assert adapter._room_kinds["dm-123"] == RoomKind.DM
  566. # -- Constants --
  567. class TestConstants:
  568. """Test that constants are properly defined."""
  569. def test_max_message_length(self):
  570. assert _MAX_MESSAGE_LENGTH == 10000
  571. def test_seen_cap(self):
  572. assert _SEEN_CAP == 500