test_adapter.py 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122
  1. """Unit tests for the Chatto platform adapter.
  2. Covers:
  3. - Protobuf codec (varint, tag, fields, client/server frames, projections)
  4. - Emoji shortcode conversion
  5. - Adapter instantiation and properties
  6. - Registration and requirements
  7. - Send / reactions / edit / delete (mocked RPC)
  8. - Typing indicator lifecycle
  9. - Read state and notifications
  10. - DM initiation and room creation
  11. - User lookup (with caching)
  12. - Presence and custom status
  13. - Message dispatch (self-echo suppression, handler invocation)
  14. - Attachment upload (chunked)
  15. All network calls are mocked — no real HTTP or WebSocket connections.
  16. """
  17. import asyncio
  18. import hashlib
  19. import os
  20. import sys
  21. import tempfile
  22. from unittest.mock import AsyncMock, MagicMock, patch, call
  23. from collections import OrderedDict
  24. import pytest
  25. import pytest_asyncio
  26. # ── Path setup ────────────────────────────────────────────────────────────
  27. sys.path.insert(0, "/opt/hermes")
  28. sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
  29. import adapter as chatto_adapter
  30. from adapter import (
  31. _encode_varint,
  32. _decode_varint,
  33. _encode_tag,
  34. _encode_field_varint,
  35. _encode_field_bytes,
  36. _encode_field_string,
  37. _encode_submessage,
  38. _decode_fields,
  39. _get_first,
  40. _get_all,
  41. _encode_client_hello,
  42. _encode_subscribe_events,
  43. _encode_ping,
  44. _encode_client_frame_hello,
  45. _encode_client_frame_subscribe,
  46. _encode_client_frame_ping,
  47. _decode_server_frame,
  48. _decode_projection_event,
  49. _decode_projection_operation,
  50. _decode_room_timeline_event,
  51. _decode_room_timeline_event_upsert,
  52. _decode_message_posted,
  53. _decode_message,
  54. _decode_thread,
  55. _decode_timestamp,
  56. _decode_event_envelope,
  57. _decode_mention_notification,
  58. _decode_dm_notification,
  59. _decode_server_hello,
  60. _decode_error,
  61. _EMOJI_TO_SHORTCODE,
  62. _REALTIME_PROTOCOL_VERSION,
  63. _MAX_MESSAGE_LENGTH,
  64. _SEEN_CAP,
  65. ChattoAdapter,
  66. check_requirements,
  67. validate_config,
  68. register,
  69. )
  70. from gateway.config import Platform, PlatformConfig
  71. from gateway.platforms.base import SendResult, MessageEvent, MessageType
  72. # ── Helpers ───────────────────────────────────────────────────────────────
  73. class _MockPluginContext:
  74. """Minimal mock for the plugin registration context."""
  75. def __init__(self):
  76. self.registered_names = []
  77. self.registered_kwargs = None
  78. def register_platform(self, **kwargs):
  79. from gateway.platform_registry import platform_registry, PlatformEntry
  80. entry = PlatformEntry(
  81. name=kwargs["name"],
  82. label=kwargs.get("label", kwargs["name"]),
  83. adapter_factory=kwargs.get("adapter_factory"),
  84. check_fn=kwargs.get("check_fn"),
  85. validate_config=kwargs.get("validate_config"),
  86. is_connected=kwargs.get("is_connected"),
  87. required_env=kwargs.get("required_env", []),
  88. source="plugin",
  89. )
  90. platform_registry.register(entry)
  91. self.registered_names.append(kwargs["name"])
  92. self.registered_kwargs = kwargs
  93. def _ensure_chatto_registered():
  94. """Register chatto in the platform registry so Platform('chatto') works."""
  95. from gateway.platform_registry import platform_registry
  96. if not platform_registry.is_registered("chatto"):
  97. ctx = _MockPluginContext()
  98. register(ctx)
  99. _CHATTO_ENV_KEYS = [
  100. "CHATTO_URL",
  101. "CHATTO_LOGIN",
  102. "CHATTO_PASSWORD",
  103. "CHATTO_CHANNELS",
  104. "CHATTO_HOME_CHANNEL",
  105. "CHATTO_REQUIRE_MENTION",
  106. "CHATTO_ALLOWED_USERS",
  107. "CHATTO_ALLOW_ALL_USERS",
  108. ]
  109. def _clear_chatto_env(monkeypatch=None):
  110. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  111. for key in _CHATTO_ENV_KEYS:
  112. if monkeypatch is not None:
  113. monkeypatch.delenv(key, raising=False)
  114. else:
  115. os.environ.pop(key, None)
  116. def _make_config(**extra_overrides):
  117. """Create a minimal PlatformConfig for testing."""
  118. _ensure_chatto_registered()
  119. extra = {"url": "https://chat.example.com", "channels": ["room1"]}
  120. extra.update(extra_overrides)
  121. return PlatformConfig(enabled=True, extra=extra)
  122. def _make_adapter(**extra_overrides):
  123. """Create a ChattoAdapter with mocked config. Token is pre-set to avoid
  124. _ensure_token triggering a real login. All CHATTO_* env vars are cleared
  125. first so the config.extra values are not overridden by the environment."""
  126. _clear_chatto_env()
  127. cfg = _make_config(**extra_overrides)
  128. adapter = ChattoAdapter(cfg)
  129. adapter._token = "test-token"
  130. adapter._user_id = "bot-user-id"
  131. adapter._user_login = "hermes_bot"
  132. adapter._user_display = "Hermes Bot"
  133. return adapter
  134. # ── Protobuf codec: varint ────────────────────────────────────────────────
  135. class TestVarint:
  136. """Test _encode_varint / _decode_varint roundtrips."""
  137. @pytest.mark.parametrize(
  138. "value",
  139. [0, 1, 127, 128, 16384, 2**32, 2**63 - 1],
  140. )
  141. def test_varint_roundtrip(self, value):
  142. encoded = _encode_varint(value)
  143. decoded, offset = _decode_varint(encoded, 0)
  144. assert decoded == value
  145. assert offset == len(encoded)
  146. def test_varint_zero(self):
  147. assert _encode_varint(0) == b"\x00"
  148. def test_varint_one(self):
  149. assert _encode_varint(1) == b"\x01"
  150. def test_varint_127(self):
  151. assert _encode_varint(127) == b"\x7f"
  152. def test_varint_128(self):
  153. assert _encode_varint(128) == b"\x80\x01"
  154. def test_varint_300(self):
  155. # 300 = 0b100101100 → 0xAC 0x02
  156. assert _encode_varint(300) == b"\xac\x02"
  157. def test_varint_16384(self):
  158. # 16384 = 0x4000 → 0x80 0x80 0x01
  159. assert _encode_varint(16384) == b"\x80\x80\x01"
  160. def test_decode_varint_truncated(self):
  161. with pytest.raises(ValueError, match="Truncated"):
  162. _decode_varint(b"\x80", 0)
  163. def test_decode_varint_too_long(self):
  164. # 10 continuation bytes — exceeds 64-bit
  165. with pytest.raises(ValueError, match="too long"):
  166. _decode_varint(b"\x80" * 10, 0)
  167. # ── Protobuf codec: tag and field encoders ────────────────────────────────
  168. class TestTagAndFields:
  169. """Test tag encoding and field-level helpers."""
  170. def test_encode_tag_field1_varint(self):
  171. # field 1, wire type 0 → (1<<3)|0 = 8 → 0x08
  172. assert _encode_tag(1, 0) == b"\x08"
  173. def test_encode_tag_field2_length_delimited(self):
  174. # field 2, wire type 2 → (2<<3)|2 = 18 → 0x12
  175. assert _encode_tag(2, 2) == b"\x12"
  176. def test_encode_tag_field15_varint(self):
  177. # field 15, wire type 0 → (15<<3)|0 = 120 → 0x78
  178. assert _encode_tag(15, 0) == b"\x78"
  179. def test_encode_field_varint(self):
  180. result = _encode_field_varint(1, 150)
  181. # tag(1,0)=0x08 + varint(150)=0x96 0x01
  182. assert result == b"\x08\x96\x01"
  183. def test_encode_field_bytes(self):
  184. result = _encode_field_bytes(2, b"hello")
  185. # tag(2,2)=0x12 + len(5)=0x05 + "hello"
  186. assert result == b"\x12\x05hello"
  187. def test_encode_field_string(self):
  188. result = _encode_field_string(3, "hi")
  189. # tag(3,2)=0x1a + len(2)=0x02 + "hi"
  190. assert result == b"\x1a\x02hi"
  191. def test_encode_submessage(self):
  192. inner = _encode_field_varint(1, 42)
  193. result = _encode_submessage(5, inner)
  194. # The submessage is length-delimited
  195. fields = _decode_fields(result)
  196. assert 5 in fields
  197. assert isinstance(fields[5][0], bytes)
  198. def test_decode_fields_varint(self):
  199. data = _encode_field_varint(1, 42)
  200. fields = _decode_fields(data)
  201. assert fields[1] == [42]
  202. def test_decode_fields_bytes(self):
  203. data = _encode_field_bytes(2, b"test")
  204. fields = _decode_fields(data)
  205. assert fields[2] == [b"test"]
  206. def test_decode_fields_string(self):
  207. data = _encode_field_string(3, "hello")
  208. fields = _decode_fields(data)
  209. assert fields[3] == [b"hello"]
  210. def test_decode_fields_multiple(self):
  211. data = _encode_field_varint(1, 10) + _encode_field_string(2, "abc")
  212. fields = _decode_fields(data)
  213. assert fields[1] == [10]
  214. assert fields[2] == [b"abc"]
  215. def test_decode_fields_repeated(self):
  216. data = _encode_field_string(2, "a") + _encode_field_string(2, "b")
  217. fields = _decode_fields(data)
  218. assert fields[2] == [b"a", b"b"]
  219. def test_decode_fields_empty(self):
  220. fields = _decode_fields(b"")
  221. assert fields == {}
  222. def test_get_first(self):
  223. fields = {1: [10, 20], 2: [b"x"]}
  224. assert _get_first(fields, 1) == 10
  225. assert _get_first(fields, 2) == b"x"
  226. assert _get_first(fields, 99, "default") == "default"
  227. def test_get_all(self):
  228. fields = {1: [10, 20]}
  229. assert _get_all(fields, 1) == [10, 20]
  230. assert _get_all(fields, 99) == []
  231. # ── Protobuf codec: client hello / subscribe / ping ──────────────────────
  232. class TestClientFrames:
  233. """Test client-side protobuf message encoders."""
  234. def test_encode_client_hello_with_token(self):
  235. msg = _encode_client_hello("my-bearer-token")
  236. fields = _decode_fields(msg)
  237. # field 1 = protocol_version (varint)
  238. assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
  239. # field 2 = bearer_token (bytes)
  240. token_val = _get_first(fields, 2)
  241. assert isinstance(token_val, bytes)
  242. assert token_val.decode("utf-8") == "my-bearer-token"
  243. def test_encode_client_hello_without_token(self):
  244. msg = _encode_client_hello("")
  245. fields = _decode_fields(msg)
  246. assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
  247. assert 2 not in fields # no bearer_token field
  248. def test_encode_client_hello_protocol_version_is_1(self):
  249. msg = _encode_client_hello("x")
  250. fields = _decode_fields(msg)
  251. assert _get_first(fields, 1) == 1
  252. def test_encode_subscribe_events_with_rooms(self):
  253. msg = _encode_subscribe_events(retained_room_ids=["room1", "room2"])
  254. fields = _decode_fields(msg)
  255. # field 2 = repeated string
  256. room_vals = _get_all(fields, 2)
  257. assert len(room_vals) == 2
  258. assert room_vals[0].decode("utf-8") == "room1"
  259. assert room_vals[1].decode("utf-8") == "room2"
  260. def test_encode_subscribe_events_with_cursor(self):
  261. msg = _encode_subscribe_events(resume_cursor="cursor123")
  262. fields = _decode_fields(msg)
  263. cursor = _get_first(fields, 1)
  264. assert isinstance(cursor, bytes)
  265. assert cursor.decode("utf-8") == "cursor123"
  266. def test_encode_subscribe_events_empty(self):
  267. msg = _encode_subscribe_events()
  268. assert msg == b""
  269. def test_encode_ping_is_empty(self):
  270. assert _encode_ping() == b""
  271. def test_encode_client_frame_hello(self):
  272. hello = _encode_client_hello("token")
  273. frame = _encode_client_frame_hello(hello)
  274. fields = _decode_fields(frame)
  275. # field 1 = hello submessage
  276. assert 1 in fields
  277. inner = _get_first(fields, 1)
  278. assert isinstance(inner, bytes)
  279. # Decode inner to verify
  280. inner_fields = _decode_fields(inner)
  281. assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
  282. def test_encode_client_frame_subscribe(self):
  283. sub = _encode_subscribe_events(retained_room_ids=["r1"])
  284. frame = _encode_client_frame_subscribe(sub)
  285. fields = _decode_fields(frame)
  286. # field 2 = subscribe_events submessage
  287. assert 2 in fields
  288. def test_encode_client_frame_ping(self):
  289. ping = _encode_ping()
  290. frame = _encode_client_frame_ping(ping)
  291. fields = _decode_fields(frame)
  292. # field 3 = ping submessage (empty)
  293. assert 3 in fields
  294. def test_client_hello_roundtrip(self):
  295. """Encode a client hello, wrap in a frame, decode the frame, decode
  296. the inner hello, and verify values match."""
  297. original = _encode_client_hello("roundtrip-token")
  298. frame = _encode_client_frame_hello(original)
  299. decoded_frame = _decode_server_frame # not for client frames, but
  300. # We decode the frame manually
  301. frame_fields = _decode_fields(frame)
  302. inner_bytes = _get_first(frame_fields, 1)
  303. inner_fields = _decode_fields(inner_bytes)
  304. assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
  305. token_raw = _get_first(inner_fields, 2)
  306. assert token_raw.decode("utf-8") == "roundtrip-token"
  307. # ── Protobuf codec: server frame decoding ────────────────────────────────
  308. class TestServerFrameDecoding:
  309. """Test _decode_server_frame with synthetic frames."""
  310. def test_decode_server_hello_frame(self):
  311. # Build a RealtimeServerHello { protocol_version = 1 }
  312. hello_inner = _encode_field_varint(1, 1)
  313. # Wrap in RealtimeServerFrame { hello = 1 }
  314. frame = _encode_submessage(1, hello_inner)
  315. result = _decode_server_frame(frame)
  316. assert result["type"] == "hello"
  317. assert isinstance(result["data"], bytes)
  318. # Decode the hello data
  319. hello = _decode_server_hello(result["data"])
  320. assert hello["protocolVersion"] == 1
  321. def test_decode_subscribed_frame(self):
  322. # Build an empty submessage for field 2 (subscribed)
  323. frame = _encode_submessage(2, b"")
  324. result = _decode_server_frame(frame)
  325. assert result["type"] == "subscribed"
  326. def test_decode_error_frame(self):
  327. # Build RealtimeError { message = "bad", code = 500 }
  328. error_inner = _encode_field_string(1, "bad") + _encode_field_varint(2, 500)
  329. # Wrap in RealtimeServerFrame { error = 5 }
  330. frame = _encode_submessage(5, error_inner)
  331. result = _decode_server_frame(frame)
  332. assert result["type"] == "error"
  333. decoded = _decode_error(result["data"])
  334. assert decoded["message"] == "bad"
  335. assert decoded["code"] == 500
  336. def test_decode_pong_frame(self):
  337. frame = _encode_submessage(7, b"")
  338. result = _decode_server_frame(frame)
  339. assert result["type"] == "pong"
  340. def test_decode_heartbeat_frame(self):
  341. frame = _encode_submessage(4, b"")
  342. result = _decode_server_frame(frame)
  343. assert result["type"] == "heartbeat"
  344. def test_decode_close_frame(self):
  345. close_inner = _encode_field_string(1, "bye")
  346. frame = _encode_submessage(6, close_inner)
  347. result = _decode_server_frame(frame)
  348. assert result["type"] == "close"
  349. def test_decode_caught_up_frame(self):
  350. frame = _encode_submessage(8, b"")
  351. result = _decode_server_frame(frame)
  352. assert result["type"] == "caught_up"
  353. def test_decode_unknown_frame(self):
  354. # Empty frame → no fields → unknown
  355. result = _decode_server_frame(b"")
  356. assert result["type"] == "unknown"
  357. assert result["data"] is None
  358. # ── Protobuf codec: projection event decoding ────────────────────────────
  359. class TestProjectionEventDecoding:
  360. """Test _decode_projection_event and related decoders."""
  361. def test_decode_projection_event_basic(self):
  362. # Build RealtimeProjectionEvent {
  363. # id = "evt1",
  364. # actor_id = "user1",
  365. # resume_cursor = "cursor1"
  366. # }
  367. proj = (
  368. _encode_field_string(1, "evt1")
  369. + _encode_field_string(3, "user1")
  370. + _encode_field_string(4, "cursor1")
  371. )
  372. result = _decode_projection_event(proj)
  373. assert result["id"] == "evt1"
  374. assert result["actor_id"] == "user1"
  375. assert result["resume_cursor"] == "cursor1"
  376. assert result["operations"] == []
  377. def test_decode_projection_event_with_timestamp(self):
  378. ts_inner = _encode_field_varint(1, 1700000000) # seconds
  379. proj = (
  380. _encode_field_string(1, "evt2")
  381. + _encode_submessage(2, ts_inner)
  382. )
  383. result = _decode_projection_event(proj)
  384. assert result["id"] == "evt2"
  385. assert "1700000000" not in result["created_at"] # should be ISO format
  386. assert "T" in result["created_at"] # ISO format has T separator
  387. def test_decode_projection_event_with_operation(self):
  388. # Build a room_timeline_event_upsert operation
  389. room_id = _encode_field_string(1, "room123")
  390. # Build a minimal RoomTimelineEvent
  391. event_inner = _encode_field_string(1, "evt456")
  392. event_upsert = room_id + _encode_submessage(2, event_inner)
  393. # Wrap in RealtimeProjectionOperation { room_timeline_event_upsert = 10 }
  394. op = _encode_submessage(10, event_upsert)
  395. # Wrap in RealtimeProjectionEvent { operations = 5 }
  396. proj = _encode_field_string(1, "proj1") + _encode_submessage(5, op)
  397. result = _decode_projection_event(proj)
  398. assert len(result["operations"]) == 1
  399. op_result = result["operations"][0]
  400. assert op_result["type"] == "room_timeline_event_upsert"
  401. assert op_result["room_id"] == "room123"
  402. assert op_result["event"]["id"] == "evt456"
  403. def test_decode_projection_operation_unknown(self):
  404. # An operation with field 1 (room_upsert) — not handled specifically
  405. op = _encode_submessage(1, _encode_field_string(1, "room1"))
  406. result = _decode_projection_operation(op)
  407. assert result["type"] == "room_upsert"
  408. def test_decode_projection_operation_empty(self):
  409. result = _decode_projection_operation(b"")
  410. assert result["type"] == "empty"
  411. # ── Protobuf codec: room timeline event decoding ─────────────────────────
  412. class TestRoomTimelineEventDecoding:
  413. """Test _decode_room_timeline_event and _decode_message."""
  414. def test_decode_room_timeline_event_basic(self):
  415. # Build RoomTimelineEvent {
  416. # id = "evt1", room_id = "room1", kind = 1 (message_posted)
  417. # }
  418. ts_inner = _encode_field_varint(1, 1700000000)
  419. event = (
  420. _encode_field_string(1, "evt1")
  421. + _encode_submessage(2, ts_inner)
  422. + _encode_field_string(3, "room1")
  423. + _encode_field_varint(4, 1)
  424. )
  425. result = _decode_room_timeline_event(event)
  426. assert result["id"] == "evt1"
  427. assert result["roomId"] == "room1"
  428. assert result["kind"] == 1
  429. assert "T" in result["createdAt"]
  430. def test_decode_room_timeline_event_with_message(self):
  431. # Build a Message { id="m1", room_id="r1", actor_id="u1", body="hello" }
  432. ts_inner = _encode_field_varint(1, 1700000000)
  433. msg = (
  434. _encode_field_string(1, "m1")
  435. + _encode_field_string(2, "r1")
  436. + _encode_field_string(3, "u1")
  437. + _encode_field_string(4, "hello")
  438. + _encode_submessage(5, ts_inner)
  439. )
  440. # Wrap in MessagePosted { message = 1 }
  441. posted = _encode_submessage(1, msg)
  442. # Wrap in RoomTimelineEvent { message_posted = 5 }
  443. event = (
  444. _encode_field_string(1, "evt1")
  445. + _encode_field_string(3, "r1")
  446. + _encode_submessage(5, posted)
  447. )
  448. result = _decode_room_timeline_event(event)
  449. assert result["id"] == "evt1"
  450. assert result["messagePosted"] is not None
  451. assert result["messagePosted"]["message"]["id"] == "m1"
  452. assert result["messagePosted"]["message"]["body"] == "hello"
  453. assert result["messagePosted"]["message"]["actorId"] == "u1"
  454. def test_decode_message_with_thread(self):
  455. # Build Message with a thread
  456. thread_inner = _encode_field_string(1, "thread-root-123")
  457. msg = (
  458. _encode_field_string(1, "m1")
  459. + _encode_field_string(2, "r1")
  460. + _encode_field_string(3, "u1")
  461. + _encode_field_string(4, "threaded reply")
  462. + _encode_submessage(8, thread_inner)
  463. )
  464. result = _decode_message(msg)
  465. assert result["id"] == "m1"
  466. assert result["body"] == "threaded reply"
  467. assert result["thread"]["threadRootEventId"] == "thread-root-123"
  468. def test_decode_message_with_login_and_display(self):
  469. msg = (
  470. _encode_field_string(1, "m1")
  471. + _encode_field_string(2, "r1")
  472. + _encode_field_string(3, "u1")
  473. + _encode_field_string(4, "hi")
  474. + _encode_field_string(6, "alice")
  475. + _encode_field_string(7, "Alice Smith")
  476. )
  477. result = _decode_message(msg)
  478. assert result["actorLogin"] == "alice"
  479. assert result["actorDisplayName"] == "Alice Smith"
  480. def test_decode_thread(self):
  481. thread = _encode_field_string(1, "thread-abc")
  482. result = _decode_thread(thread)
  483. assert result["threadRootEventId"] == "thread-abc"
  484. def test_decode_thread_empty(self):
  485. result = _decode_thread(b"")
  486. assert result["threadRootEventId"] == ""
  487. def test_decode_message_posted_empty(self):
  488. result = _decode_message_posted(b"")
  489. assert result == {"message": {}}
  490. # ── Protobuf codec: timestamp decoding ────────────────────────────────────
  491. class TestTimestampDecoding:
  492. """Test _decode_timestamp."""
  493. def test_decode_timestamp_basic(self):
  494. ts = _encode_field_varint(1, 1700000000)
  495. result = _decode_timestamp(ts)
  496. assert "2023" in result # Nov 14, 2023
  497. assert result.endswith("Z")
  498. def test_decode_timestamp_with_nanos(self):
  499. ts = _encode_field_varint(1, 1700000000) + _encode_field_varint(2, 500000)
  500. result = _decode_timestamp(ts)
  501. assert "2023" in result
  502. # 500000 nanos = 0.5 seconds → microsecond=500000 → ".000500" in ISO
  503. assert ".000500" in result
  504. def test_decode_timestamp_zero(self):
  505. ts = b""
  506. result = _decode_timestamp(ts)
  507. assert result == ""
  508. def test_decode_timestamp_only_nanos(self):
  509. # No seconds, just nanos — should return "" since seconds == 0
  510. ts = _encode_field_varint(2, 1000000)
  511. result = _decode_timestamp(ts)
  512. assert result == ""
  513. # ── Protobuf codec: event envelope (transient events) ────────────────────
  514. class TestEventEnvelopeDecoding:
  515. """Test _decode_event_envelope for mention and DM notifications."""
  516. def test_decode_mention_notification(self):
  517. # Build MentionNotification { room_id = "r1", event_id = "e1" }
  518. mention_inner = _encode_field_string(1, "r1") + _encode_field_string(2, "e1")
  519. # Wrap in RealtimeEventEnvelope { mention_notification = 88 }
  520. envelope = (
  521. _encode_field_string(1, "envelope1")
  522. + _encode_submessage(88, mention_inner)
  523. )
  524. result = _decode_event_envelope(envelope)
  525. assert result["id"] == "envelope1"
  526. assert result["type"] == "mention_notification"
  527. assert result["data"]["roomId"] == "r1"
  528. assert result["data"]["eventId"] == "e1"
  529. def test_decode_dm_notification(self):
  530. # Build NewDirectMessageNotification { room_id = "r2", event_id = "e2" }
  531. dm_inner = _encode_field_string(1, "r2") + _encode_field_string(2, "e2")
  532. # Wrap in RealtimeEventEnvelope { new_direct_message_notification = 89 }
  533. envelope = (
  534. _encode_field_string(1, "envelope2")
  535. + _encode_submessage(89, dm_inner)
  536. )
  537. result = _decode_event_envelope(envelope)
  538. assert result["id"] == "envelope2"
  539. assert result["type"] == "new_direct_message_notification"
  540. assert result["data"]["roomId"] == "r2"
  541. assert result["data"]["eventId"] == "e2"
  542. def test_decode_event_envelope_unknown(self):
  543. envelope = _encode_field_string(1, "env3")
  544. result = _decode_event_envelope(envelope)
  545. assert result["id"] == "env3"
  546. assert result["type"] == "unknown"
  547. assert result["data"] == {}
  548. def test_decode_mention_notification_directly(self):
  549. mention_inner = _encode_field_string(1, "roomX") + _encode_field_string(2, "evtX")
  550. result = _decode_mention_notification(mention_inner)
  551. assert result["roomId"] == "roomX"
  552. assert result["eventId"] == "evtX"
  553. def test_decode_dm_notification_directly(self):
  554. dm_inner = _encode_field_string(1, "roomY") + _encode_field_string(2, "evtY")
  555. result = _decode_dm_notification(dm_inner)
  556. assert result["roomId"] == "roomY"
  557. assert result["eventId"] == "evtY"
  558. # ── Emoji shortcode conversion ───────────────────────────────────────────
  559. class TestEmojiShortcode:
  560. """Test _emoji_to_shortcode static method and the emoji mapping."""
  561. @pytest.mark.parametrize(
  562. "emoji,shortcode",
  563. [
  564. ("👍", "thumbsup"),
  565. ("👎", "thumbsdown"),
  566. ("❤️", "heart"),
  567. ("❤", "heart"),
  568. ("✅", "white_check_mark"),
  569. ("❌", "x"),
  570. ("👀", "eyes"),
  571. ("🎉", "tada"),
  572. ("😂", "joy"),
  573. ("🚀", "rocket"),
  574. ("🔥", "fire"),
  575. ("💯", "100"),
  576. ("🤔", "thinking"),
  577. ("👏", "clap"),
  578. ("🙏", "pray"),
  579. ("😅", "sweat_smile"),
  580. ("😴", "sleeping"),
  581. ("⏳", "hourglass"),
  582. ],
  583. )
  584. def test_known_emoji_to_shortcode(self, emoji, shortcode):
  585. assert ChattoAdapter._emoji_to_shortcode(emoji) == shortcode
  586. def test_unknown_emoji_passes_through(self):
  587. # 🦀 (crab) is not in the mapping
  588. assert ChattoAdapter._emoji_to_shortcode("🦀") == "🦀"
  589. def test_shortcode_passes_through(self):
  590. assert ChattoAdapter._emoji_to_shortcode("thumbsup") == "thumbsup"
  591. def test_empty_string_passes_through(self):
  592. assert ChattoAdapter._emoji_to_shortcode("") == ""
  593. def test_emoji_mapping_completeness(self):
  594. """Verify the mapping dict has the expected keys."""
  595. assert "👍" in _EMOJI_TO_SHORTCODE
  596. assert _EMOJI_TO_SHORTCODE["👍"] == "thumbsup"
  597. assert _EMOJI_TO_SHORTCODE["❤️"] == "heart"
  598. # ── Adapter instantiation and properties ──────────────────────────────────
  599. class TestAdapterInstantiation:
  600. """Test ChattoAdapter creation and property values."""
  601. def test_platform_name(self):
  602. adapter = _make_adapter()
  603. assert adapter.platform_name == "chatto"
  604. def test_supports_markdown(self):
  605. adapter = _make_adapter()
  606. assert adapter.supports_markdown is True
  607. def test_supports_reactions(self):
  608. adapter = _make_adapter()
  609. assert adapter.supports_reactions is True
  610. def test_supports_threads(self):
  611. adapter = _make_adapter()
  612. assert adapter.supports_threads is True
  613. def test_max_message_length(self):
  614. adapter = _make_adapter()
  615. assert adapter.MAX_MESSAGE_LENGTH == 10000
  616. def test_splits_long_messages(self):
  617. adapter = _make_adapter()
  618. assert adapter.splits_long_messages is True
  619. def test_typing_tasks_empty(self):
  620. adapter = _make_adapter()
  621. assert adapter._typing_tasks == {}
  622. def test_user_cache_empty(self):
  623. adapter = _make_adapter()
  624. assert adapter._user_cache == {}
  625. def test_base_url_from_extra(self):
  626. adapter = _make_adapter()
  627. assert adapter._base_url == "https://chat.example.com"
  628. def test_channels_from_extra(self):
  629. adapter = _make_adapter()
  630. assert adapter._channel_ids == ["room1"]
  631. def test_require_mention_default(self):
  632. adapter = _make_adapter(require_mention=False)
  633. assert adapter._require_mention is False
  634. def test_is_base_platform_adapter(self):
  635. from gateway.platforms.base import BasePlatformAdapter
  636. adapter = _make_adapter()
  637. assert isinstance(adapter, BasePlatformAdapter)
  638. # ── Registration and requirements ─────────────────────────────────────────
  639. class TestRegistration:
  640. """Test plugin registration and requirements checking."""
  641. def test_register_calls_register_platform(self):
  642. ctx = _MockPluginContext()
  643. register(ctx)
  644. assert "chatto" in ctx.registered_names
  645. kwargs = ctx.registered_kwargs
  646. assert kwargs["name"] == "chatto"
  647. assert kwargs["label"] == "Chatto"
  648. assert callable(kwargs["adapter_factory"])
  649. assert callable(kwargs["check_fn"])
  650. def test_register_adapter_factory_creates_adapter(self):
  651. ctx = _MockPluginContext()
  652. register(ctx)
  653. kwargs = ctx.registered_kwargs
  654. cfg = _make_config()
  655. adapter = kwargs["adapter_factory"](cfg)
  656. assert adapter is not None
  657. assert isinstance(adapter, ChattoAdapter)
  658. def test_check_requirements_true_when_env_set(self, monkeypatch):
  659. monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
  660. monkeypatch.setenv("CHATTO_LOGIN", "user")
  661. monkeypatch.setenv("CHATTO_PASSWORD", "pass")
  662. assert check_requirements() is True
  663. def test_check_requirements_false_when_url_missing(self, monkeypatch):
  664. monkeypatch.delenv("CHATTO_URL", raising=False)
  665. monkeypatch.setenv("CHATTO_LOGIN", "user")
  666. monkeypatch.setenv("CHATTO_PASSWORD", "pass")
  667. assert check_requirements() is False
  668. def test_check_requirements_false_when_login_missing(self, monkeypatch):
  669. monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
  670. monkeypatch.delenv("CHATTO_LOGIN", raising=False)
  671. monkeypatch.setenv("CHATTO_PASSWORD", "pass")
  672. assert check_requirements() is False
  673. def test_check_requirements_false_when_password_missing(self, monkeypatch):
  674. monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
  675. monkeypatch.setenv("CHATTO_LOGIN", "user")
  676. monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
  677. assert check_requirements() is False
  678. def test_check_requirements_false_all_missing(self, monkeypatch):
  679. monkeypatch.delenv("CHATTO_URL", raising=False)
  680. monkeypatch.delenv("CHATTO_LOGIN", raising=False)
  681. monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
  682. assert check_requirements() is False
  683. def test_validate_config_with_extra_url(self):
  684. cfg = MagicMock()
  685. cfg.extra = {"url": "https://chat.example.com"}
  686. # Also need login/password from env
  687. with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}):
  688. assert validate_config(cfg) is True
  689. def test_validate_config_missing_url(self):
  690. cfg = MagicMock()
  691. cfg.extra = {}
  692. with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}, clear=False):
  693. # Remove CHATTO_URL if set
  694. os.environ.pop("CHATTO_URL", None)
  695. assert validate_config(cfg) is False
  696. # ── Send method (mocked RPC) ──────────────────────────────────────────────
  697. class TestSend:
  698. """Test the send() method with mocked _rpc."""
  699. @pytest.mark.asyncio
  700. async def test_send_basic(self):
  701. adapter = _make_adapter()
  702. adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt123"}}))
  703. result = await adapter.send("room1", "Hello world")
  704. assert result.success is True
  705. assert result.message_id == "evt123"
  706. @pytest.mark.asyncio
  707. async def test_send_with_reply_to(self):
  708. adapter = _make_adapter()
  709. adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt456"}}))
  710. adapter._follow_thread = AsyncMock()
  711. await adapter.send("room1", "Reply", reply_to="thread-root-1")
  712. # Verify RPC body contains threadRootEventId
  713. call_args = adapter._rpc.call_args
  714. body = call_args.kwargs.get("body") or call_args.args[1]
  715. assert body["threadRootEventId"] == "thread-root-1"
  716. @pytest.mark.asyncio
  717. async def test_send_with_metadata_thread_id(self):
  718. adapter = _make_adapter()
  719. adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt789"}}))
  720. adapter._follow_thread = AsyncMock()
  721. await adapter.send("room1", "Reply", metadata={"thread_id": "thread456"})
  722. call_args = adapter._rpc.call_args
  723. body = call_args.kwargs.get("body") or call_args.args[1]
  724. assert body["threadRootEventId"] == "thread456"
  725. @pytest.mark.asyncio
  726. async def test_send_empty_content(self):
  727. adapter = _make_adapter()
  728. adapter._rpc = AsyncMock()
  729. result = await adapter.send("room1", "")
  730. assert result.success is False
  731. assert "Empty" in (result.error or "")
  732. adapter._rpc.assert_not_called()
  733. @pytest.mark.asyncio
  734. async def test_send_failure(self):
  735. adapter = _make_adapter()
  736. adapter._rpc = AsyncMock(return_value=(500, {"error": "server error"}))
  737. result = await adapter.send("room1", "Hello")
  738. assert result.success is False
  739. assert "server error" in (result.error or "")
  740. @pytest.mark.asyncio
  741. async def test_send_long_message_split(self):
  742. adapter = _make_adapter()
  743. # Return different message IDs for each call
  744. call_count = [0]
  745. async def mock_rpc(path, body, **kwargs):
  746. call_count[0] += 1
  747. return 200, {"message": {"id": f"evt-{call_count[0]}"}}
  748. adapter._rpc = AsyncMock(side_effect=mock_rpc)
  749. adapter._follow_thread = AsyncMock()
  750. # Create content longer than MAX_MESSAGE_LENGTH (10000)
  751. long_content = "A" * 12000
  752. result = await adapter.send("room1", long_content)
  753. assert result.success is True
  754. assert result.message_id == "evt-1"
  755. # Should have been called multiple times
  756. assert adapter._rpc.call_count > 1
  757. @pytest.mark.asyncio
  758. async def test_send_marks_seen(self):
  759. adapter = _make_adapter()
  760. adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "new_evt"}}))
  761. await adapter.send("room1", "Hello")
  762. assert "new_evt" in adapter._seen.get("room1", {})
  763. # ── Reactions (mocked RPC) ────────────────────────────────────────────────
  764. class TestReactions:
  765. """Test send_reaction and remove_reaction."""
  766. @pytest.mark.asyncio
  767. async def test_send_reaction_success(self):
  768. adapter = _make_adapter()
  769. adapter._rpc = AsyncMock(return_value=(200, {"added": True}))
  770. result = await adapter.send_reaction("room1", "evt1", "👍")
  771. assert result is True
  772. # Verify emoji was converted to shortcode
  773. call_args = adapter._rpc.call_args
  774. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  775. assert body["emoji"] == "thumbsup"
  776. @pytest.mark.asyncio
  777. async def test_send_reaction_heart(self):
  778. adapter = _make_adapter()
  779. adapter._rpc = AsyncMock(return_value=(200, {}))
  780. await adapter.send_reaction("room1", "evt1", "❤️")
  781. call_args = adapter._rpc.call_args
  782. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  783. assert body["emoji"] == "heart"
  784. @pytest.mark.asyncio
  785. async def test_remove_reaction_success(self):
  786. adapter = _make_adapter()
  787. adapter._rpc = AsyncMock(return_value=(200, {}))
  788. result = await adapter.remove_reaction("room1", "evt1", "❤️")
  789. assert result is True
  790. call_args = adapter._rpc.call_args
  791. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  792. assert body["emoji"] == "heart"
  793. @pytest.mark.asyncio
  794. async def test_send_reaction_failure(self):
  795. adapter = _make_adapter()
  796. adapter._rpc = AsyncMock(return_value=(500, {}))
  797. result = await adapter.send_reaction("room1", "evt1", "👍")
  798. assert result is False
  799. @pytest.mark.asyncio
  800. async def test_remove_reaction_failure(self):
  801. adapter = _make_adapter()
  802. adapter._rpc = AsyncMock(return_value=(404, {}))
  803. result = await adapter.remove_reaction("room1", "evt1", "❤️")
  804. assert result is False
  805. @pytest.mark.asyncio
  806. async def test_send_reaction_exception(self):
  807. adapter = _make_adapter()
  808. adapter._rpc = AsyncMock(side_effect=Exception("network error"))
  809. result = await adapter.send_reaction("room1", "evt1", "👍")
  810. assert result is False
  811. # ── Message edit/delete (mocked RPC) ──────────────────────────────────────
  812. class TestEditDelete:
  813. """Test edit_message and delete_message."""
  814. @pytest.mark.asyncio
  815. async def test_edit_message_success(self):
  816. adapter = _make_adapter()
  817. adapter._rpc = AsyncMock(return_value=(200, {}))
  818. result = await adapter.edit_message("room1", "evt1", "new text")
  819. assert result is True
  820. call_args = adapter._rpc.call_args
  821. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  822. assert body["body"] == "new text"
  823. assert body["eventId"] == "evt1"
  824. @pytest.mark.asyncio
  825. async def test_edit_message_failure(self):
  826. adapter = _make_adapter()
  827. adapter._rpc = AsyncMock(return_value=(404, {}))
  828. result = await adapter.edit_message("room1", "evt1", "new text")
  829. assert result is False
  830. @pytest.mark.asyncio
  831. async def test_delete_message_success(self):
  832. adapter = _make_adapter()
  833. adapter._rpc = AsyncMock(return_value=(200, {}))
  834. result = await adapter.delete_message("room1", "evt1")
  835. assert result is True
  836. @pytest.mark.asyncio
  837. async def test_delete_message_failure(self):
  838. adapter = _make_adapter()
  839. adapter._rpc = AsyncMock(return_value=(404, {}))
  840. result = await adapter.delete_message("room1", "evt1")
  841. assert result is False
  842. @pytest.mark.asyncio
  843. async def test_edit_message_exception(self):
  844. adapter = _make_adapter()
  845. adapter._rpc = AsyncMock(side_effect=Exception("boom"))
  846. result = await adapter.edit_message("room1", "evt1", "text")
  847. assert result is False
  848. @pytest.mark.asyncio
  849. async def test_delete_message_exception(self):
  850. adapter = _make_adapter()
  851. adapter._rpc = AsyncMock(side_effect=Exception("boom"))
  852. result = await adapter.delete_message("room1", "evt1")
  853. assert result is False
  854. # ── Typing indicator lifecycle ─────────────────────────────────────────────
  855. class TestTypingIndicator:
  856. """Test send_typing and stop_typing lifecycle."""
  857. @pytest.mark.asyncio
  858. async def test_send_typing_creates_task(self):
  859. adapter = _make_adapter()
  860. adapter._rpc = AsyncMock(return_value=(200, {}))
  861. await adapter.send_typing("room1")
  862. assert "room1" in adapter._typing_tasks
  863. assert isinstance(adapter._typing_tasks["room1"], asyncio.Task)
  864. # Clean up
  865. await adapter.stop_typing("room1")
  866. @pytest.mark.asyncio
  867. async def test_send_typing_no_duplicate(self):
  868. adapter = _make_adapter()
  869. adapter._rpc = AsyncMock(return_value=(200, {}))
  870. await adapter.send_typing("room1")
  871. first_task = adapter._typing_tasks["room1"]
  872. await adapter.send_typing("room1")
  873. assert adapter._typing_tasks["room1"] is first_task
  874. await adapter.stop_typing("room1")
  875. @pytest.mark.asyncio
  876. async def test_stop_typing_cancels_task(self):
  877. adapter = _make_adapter()
  878. adapter._rpc = AsyncMock(return_value=(200, {}))
  879. await adapter.send_typing("room1")
  880. assert "room1" in adapter._typing_tasks
  881. await adapter.stop_typing("room1")
  882. assert "room1" not in adapter._typing_tasks
  883. @pytest.mark.asyncio
  884. async def test_stop_typing_when_not_running(self):
  885. adapter = _make_adapter()
  886. # Should not raise even if no task exists
  887. await adapter.stop_typing("room1")
  888. @pytest.mark.asyncio
  889. async def test_typing_loop_calls_rpc(self):
  890. adapter = _make_adapter()
  891. adapter._rpc = AsyncMock(return_value=(200, {}))
  892. await adapter.send_typing("room1")
  893. # Allow the loop to run one iteration
  894. await asyncio.sleep(0.05)
  895. # The typing loop should have called _rpc at least once
  896. assert adapter._rpc.call_count >= 1
  897. # Verify it was called with the typing path
  898. first_call = adapter._rpc.call_args_list[0]
  899. path = first_call.args[0] if len(first_call.args) > 0 else first_call.kwargs.get("path")
  900. assert "UpdateTypingIndicator" in path
  901. body = first_call.args[1] if len(first_call.args) > 1 else first_call.kwargs.get("body")
  902. assert body["roomId"] == "room1"
  903. assert body["typing"] is True
  904. await adapter.stop_typing("room1")
  905. # ── Read state and notifications (mocked RPC) ─────────────────────────────
  906. class TestReadStateAndNotifications:
  907. """Test mark_room_as_read, mark_thread_as_read, dismiss notifications."""
  908. @pytest.mark.asyncio
  909. async def test_mark_room_as_read_success(self):
  910. adapter = _make_adapter()
  911. adapter._rpc = AsyncMock(return_value=(200, {}))
  912. result = await adapter.mark_room_as_read("room1")
  913. assert result is True
  914. @pytest.mark.asyncio
  915. async def test_mark_room_as_read_failure(self):
  916. adapter = _make_adapter()
  917. adapter._rpc = AsyncMock(return_value=(500, {}))
  918. result = await adapter.mark_room_as_read("room1")
  919. assert result is False
  920. @pytest.mark.asyncio
  921. async def test_mark_thread_as_read_success(self):
  922. adapter = _make_adapter()
  923. adapter._rpc = AsyncMock(return_value=(200, {}))
  924. result = await adapter.mark_thread_as_read("room1", "thread-root-1")
  925. assert result is True
  926. call_args = adapter._rpc.call_args
  927. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  928. assert body["roomId"] == "room1"
  929. assert body["threadRootEventId"] == "thread-root-1"
  930. @pytest.mark.asyncio
  931. async def test_mark_thread_as_read_failure(self):
  932. adapter = _make_adapter()
  933. adapter._rpc = AsyncMock(return_value=(404, {}))
  934. result = await adapter.mark_thread_as_read("room1", "thread-root-1")
  935. assert result is False
  936. @pytest.mark.asyncio
  937. async def test_dismiss_all_notifications_success(self):
  938. adapter = _make_adapter()
  939. adapter._rpc = AsyncMock(return_value=(200, {}))
  940. result = await adapter.dismiss_all_notifications()
  941. assert result is True
  942. @pytest.mark.asyncio
  943. async def test_dismiss_all_notifications_failure(self):
  944. adapter = _make_adapter()
  945. adapter._rpc = AsyncMock(return_value=(500, {}))
  946. result = await adapter.dismiss_all_notifications()
  947. assert result is False
  948. @pytest.mark.asyncio
  949. async def test_dismiss_notification_success(self):
  950. adapter = _make_adapter()
  951. adapter._rpc = AsyncMock(return_value=(200, {}))
  952. result = await adapter.dismiss_notification("notif123")
  953. assert result is True
  954. call_args = adapter._rpc.call_args
  955. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  956. assert body["notificationId"] == "notif123"
  957. @pytest.mark.asyncio
  958. async def test_dismiss_notification_failure(self):
  959. adapter = _make_adapter()
  960. adapter._rpc = AsyncMock(return_value=(404, {}))
  961. result = await adapter.dismiss_notification("notif123")
  962. assert result is False
  963. # ── DM initiation and room creation (mocked RPC) ──────────────────────────
  964. class TestDMAndRoomCreation:
  965. """Test start_dm and create_room."""
  966. @pytest.mark.asyncio
  967. async def test_start_dm_success(self):
  968. adapter = _make_adapter()
  969. adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
  970. result = await adapter.start_dm("user123")
  971. assert result == "room789"
  972. call_args = adapter._rpc.call_args
  973. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  974. assert body["participantIds"] == ["user123"]
  975. @pytest.mark.asyncio
  976. async def test_start_dm_failure(self):
  977. adapter = _make_adapter()
  978. adapter._rpc = AsyncMock(return_value=(500, {}))
  979. result = await adapter.start_dm("user123")
  980. assert result is None
  981. @pytest.mark.asyncio
  982. async def test_start_dm_no_room_id(self):
  983. adapter = _make_adapter()
  984. adapter._rpc = AsyncMock(return_value=(200, {"room": {}}))
  985. result = await adapter.start_dm("user123")
  986. assert result is None
  987. @pytest.mark.asyncio
  988. async def test_start_dm_sets_room_kind(self):
  989. adapter = _make_adapter()
  990. adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "dm-room-1"}}))
  991. await adapter.start_dm("user123")
  992. assert adapter._room_kinds["dm-room-1"] == "ROOM_KIND_DM"
  993. @pytest.mark.asyncio
  994. async def test_create_room_success(self):
  995. adapter = _make_adapter()
  996. adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
  997. result = await adapter.create_room("test-room", "description")
  998. assert result == "room789"
  999. call_args = adapter._rpc.call_args
  1000. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1001. assert body["name"] == "test-room"
  1002. assert body["description"] == "description"
  1003. @pytest.mark.asyncio
  1004. async def test_create_room_failure(self):
  1005. adapter = _make_adapter()
  1006. adapter._rpc = AsyncMock(return_value=(500, {}))
  1007. result = await adapter.create_room("test-room", "description")
  1008. assert result is None
  1009. @pytest.mark.asyncio
  1010. async def test_create_room_sets_room_kind(self):
  1011. adapter = _make_adapter()
  1012. adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "group-room-1"}}))
  1013. await adapter.create_room("test-room")
  1014. assert adapter._room_kinds["group-room-1"] == "ROOM_KIND_GROUP"
  1015. assert adapter._room_names["group-room-1"] == "test-room"
  1016. # ── User lookup (mocked RPC) ──────────────────────────────────────────────
  1017. class TestUserLookup:
  1018. """Test get_user, list_users, batch_get_users with caching."""
  1019. @pytest.mark.asyncio
  1020. async def test_get_user_success(self):
  1021. adapter = _make_adapter()
  1022. adapter._rpc = AsyncMock(
  1023. return_value=(200, {"user": {"id": "u1", "login": "alice", "displayName": "Alice"}})
  1024. )
  1025. result = await adapter.get_user("u1")
  1026. assert result is not None
  1027. assert result["id"] == "u1"
  1028. assert result["login"] == "alice"
  1029. @pytest.mark.asyncio
  1030. async def test_get_user_caches(self):
  1031. adapter = _make_adapter()
  1032. adapter._rpc = AsyncMock(
  1033. return_value=(200, {"user": {"id": "u1", "login": "alice"}})
  1034. )
  1035. await adapter.get_user("u1")
  1036. # Second call should use cache — no second RPC
  1037. await adapter.get_user("u1")
  1038. assert adapter._rpc.call_count == 1
  1039. assert "u1" in adapter._user_cache
  1040. @pytest.mark.asyncio
  1041. async def test_get_user_empty_id(self):
  1042. adapter = _make_adapter()
  1043. adapter._rpc = AsyncMock()
  1044. result = await adapter.get_user("")
  1045. assert result is None
  1046. adapter._rpc.assert_not_called()
  1047. @pytest.mark.asyncio
  1048. async def test_get_user_failure(self):
  1049. adapter = _make_adapter()
  1050. adapter._rpc = AsyncMock(return_value=(404, {}))
  1051. result = await adapter.get_user("u1")
  1052. assert result is None
  1053. @pytest.mark.asyncio
  1054. async def test_get_user_exception(self):
  1055. adapter = _make_adapter()
  1056. adapter._rpc = AsyncMock(side_effect=Exception("boom"))
  1057. result = await adapter.get_user("u1")
  1058. assert result is None
  1059. @pytest.mark.asyncio
  1060. async def test_list_users_success(self):
  1061. adapter = _make_adapter()
  1062. adapter._rpc = AsyncMock(
  1063. return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
  1064. )
  1065. result = await adapter.list_users()
  1066. assert len(result) == 2
  1067. assert result[0]["id"] == "u1"
  1068. # Should cache all returned users
  1069. assert "u1" in adapter._user_cache
  1070. assert "u2" in adapter._user_cache
  1071. @pytest.mark.asyncio
  1072. async def test_list_users_failure(self):
  1073. adapter = _make_adapter()
  1074. adapter._rpc = AsyncMock(return_value=(500, {}))
  1075. result = await adapter.list_users()
  1076. assert result == []
  1077. @pytest.mark.asyncio
  1078. async def test_batch_get_users_success(self):
  1079. adapter = _make_adapter()
  1080. adapter._rpc = AsyncMock(
  1081. return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
  1082. )
  1083. result = await adapter.batch_get_users(["u1", "u2"])
  1084. assert len(result) == 2
  1085. # Should cache results
  1086. assert "u1" in adapter._user_cache
  1087. assert "u2" in adapter._user_cache
  1088. @pytest.mark.asyncio
  1089. async def test_batch_get_users_empty_list(self):
  1090. adapter = _make_adapter()
  1091. adapter._rpc = AsyncMock()
  1092. result = await adapter.batch_get_users([])
  1093. assert result == []
  1094. adapter._rpc.assert_not_called()
  1095. @pytest.mark.asyncio
  1096. async def test_batch_get_users_uses_cache(self):
  1097. adapter = _make_adapter()
  1098. # Pre-populate cache
  1099. adapter._user_cache["u1"] = {"id": "u1", "login": "alice"}
  1100. adapter._rpc = AsyncMock(
  1101. return_value=(200, {"users": [{"id": "u2"}]})
  1102. )
  1103. result = await adapter.batch_get_users(["u1", "u2"])
  1104. assert len(result) == 2
  1105. # Should have only fetched u2 from server
  1106. call_args = adapter._rpc.call_args
  1107. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1108. assert body["userIds"] == ["u2"]
  1109. @pytest.mark.asyncio
  1110. async def test_batch_get_users_all_cached(self):
  1111. adapter = _make_adapter()
  1112. adapter._user_cache["u1"] = {"id": "u1"}
  1113. adapter._rpc = AsyncMock()
  1114. result = await adapter.batch_get_users(["u1"])
  1115. assert len(result) == 1
  1116. adapter._rpc.assert_not_called()
  1117. @pytest.mark.asyncio
  1118. async def test_batch_get_users_failure(self):
  1119. adapter = _make_adapter()
  1120. adapter._rpc = AsyncMock(return_value=(500, {}))
  1121. result = await adapter.batch_get_users(["u1"])
  1122. assert result == []
  1123. # ── Presence and custom status (mocked RPC) ──────────────────────────────
  1124. class TestPresenceAndStatus:
  1125. """Test set_presence, set_custom_status, clear_custom_status."""
  1126. @pytest.mark.asyncio
  1127. async def test_set_presence_online(self):
  1128. adapter = _make_adapter()
  1129. adapter._rpc = AsyncMock(return_value=(200, {}))
  1130. result = await adapter.set_presence("online")
  1131. assert result is True
  1132. call_args = adapter._rpc.call_args
  1133. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1134. assert body["status"] == 1
  1135. @pytest.mark.asyncio
  1136. async def test_set_presence_dnd(self):
  1137. adapter = _make_adapter()
  1138. adapter._rpc = AsyncMock(return_value=(200, {}))
  1139. result = await adapter.set_presence("dnd")
  1140. assert result is True
  1141. call_args = adapter._rpc.call_args
  1142. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1143. assert body["status"] == 3
  1144. @pytest.mark.asyncio
  1145. async def test_set_presence_away(self):
  1146. adapter = _make_adapter()
  1147. adapter._rpc = AsyncMock(return_value=(200, {}))
  1148. result = await adapter.set_presence("away")
  1149. assert result is True
  1150. call_args = adapter._rpc.call_args
  1151. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1152. assert body["status"] == 2
  1153. @pytest.mark.asyncio
  1154. async def test_set_presence_do_not_disturb(self):
  1155. adapter = _make_adapter()
  1156. adapter._rpc = AsyncMock(return_value=(200, {}))
  1157. result = await adapter.set_presence("do_not_disturb")
  1158. assert result is True
  1159. call_args = adapter._rpc.call_args
  1160. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1161. assert body["status"] == 3
  1162. @pytest.mark.asyncio
  1163. async def test_set_presence_unknown(self):
  1164. adapter = _make_adapter()
  1165. adapter._rpc = AsyncMock(return_value=(200, {}))
  1166. result = await adapter.set_presence("invisible")
  1167. assert result is False
  1168. adapter._rpc.assert_not_called()
  1169. @pytest.mark.asyncio
  1170. async def test_set_presence_failure(self):
  1171. adapter = _make_adapter()
  1172. adapter._rpc = AsyncMock(return_value=(500, {}))
  1173. result = await adapter.set_presence("online")
  1174. assert result is False
  1175. @pytest.mark.asyncio
  1176. async def test_set_custom_status_success(self):
  1177. adapter = _make_adapter()
  1178. adapter._rpc = AsyncMock(return_value=(200, {}))
  1179. result = await adapter.set_custom_status("Processing...")
  1180. assert result is True
  1181. call_args = adapter._rpc.call_args
  1182. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1183. assert body["status"] == "Processing..."
  1184. @pytest.mark.asyncio
  1185. async def test_set_custom_status_empty(self):
  1186. adapter = _make_adapter()
  1187. adapter._rpc = AsyncMock()
  1188. result = await adapter.set_custom_status("")
  1189. assert result is False
  1190. adapter._rpc.assert_not_called()
  1191. @pytest.mark.asyncio
  1192. async def test_set_custom_status_truncates(self):
  1193. adapter = _make_adapter()
  1194. adapter._rpc = AsyncMock(return_value=(200, {}))
  1195. long_status = "A" * 200
  1196. await adapter.set_custom_status(long_status)
  1197. call_args = adapter._rpc.call_args
  1198. body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
  1199. assert len(body["status"]) == 100
  1200. @pytest.mark.asyncio
  1201. async def test_set_custom_status_failure(self):
  1202. adapter = _make_adapter()
  1203. adapter._rpc = AsyncMock(return_value=(500, {}))
  1204. result = await adapter.set_custom_status("test")
  1205. assert result is False
  1206. @pytest.mark.asyncio
  1207. async def test_clear_custom_status_success(self):
  1208. adapter = _make_adapter()
  1209. adapter._rpc = AsyncMock(return_value=(200, {}))
  1210. result = await adapter.clear_custom_status()
  1211. assert result is True
  1212. @pytest.mark.asyncio
  1213. async def test_clear_custom_status_failure(self):
  1214. adapter = _make_adapter()
  1215. adapter._rpc = AsyncMock(return_value=(500, {}))
  1216. result = await adapter.clear_custom_status()
  1217. assert result is False
  1218. # ── Message dispatch (mocked) ─────────────────────────────────────────────
  1219. class TestMessageDispatch:
  1220. """Test _dispatch_message with mocked handler.
  1221. The base class ``handle_message`` does complex session management and
  1222. spawns background tasks, so we mock it to verify _dispatch_message calls
  1223. it with the right MessageEvent.
  1224. """
  1225. @pytest.mark.asyncio
  1226. async def test_dispatch_message_calls_handler(self):
  1227. adapter = _make_adapter()
  1228. adapter._message_handler = AsyncMock()
  1229. adapter.handle_message = AsyncMock()
  1230. adapter._rpc = AsyncMock(return_value=(200, {}))
  1231. adapter._room_names["room1"] = "General"
  1232. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1233. adapter._require_mention = False # don't require mention for this test
  1234. msg = {
  1235. "id": "evt1",
  1236. "roomId": "room1",
  1237. "actorId": "user1",
  1238. "body": "Hello bot",
  1239. "createdAt": "",
  1240. "actorLogin": "alice",
  1241. "actorDisplayName": "Alice",
  1242. "thread": {},
  1243. }
  1244. await adapter._dispatch_message(msg, "room1")
  1245. assert adapter.handle_message.called
  1246. event = adapter.handle_message.call_args.args[0]
  1247. assert event.text == "Hello bot"
  1248. assert event.message_id == "evt1"
  1249. @pytest.mark.asyncio
  1250. async def test_dispatch_message_self_echo_suppressed(self):
  1251. adapter = _make_adapter()
  1252. adapter._message_handler = AsyncMock()
  1253. adapter.handle_message = AsyncMock()
  1254. adapter._rpc = AsyncMock(return_value=(200, {}))
  1255. msg = {
  1256. "id": "evt1",
  1257. "roomId": "room1",
  1258. "actorId": "bot-user-id", # matches adapter._user_id
  1259. "body": "My own message",
  1260. "createdAt": "",
  1261. "actorLogin": "hermes_bot",
  1262. "actorDisplayName": "Hermes Bot",
  1263. "thread": {},
  1264. }
  1265. await adapter._dispatch_message(msg, "room1")
  1266. adapter.handle_message.assert_not_called()
  1267. @pytest.mark.asyncio
  1268. async def test_dispatch_message_empty_body_skipped(self):
  1269. adapter = _make_adapter()
  1270. adapter._message_handler = AsyncMock()
  1271. adapter.handle_message = AsyncMock()
  1272. adapter._rpc = AsyncMock(return_value=(200, {}))
  1273. msg = {
  1274. "id": "evt1",
  1275. "roomId": "room1",
  1276. "actorId": "user1",
  1277. "body": "",
  1278. "createdAt": "",
  1279. "thread": {},
  1280. }
  1281. await adapter._dispatch_message(msg, "room1")
  1282. adapter.handle_message.assert_not_called()
  1283. @pytest.mark.asyncio
  1284. async def test_dispatch_message_no_handler(self):
  1285. adapter = _make_adapter()
  1286. adapter._message_handler = None
  1287. adapter._rpc = AsyncMock(return_value=(200, {}))
  1288. # Should not raise
  1289. msg = {
  1290. "id": "evt1",
  1291. "roomId": "room1",
  1292. "actorId": "user1",
  1293. "body": "Hello",
  1294. "createdAt": "",
  1295. "thread": {},
  1296. }
  1297. await adapter._dispatch_message(msg, "room1")
  1298. @pytest.mark.asyncio
  1299. async def test_dispatch_message_mark_room_read_called(self):
  1300. adapter = _make_adapter()
  1301. adapter._message_handler = AsyncMock()
  1302. adapter.handle_message = AsyncMock()
  1303. adapter._rpc = AsyncMock(return_value=(200, {}))
  1304. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1305. adapter._require_mention = False # don't require mention for this test
  1306. msg = {
  1307. "id": "evt1",
  1308. "roomId": "room1",
  1309. "actorId": "user1",
  1310. "body": "Hello",
  1311. "createdAt": "",
  1312. "actorLogin": "alice",
  1313. "thread": {},
  1314. }
  1315. await adapter._dispatch_message(msg, "room1")
  1316. # mark_room_as_read and dismiss_all_notifications should have been called
  1317. # via _rpc — check for MarkRoomAsRead and DismissAllNotifications paths
  1318. rpc_paths = [c.args[0] for c in adapter._rpc.call_args_list if len(c.args) > 0]
  1319. assert any("MarkRoomAsRead" in p for p in rpc_paths)
  1320. assert any("DismissAllNotifications" in p for p in rpc_paths)
  1321. @pytest.mark.asyncio
  1322. async def test_dispatch_message_dm_always_responds(self):
  1323. adapter = _make_adapter()
  1324. adapter._message_handler = AsyncMock()
  1325. adapter.handle_message = AsyncMock()
  1326. adapter._rpc = AsyncMock(return_value=(200, {}))
  1327. adapter._room_kinds["room1"] = "ROOM_KIND_DM"
  1328. adapter._require_mention = True # even with require_mention, DMs respond
  1329. msg = {
  1330. "id": "evt1",
  1331. "roomId": "room1",
  1332. "actorId": "user1",
  1333. "body": "Hello without mention",
  1334. "createdAt": "",
  1335. "actorLogin": "alice",
  1336. "thread": {},
  1337. }
  1338. await adapter._dispatch_message(msg, "room1")
  1339. assert adapter.handle_message.called
  1340. @pytest.mark.asyncio
  1341. async def test_dispatch_message_require_mention_no_mention_skipped(self):
  1342. adapter = _make_adapter()
  1343. adapter._message_handler = AsyncMock()
  1344. adapter.handle_message = AsyncMock()
  1345. adapter._rpc = AsyncMock(return_value=(200, {}))
  1346. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1347. adapter._require_mention = True
  1348. msg = {
  1349. "id": "evt1",
  1350. "roomId": "room1",
  1351. "actorId": "user1",
  1352. "body": "Hello without mention",
  1353. "createdAt": "",
  1354. "actorLogin": "alice",
  1355. "thread": {},
  1356. }
  1357. await adapter._dispatch_message(msg, "room1")
  1358. adapter.handle_message.assert_not_called()
  1359. @pytest.mark.asyncio
  1360. async def test_dispatch_message_with_mention(self):
  1361. adapter = _make_adapter()
  1362. adapter._message_handler = AsyncMock()
  1363. adapter.handle_message = AsyncMock()
  1364. adapter._rpc = AsyncMock(return_value=(200, {}))
  1365. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1366. adapter._require_mention = True
  1367. msg = {
  1368. "id": "evt1",
  1369. "roomId": "room1",
  1370. "actorId": "user1",
  1371. "body": "@hermes_bot do something",
  1372. "createdAt": "",
  1373. "actorLogin": "alice",
  1374. "thread": {},
  1375. }
  1376. await adapter._dispatch_message(msg, "room1")
  1377. assert adapter.handle_message.called
  1378. @pytest.mark.asyncio
  1379. async def test_dispatch_message_with_thread(self):
  1380. adapter = _make_adapter()
  1381. adapter._message_handler = AsyncMock()
  1382. adapter.handle_message = AsyncMock()
  1383. adapter._rpc = AsyncMock(return_value=(200, {}))
  1384. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1385. adapter._require_mention = False # don't require mention for this test
  1386. msg = {
  1387. "id": "evt1",
  1388. "roomId": "room1",
  1389. "actorId": "user1",
  1390. "body": "Hello",
  1391. "createdAt": "",
  1392. "actorLogin": "alice",
  1393. "thread": {"threadRootEventId": "thread-root-123"},
  1394. }
  1395. await adapter._dispatch_message(msg, "room1")
  1396. assert adapter.handle_message.called
  1397. # Verify the event was created with the thread_id
  1398. event = adapter.handle_message.call_args.args[0]
  1399. assert event.source.thread_id == "thread-root-123"
  1400. @pytest.mark.asyncio
  1401. async def test_dispatch_message_strips_mention_prefix(self):
  1402. adapter = _make_adapter()
  1403. adapter._message_handler = AsyncMock()
  1404. adapter.handle_message = AsyncMock()
  1405. adapter._rpc = AsyncMock(return_value=(200, {}))
  1406. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1407. adapter._require_mention = True
  1408. msg = {
  1409. "id": "evt1",
  1410. "roomId": "room1",
  1411. "actorId": "user1",
  1412. "body": "@hermes_bot please help",
  1413. "createdAt": "",
  1414. "actorLogin": "alice",
  1415. "thread": {},
  1416. }
  1417. await adapter._dispatch_message(msg, "room1")
  1418. event = adapter.handle_message.call_args.args[0]
  1419. # The mention prefix should be stripped
  1420. assert not event.text.startswith("@hermes_bot")
  1421. assert "please help" in event.text
  1422. # ── Processing lifecycle hooks ─────────────────────────────────────────────
  1423. class TestProcessingLifecycle:
  1424. """Test on_processing_start and on_processing_complete reaction hooks."""
  1425. @pytest.mark.asyncio
  1426. async def test_on_processing_start_adds_eyes(self):
  1427. adapter = _make_adapter()
  1428. adapter.send_reaction = AsyncMock(return_value=True)
  1429. event = MagicMock()
  1430. event.message_id = "evt1"
  1431. event.source = MagicMock()
  1432. event.source.chat_id = "room1"
  1433. event.raw_message = {}
  1434. await adapter.on_processing_start(event)
  1435. adapter.send_reaction.assert_called_once_with("room1", "evt1", "👀")
  1436. @pytest.mark.asyncio
  1437. async def test_on_processing_complete_success(self):
  1438. from gateway.platforms.base import ProcessingOutcome
  1439. adapter = _make_adapter()
  1440. adapter.send_reaction = AsyncMock(return_value=True)
  1441. adapter.remove_reaction = AsyncMock(return_value=True)
  1442. event = MagicMock()
  1443. event.message_id = "evt1"
  1444. event.source = MagicMock()
  1445. event.source.chat_id = "room1"
  1446. event.raw_message = {}
  1447. await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
  1448. adapter.remove_reaction.assert_called_once_with("room1", "evt1", "👀")
  1449. adapter.send_reaction.assert_called_once_with("room1", "evt1", "✅")
  1450. @pytest.mark.asyncio
  1451. async def test_on_processing_complete_failure(self):
  1452. from gateway.platforms.base import ProcessingOutcome
  1453. adapter = _make_adapter()
  1454. adapter.send_reaction = AsyncMock(return_value=True)
  1455. adapter.remove_reaction = AsyncMock(return_value=True)
  1456. event = MagicMock()
  1457. event.message_id = "evt1"
  1458. event.source = MagicMock()
  1459. event.source.chat_id = "room1"
  1460. event.raw_message = {}
  1461. await adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
  1462. adapter.send_reaction.assert_called_once_with("room1", "evt1", "❌")
  1463. @pytest.mark.asyncio
  1464. async def test_reactions_disabled(self, monkeypatch):
  1465. adapter = _make_adapter()
  1466. monkeypatch.setenv("CHATTO_REACTIONS", "false")
  1467. adapter.send_reaction = AsyncMock()
  1468. event = MagicMock()
  1469. event.message_id = "evt1"
  1470. event.source = MagicMock()
  1471. event.source.chat_id = "room1"
  1472. event.raw_message = {}
  1473. await adapter.on_processing_start(event)
  1474. adapter.send_reaction.assert_not_called()
  1475. # ── Attachment upload (mocked RPC) ────────────────────────────────────────
  1476. class TestAttachmentUpload:
  1477. """Test the chunked asset upload flow."""
  1478. @pytest.mark.asyncio
  1479. async def test_upload_asset_success(self):
  1480. adapter = _make_adapter()
  1481. # Create a temporary file
  1482. with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
  1483. f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
  1484. tmp_path = f.name
  1485. try:
  1486. rpc_responses = [
  1487. (200, {"upload": {"id": "upload-1"}}), # CreateUpload
  1488. (200, {}), # UploadChunk
  1489. (200, {"asset": {"id": "asset-1"}}), # CompleteUpload
  1490. ]
  1491. adapter._rpc = AsyncMock(side_effect=rpc_responses)
  1492. asset_id = await adapter._upload_asset("room1", tmp_path)
  1493. assert asset_id == "asset-1"
  1494. assert adapter._rpc.call_count == 3
  1495. # Verify the first call was CreateUpload
  1496. first_call = adapter._rpc.call_args_list[0]
  1497. path = first_call.args[0]
  1498. assert "CreateUpload" in path
  1499. body = first_call.args[1]
  1500. assert body["roomId"] == "room1"
  1501. assert "sha256" in body
  1502. # Verify last call was CompleteUpload
  1503. last_call = adapter._rpc.call_args_list[-1]
  1504. path = last_call.args[0]
  1505. assert "CompleteUpload" in path
  1506. finally:
  1507. os.unlink(tmp_path)
  1508. @pytest.mark.asyncio
  1509. async def test_upload_asset_create_fails(self):
  1510. adapter = _make_adapter()
  1511. with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
  1512. f.write(b"test data")
  1513. tmp_path = f.name
  1514. try:
  1515. adapter._rpc = AsyncMock(return_value=(500, {"error": "fail"}))
  1516. result = await adapter._upload_asset("room1", tmp_path)
  1517. assert result is None
  1518. finally:
  1519. os.unlink(tmp_path)
  1520. @pytest.mark.asyncio
  1521. async def test_upload_asset_empty_file(self):
  1522. adapter = _make_adapter()
  1523. with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
  1524. f.write(b"")
  1525. tmp_path = f.name
  1526. try:
  1527. adapter._rpc = AsyncMock()
  1528. result = await adapter._upload_asset("room1", tmp_path)
  1529. assert result is None
  1530. adapter._rpc.assert_not_called()
  1531. finally:
  1532. os.unlink(tmp_path)
  1533. @pytest.mark.asyncio
  1534. async def test_send_image_file_success(self):
  1535. adapter = _make_adapter()
  1536. with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
  1537. f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
  1538. tmp_path = f.name
  1539. try:
  1540. rpc_responses = [
  1541. (200, {"upload": {"id": "upload-1"}}), # CreateUpload
  1542. (200, {}), # UploadChunk
  1543. (200, {"asset": {"id": "asset-1"}}), # CompleteUpload
  1544. (200, {"message": {"id": "msg-1"}}), # CreateMessage
  1545. ]
  1546. adapter._rpc = AsyncMock(side_effect=rpc_responses)
  1547. result = await adapter.send_image_file("room1", tmp_path, caption="Test image")
  1548. assert result.success is True
  1549. assert result.message_id == "msg-1"
  1550. # Verify the CreateMessage call had attachmentAssetIds
  1551. create_msg_call = adapter._rpc.call_args_list[-1]
  1552. body = create_msg_call.args[1]
  1553. assert body["attachmentAssetIds"] == ["asset-1"]
  1554. assert body["body"] == "Test image"
  1555. finally:
  1556. os.unlink(tmp_path)
  1557. @pytest.mark.asyncio
  1558. async def test_send_image_file_upload_fails_fallback(self):
  1559. adapter = _make_adapter()
  1560. with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
  1561. f.write(b"\x89PNG" + b"\x00" * 100)
  1562. tmp_path = f.name
  1563. try:
  1564. # Upload fails, then fallback send succeeds
  1565. rpc_responses = [
  1566. (500, {"error": "fail"}), # CreateUpload fails
  1567. (200, {"message": {"id": "fallback-msg"}}), # Fallback send
  1568. ]
  1569. adapter._rpc = AsyncMock(side_effect=rpc_responses)
  1570. result = await adapter.send_image_file("room1", tmp_path)
  1571. assert result.success is True
  1572. assert result.message_id == "fallback-msg"
  1573. finally:
  1574. os.unlink(tmp_path)
  1575. # ── Seen tracking ─────────────────────────────────────────────────────────
  1576. class TestSeenTracking:
  1577. """Test _mark_seen, _is_seen, and the _SEEN_CAP."""
  1578. def test_mark_and_check_seen(self):
  1579. adapter = _make_adapter()
  1580. adapter._mark_seen("room1", "evt1")
  1581. assert adapter._is_seen("room1", "evt1") is True
  1582. assert adapter._is_seen("room1", "evt2") is False
  1583. assert adapter._is_seen("room2", "evt1") is False
  1584. def test_mark_seen_evicts_old_beyond_cap(self):
  1585. adapter = _make_adapter()
  1586. # Add more than _SEEN_CAP events
  1587. for i in range(_SEEN_CAP + 10):
  1588. adapter._mark_seen("room1", f"evt{i}")
  1589. # The first events should have been evicted
  1590. assert adapter._is_seen("room1", "evt0") is False
  1591. # The most recent should still be there
  1592. assert adapter._is_seen("room1", f"evt{_SEEN_CAP + 9}") is True
  1593. # Total should not exceed cap
  1594. assert len(adapter._seen["room1"]) <= _SEEN_CAP
  1595. # ── WebSocket URL building ────────────────────────────────────────────────
  1596. class TestWebSocketURL:
  1597. """Test _websocket_url conversion."""
  1598. def test_https_to_wss(self):
  1599. adapter = _make_adapter()
  1600. adapter._base_url = "https://chat.example.com"
  1601. url = adapter._websocket_url()
  1602. assert url.startswith("wss://")
  1603. assert "/api/realtime" in url
  1604. def test_http_to_ws(self):
  1605. adapter = _make_adapter()
  1606. adapter._base_url = "http://localhost:8080"
  1607. url = adapter._websocket_url()
  1608. assert url.startswith("ws://")
  1609. assert "/api/realtime" in url
  1610. def test_websocket_url_with_path(self):
  1611. adapter = _make_adapter()
  1612. adapter._base_url = "https://chat.example.com/subpath"
  1613. url = adapter._websocket_url()
  1614. assert "/subpath/api/realtime" in url
  1615. def test_websocket_url_invalid_scheme(self):
  1616. adapter = _make_adapter()
  1617. adapter._base_url = "ftp://chat.example.com"
  1618. with pytest.raises(ValueError, match="must use http"):
  1619. adapter._websocket_url()
  1620. # ── Get chat info ─────────────────────────────────────────────────────────
  1621. class TestGetChatInfo:
  1622. """Test get_chat_info."""
  1623. @pytest.mark.asyncio
  1624. async def test_get_chat_info_group(self):
  1625. adapter = _make_adapter()
  1626. adapter._room_names["room1"] = "General"
  1627. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1628. info = await adapter.get_chat_info("room1")
  1629. assert info["name"] == "General"
  1630. assert info["type"] == "group"
  1631. @pytest.mark.asyncio
  1632. async def test_get_chat_info_dm(self):
  1633. adapter = _make_adapter()
  1634. adapter._room_names["room2"] = "Alice"
  1635. adapter._room_kinds["room2"] = "ROOM_KIND_DM"
  1636. info = await adapter.get_chat_info("room2")
  1637. assert info["name"] == "Alice"
  1638. assert info["type"] == "dm"
  1639. @pytest.mark.asyncio
  1640. async def test_get_chat_info_unknown_room(self):
  1641. adapter = _make_adapter()
  1642. info = await adapter.get_chat_info("unknown-room")
  1643. assert info["name"] == "unknown-room"
  1644. assert info["type"] == "group" # default
  1645. # ── Handle projection event ──────────────────────────────────────────────
  1646. class TestHandleProjectionEvent:
  1647. """Test _handle_projection_event and _handle_timeline_event_upsert."""
  1648. @pytest.mark.asyncio
  1649. async def test_handle_projection_event_updates_cursor(self):
  1650. adapter = _make_adapter()
  1651. # Build a projection event with a resume cursor
  1652. proj = _encode_field_string(4, "new-cursor-123")
  1653. await adapter._handle_projection_event(proj)
  1654. assert adapter._resume_cursor == "new-cursor-123"
  1655. @pytest.mark.asyncio
  1656. async def test_handle_projection_event_empty_data(self):
  1657. adapter = _make_adapter()
  1658. await adapter._handle_projection_event(b"")
  1659. # Should not raise, cursor unchanged
  1660. @pytest.mark.asyncio
  1661. async def test_handle_timeline_event_dedup(self):
  1662. adapter = _make_adapter()
  1663. adapter._message_handler = AsyncMock()
  1664. adapter.handle_message = AsyncMock()
  1665. adapter._rpc = AsyncMock(return_value=(200, {}))
  1666. adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
  1667. adapter._require_mention = False # don't require mention for this test
  1668. op = {
  1669. "room_id": "room1",
  1670. "event": {
  1671. "id": "evt-dedup",
  1672. "messagePosted": {
  1673. "message": {
  1674. "id": "m1",
  1675. "roomId": "room1",
  1676. "actorId": "user1",
  1677. "body": "Hello",
  1678. "createdAt": "",
  1679. "thread": {},
  1680. }
  1681. },
  1682. },
  1683. }
  1684. # First call should dispatch
  1685. await adapter._handle_timeline_event_upsert(op)
  1686. assert adapter.handle_message.called
  1687. # Reset mock
  1688. adapter.handle_message.reset_mock()
  1689. # Second call should be deduped
  1690. await adapter._handle_timeline_event_upsert(op)
  1691. adapter.handle_message.assert_not_called()
  1692. @pytest.mark.asyncio
  1693. async def test_handle_timeline_event_no_message_posted(self):
  1694. adapter = _make_adapter()
  1695. adapter._message_handler = AsyncMock()
  1696. op = {
  1697. "room_id": "room1",
  1698. "event": {
  1699. "id": "evt1",
  1700. "messagePosted": None,
  1701. },
  1702. }
  1703. await adapter._handle_timeline_event_upsert(op)
  1704. adapter._message_handler.assert_not_called()
  1705. @pytest.mark.asyncio
  1706. async def test_handle_timeline_event_empty_event(self):
  1707. adapter = _make_adapter()
  1708. adapter._message_handler = AsyncMock()
  1709. op = {"room_id": "room1", "event": {}}
  1710. await adapter._handle_timeline_event_upsert(op)
  1711. adapter._message_handler.assert_not_called()
  1712. # ── Handle transient event ────────────────────────────────────────────────
  1713. class TestHandleTransientEvent:
  1714. """Test _handle_transient_event."""
  1715. @pytest.mark.asyncio
  1716. async def test_handle_transient_mention(self):
  1717. adapter = _make_adapter()
  1718. # Build a transient event envelope with a mention notification
  1719. mention_inner = _encode_field_string(1, "room1") + _encode_field_string(2, "evt1")
  1720. envelope = _encode_field_string(1, "env1") + _encode_submessage(88, mention_inner)
  1721. # Should not raise
  1722. await adapter._handle_transient_event(envelope)
  1723. @pytest.mark.asyncio
  1724. async def test_handle_transient_dm(self):
  1725. adapter = _make_adapter()
  1726. dm_inner = _encode_field_string(1, "room2") + _encode_field_string(2, "evt2")
  1727. envelope = _encode_field_string(1, "env2") + _encode_submessage(89, dm_inner)
  1728. await adapter._handle_transient_event(envelope)
  1729. @pytest.mark.asyncio
  1730. async def test_handle_transient_empty(self):
  1731. adapter = _make_adapter()
  1732. await adapter._handle_transient_event(b"")
  1733. # ── Disconnect ────────────────────────────────────────────────────────────
  1734. class TestDisconnect:
  1735. """Test disconnect cleanup."""
  1736. @pytest.mark.asyncio
  1737. async def test_disconnect_cancels_typing_tasks(self):
  1738. adapter = _make_adapter()
  1739. adapter._rpc = AsyncMock(return_value=(200, {}))
  1740. adapter._ws_active = False # avoid websocket teardown
  1741. adapter._liveness_task = None
  1742. adapter._ws_task = None
  1743. await adapter.send_typing("room1")
  1744. assert "room1" in adapter._typing_tasks
  1745. await adapter.disconnect()
  1746. assert "room1" not in adapter._typing_tasks
  1747. @pytest.mark.asyncio
  1748. async def test_disconnect_clears_token(self):
  1749. adapter = _make_adapter()
  1750. adapter._rpc = AsyncMock(return_value=(200, {}))
  1751. adapter._ws_active = False
  1752. adapter._liveness_task = None
  1753. adapter._ws_task = None
  1754. assert adapter._token is not None
  1755. await adapter.disconnect()
  1756. assert adapter._token is None