test_client.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. """Tests for ChattoClient.
  2. Since the client now goes through generated ConnectRPC service stubs, tests
  3. mock at the service-client method level with ``AsyncMock`` rather than at the
  4. HTTP transport layer. This exercises the request-building and response-parsing
  5. paths without any real network traffic.
  6. """
  7. from __future__ import annotations
  8. import datetime as dt
  9. from unittest.mock import AsyncMock
  10. import pytest
  11. from chattolib._pb.chatto.admin.v1 import (
  12. diagnostics_pb2,
  13. room_layout_pb2,
  14. )
  15. from chattolib._pb.chatto.admin.v1 import (
  16. server_pb2 as admin_server_pb2,
  17. )
  18. from chattolib._pb.chatto.api.v1 import (
  19. account_pb2,
  20. asset_uploads_pb2,
  21. attachments_pb2,
  22. messages_pb2,
  23. notification_preferences_pb2,
  24. notifications_pb2,
  25. presence_pb2,
  26. push_notifications_pb2,
  27. reactions_pb2,
  28. read_state_pb2,
  29. roles_pb2,
  30. room_directory_pb2,
  31. room_timeline_pb2,
  32. rooms_pb2,
  33. threads_pb2,
  34. user_service_pb2,
  35. viewer_pb2,
  36. voice_calls_pb2,
  37. )
  38. from chattolib._pb.chatto.discovery.v1 import server_pb2 as discovery_server_pb2
  39. from chattolib.client import ChattoClient
  40. from chattolib.types import (
  41. ImageFitMode,
  42. ImageTransformOptions,
  43. NotificationLevel,
  44. PresenceStatus,
  45. RoomKind,
  46. ThreadRootAuthorFilter,
  47. TimeFormat,
  48. )
  49. BASE = "https://chat.chatto.run"
  50. @pytest.fixture
  51. def client():
  52. return ChattoClient(token="test-token", base_url=BASE)
  53. def _mock_method(client: ChattoClient, service: str, method: str, response):
  54. """Replace a service-client method with an AsyncMock returning ``response``."""
  55. svc = getattr(client._svc, service)
  56. setattr(svc, method, AsyncMock(return_value=response))
  57. return getattr(svc, method)
  58. # --- Server discovery ---------------------------------------------------
  59. async def test_get_server(client):
  60. resp = discovery_server_pb2.GetServerResponse()
  61. resp.profile.name = "Chatto HQ"
  62. resp.profile.version = "0.4.2"
  63. resp.login.direct_registration_enabled = True
  64. resp.login.authorize_url = "/oauth"
  65. _mock_method(client, "server_discovery", "get_server", resp)
  66. async with client:
  67. profile, login = await client.get_server()
  68. assert profile.name == "Chatto HQ"
  69. assert profile.version == "0.4.2"
  70. assert login.direct_registration_enabled is True
  71. assert login.authorize_url == "/oauth"
  72. async def test_list_neighborhood_servers(client):
  73. resp = discovery_server_pb2.ListNeighborhoodServersResponse()
  74. s1 = resp.servers.add()
  75. s1.origin = "https://neighbor.example"
  76. s1.profile.name = "Neighbor"
  77. s1.profile.version = "0.5.0"
  78. s1.profile.logo_url = "/logos/neighbor.png"
  79. s1.direct_neighbor = True
  80. s2 = resp.servers.add()
  81. s2.origin = "https://referred.example"
  82. s2.profile.name = "Referred"
  83. s2.direct_neighbor = False
  84. s2.recommended_by_origins.append("https://neighbor.example")
  85. resp.refreshed_at.FromJsonString("2026-09-26T12:00:00Z")
  86. mocked = _mock_method(client, "server_discovery", "list_neighborhood_servers", resp)
  87. async with client:
  88. page = await client.list_neighborhood_servers()
  89. mocked.assert_awaited_once()
  90. assert [s.origin for s in page.servers] == [
  91. "https://neighbor.example",
  92. "https://referred.example",
  93. ]
  94. assert page.servers[0].profile is not None
  95. assert page.servers[0].profile.name == "Neighbor"
  96. assert page.servers[0].profile.logo_url == "/logos/neighbor.png"
  97. assert page.servers[0].direct_neighbor is True
  98. assert page.servers[1].direct_neighbor is False
  99. assert page.servers[1].recommended_by_origins == ["https://neighbor.example"]
  100. assert page.refreshed_at == dt.datetime(2026, 9, 26, 12, 0, tzinfo=dt.UTC)
  101. # --- Viewer -------------------------------------------------------------
  102. async def test_me(client):
  103. resp = viewer_pb2.GetViewerResponse()
  104. resp.user.profile.id = "u1"
  105. resp.user.profile.login = "alice"
  106. resp.user.profile.display_name = "Alice"
  107. resp.user.profile.presence_status = "PRESENCE_STATUS_ONLINE"
  108. resp.user.has_verified_email = True
  109. resp.user.settings.timezone = "UTC"
  110. resp.user.settings.time_format = "TIME_FORMAT_24_HOUR"
  111. resp.user.has_password = True
  112. _mock_method(client, "viewer", "get_viewer", resp)
  113. async with client:
  114. user = await client.me()
  115. assert user.id == "u1"
  116. assert user.login == "alice"
  117. assert user.display_name == "Alice"
  118. assert user.presence_status is PresenceStatus.ONLINE
  119. # --- Room directory ----------------------------------------------------
  120. async def test_list_rooms(client):
  121. resp = room_directory_pb2.ListRoomsResponse()
  122. entry = resp.rooms.add()
  123. entry.room.id = "r1"
  124. entry.room.kind = "ROOM_KIND_CHANNEL"
  125. entry.room.name = "general"
  126. entry.room.group_id = "g1"
  127. entry.viewer_state.is_member = True
  128. entry.viewer_state.has_unread = False
  129. _mock_method(client, "room_directory", "list_rooms", resp)
  130. async with client:
  131. rooms = await client.list_rooms()
  132. assert len(rooms) == 1
  133. assert rooms[0].room is not None
  134. assert rooms[0].room.name == "general"
  135. assert rooms[0].room.kind is RoomKind.CHANNEL
  136. assert rooms[0].viewer_state.is_member is True
  137. async def test_list_rooms_dm_keeps_deleted_participants(client):
  138. # Since Chatto 0.5.0-beta.10, a DM's member_user_ids keeps participants
  139. # whose accounts were deleted; the client must surface the full list
  140. # (resolution to a User then legitimately fails for the deleted one).
  141. resp = room_directory_pb2.ListRoomsResponse()
  142. entry = resp.rooms.add()
  143. entry.room.id = "r-dm"
  144. entry.room.kind = "ROOM_KIND_DM"
  145. entry.room.name = "me & u2"
  146. entry.viewer_state.is_member = True
  147. entry.member_user_ids.extend(["u1", "u-deleted"])
  148. _mock_method(client, "room_directory", "list_rooms", resp)
  149. async with client:
  150. rooms = await client.list_rooms()
  151. assert len(rooms) == 1
  152. assert rooms[0].room is not None
  153. assert rooms[0].room.kind is RoomKind.DM
  154. assert rooms[0].member_user_ids == ["u1", "u-deleted"]
  155. # --- Messages ----------------------------------------------------------
  156. async def test_post_message(client):
  157. resp = messages_pb2.CreateMessageResponse()
  158. resp.message.id = "e1"
  159. resp.message.room_id = "r1"
  160. resp.message.actor_id = "u1"
  161. resp.message.body = "Hello!"
  162. _mock_method(client, "messages", "create_message", resp)
  163. async with client:
  164. message = await client.post_message("r1", "Hello!")
  165. assert message.id == "e1"
  166. assert message.body == "Hello!"
  167. assert message.room_id == "r1"
  168. async def test_update_message(client):
  169. resp = messages_pb2.UpdateMessageResponse()
  170. resp.message.id = "e1"
  171. resp.message.room_id = "r1"
  172. resp.message.actor_id = "u1"
  173. resp.message.body = "edited"
  174. resp.message.updated_at.FromJsonString("2026-01-01T00:01:00Z")
  175. m = _mock_method(client, "messages", "update_message", resp)
  176. async with client:
  177. msg = await client.update_message("r1", "e1", body="edited")
  178. assert msg.body == "edited"
  179. assert msg.updated_at is not None
  180. # verify the request had the optional body field explicitly set
  181. call_args = m.call_args
  182. req = call_args.args[0]
  183. assert isinstance(req, messages_pb2.UpdateMessageRequest)
  184. assert req.HasField("body")
  185. assert req.body == "edited"
  186. async def test_delete_message(client):
  187. _mock_method(client, "messages", "delete_message", messages_pb2.DeleteMessageResponse())
  188. async with client:
  189. assert await client.delete_message("r1", "e1") is None
  190. async def test_add_reaction(client):
  191. resp = reactions_pb2.AddReactionResponse(added=True)
  192. _mock_method(client, "messages", "add_reaction", resp)
  193. async with client:
  194. assert await client.add_reaction("r1", "e1", "thumbsup") is True
  195. async def test_remove_reaction(client):
  196. resp = reactions_pb2.RemoveReactionResponse(removed=True)
  197. _mock_method(client, "messages", "remove_reaction", resp)
  198. async with client:
  199. assert await client.remove_reaction("r1", "e1", "thumbsup") is True
  200. # --- Room lifecycle ---------------------------------------------------
  201. async def test_create_room(client):
  202. resp = rooms_pb2.CreateRoomResponse()
  203. resp.room.id = "r1"
  204. resp.room.kind = "ROOM_KIND_CHANNEL"
  205. resp.room.name = "general"
  206. resp.room.group_id = "g1"
  207. _mock_method(client, "rooms", "create_room", resp)
  208. async with client:
  209. room = await client.create_room("general", "g1")
  210. assert room.id == "r1"
  211. assert room.name == "general"
  212. assert room.group_id == "g1"
  213. async def test_archive_room(client):
  214. resp = rooms_pb2.ArchiveRoomResponse()
  215. resp.room.id = "r1"
  216. resp.room.name = "general"
  217. resp.room.archived = True
  218. _mock_method(client, "rooms", "archive_room", resp)
  219. async with client:
  220. room = await client.archive_room("r1")
  221. assert room.archived is True
  222. async def test_remove_user(client):
  223. resp = rooms_pb2.RemoveUserResponse()
  224. mock = _mock_method(client, "rooms", "remove_user", resp)
  225. async with client:
  226. await client.remove_user("r1", "u2", "spam")
  227. await client.remove_user(
  228. "r1", "u2", "spam", suspension_expires_at=dt.datetime.fromtimestamp(0, dt.UTC)
  229. )
  230. await client.remove_user("r1", "u2", "spam", suspend_indefinitely=True)
  231. calls = [c.args[0] for c in mock.call_args_list]
  232. assert calls[0].WhichOneof("suspension") is None
  233. assert calls[1].WhichOneof("suspension") == "suspension_expires_at"
  234. assert calls[2].WhichOneof("suspension") == "suspend_indefinitely"
  235. async def test_remove_user_rejects_both_suspensions(client):
  236. _mock_method(client, "rooms", "remove_user", rooms_pb2.RemoveUserResponse())
  237. async with client:
  238. with pytest.raises(ValueError, match="not both"):
  239. await client.remove_user(
  240. "r1",
  241. "u2",
  242. "spam",
  243. suspension_expires_at=dt.datetime.fromtimestamp(0, dt.UTC),
  244. suspend_indefinitely=True,
  245. )
  246. async def test_lift_suspension(client):
  247. _mock_method(client, "rooms", "lift_suspension", rooms_pb2.LiftSuspensionResponse())
  248. async with client:
  249. assert await client.lift_suspension("r1", "u2", "reinstated") is None
  250. async def test_list_suspensions(client):
  251. resp = rooms_pb2.ListSuspensionsResponse()
  252. s = resp.suspensions.add()
  253. s.id = "s1"
  254. s.room_id = "r1"
  255. s.user_id = "u2"
  256. _mock_method(client, "rooms", "list_suspensions", resp)
  257. async with client:
  258. suspensions, _ = await client.list_suspensions(room_id="r1")
  259. assert [x.id for x in suspensions] == ["s1"]
  260. async def test_start_dm(client):
  261. resp = rooms_pb2.StartDMResponse()
  262. resp.room.id = "r1"
  263. resp.room.kind = "ROOM_KIND_DM"
  264. _mock_method(client, "rooms", "start_dm", resp)
  265. async with client:
  266. room = await client.start_dm(["u1", "u2"])
  267. assert room.id == "r1"
  268. assert room.kind is RoomKind.DM
  269. # --- Room timeline ----------------------------------------------------
  270. async def test_get_room_events(client):
  271. resp = room_timeline_pb2.GetRoomEventsResponse()
  272. event = resp.page.events.add()
  273. event.id = "e1"
  274. event.actor_id = "u1"
  275. event.created_at.FromJsonString("2026-01-01T00:00:00Z")
  276. event.message_posted.message.id = "e1"
  277. event.message_posted.message.room_id = "r1"
  278. event.message_posted.message.actor_id = "u1"
  279. event.message_posted.message.body = "hi"
  280. event.message_posted.message.created_at.FromJsonString("2026-01-01T00:00:00Z")
  281. resp.page.start_cursor = "cA"
  282. resp.page.end_cursor = "cB"
  283. resp.page.includes.users["u1"].id = "u1"
  284. resp.page.includes.users["u1"].login = "alice"
  285. resp.page.includes.users["u1"].display_name = "Alice"
  286. _mock_method(client, "rooms", "get_room_events", resp)
  287. async with client:
  288. page = await client.get_room_events("r1", limit=50)
  289. assert len(page.events) == 1
  290. assert page.events[0].kind == "message_posted"
  291. assert page.events[0].message is not None
  292. assert page.events[0].message.body == "hi"
  293. assert page.users_by_id["u1"].login == "alice"
  294. async def test_mark_room_as_read(client):
  295. resp = read_state_pb2.MarkRoomAsReadResponse()
  296. resp.last_read_at.FromJsonString("2026-01-01T00:00:00Z")
  297. resp.previous_last_read_at.FromJsonString("2025-12-31T00:00:00Z")
  298. _mock_method(client, "rooms", "mark_room_as_read", resp)
  299. async with client:
  300. last, previous = await client.mark_room_as_read("r1", up_to_event_id="e1")
  301. assert last is not None
  302. assert previous is not None
  303. # --- Profile / account ------------------------------------------------
  304. async def test_update_user_profile(client):
  305. resp = user_service_pb2.UpdateUserProfileResponse()
  306. resp.user.id = "u1"
  307. resp.user.login = "newname"
  308. resp.user.display_name = "New Name"
  309. resp.user.bio = "# Hi"
  310. resp.user.presence_status = "PRESENCE_STATUS_ONLINE"
  311. _mock_method(client, "user", "update_user_profile", resp)
  312. async with client:
  313. user = await client.update_user_profile(
  314. "u1", login="newname", display_name="New Name", bio="# Hi"
  315. )
  316. assert user.id == "u1"
  317. assert user.login == "newname"
  318. assert user.display_name == "New Name"
  319. assert user.bio == "# Hi"
  320. async def test_update_profile(client):
  321. """Self-service update resolves the caller id via the viewer, then
  322. delegates to the new UserService.UpdateUserProfile wire call."""
  323. viewer = viewer_pb2.GetViewerResponse()
  324. viewer.user.profile.id = "u1"
  325. viewer.user.profile.login = "alice"
  326. viewer.user.profile.display_name = "Alice"
  327. _mock_method(client, "viewer", "get_viewer", viewer)
  328. resp = user_service_pb2.UpdateUserProfileResponse()
  329. resp.user.id = "u1"
  330. resp.user.login = "newname"
  331. resp.user.display_name = "New Name"
  332. resp.user.presence_status = "PRESENCE_STATUS_ONLINE"
  333. update = _mock_method(client, "user", "update_user_profile", resp)
  334. async with client:
  335. user = await client.update_profile(login="newname", display_name="New Name")
  336. assert user.login == "newname"
  337. assert user.display_name == "New Name"
  338. (req,) = update.await_args.args
  339. assert req.user_id == "u1"
  340. async def test_set_presence(client):
  341. resp = presence_pb2.SetPresenceResponse()
  342. resp.status = "PRESENCE_STATUS_ONLINE"
  343. _mock_method(client, "account", "set_presence", resp)
  344. async with client:
  345. result = await client.set_presence(PresenceStatus.ONLINE)
  346. assert result is PresenceStatus.ONLINE
  347. async def test_set_presence_offline_rejected(client):
  348. async with client:
  349. with pytest.raises(ValueError):
  350. await client.set_presence(PresenceStatus.OFFLINE)
  351. async def test_get_presence_preference(client):
  352. resp = presence_pb2.GetPresencePreferenceResponse()
  353. resp.preference.status = "PRESENCE_STATUS_OFFLINE"
  354. resp.preference.revision = "rev-1"
  355. _mock_method(client, "account", "get_presence_preference", resp)
  356. async with client:
  357. pref = await client.get_presence_preference()
  358. assert pref is not None
  359. assert pref.status is PresenceStatus.OFFLINE
  360. assert pref.revision == "rev-1"
  361. async def test_get_presence_preference_absent(client):
  362. # No preference saved yet: the response carries no sub-message.
  363. _mock_method(
  364. client, "account", "get_presence_preference", presence_pb2.GetPresencePreferenceResponse()
  365. )
  366. async with client:
  367. assert await client.get_presence_preference() is None
  368. async def test_set_presence_preference(client):
  369. resp = presence_pb2.SetPresencePreferenceResponse()
  370. resp.preference.status = "PRESENCE_STATUS_DO_NOT_DISTURB"
  371. resp.preference.revision = "rev-2"
  372. m = _mock_method(client, "account", "set_presence_preference", resp)
  373. async with client:
  374. pref = await client.set_presence_preference(
  375. PresenceStatus.DO_NOT_DISTURB, expected_revision="rev-1"
  376. )
  377. assert pref.status is PresenceStatus.DO_NOT_DISTURB
  378. assert pref.revision == "rev-2"
  379. req = m.await_args.args[0]
  380. assert req.status == presence_pb2.PresenceStatus.Value("PRESENCE_STATUS_DO_NOT_DISTURB")
  381. assert req.expected_revision == "rev-1"
  382. async def test_set_presence_preference_offline_allowed(client):
  383. # Unlike set_presence, OFFLINE is a valid saved choice ("Appear Offline").
  384. resp = presence_pb2.SetPresencePreferenceResponse()
  385. resp.preference.status = "PRESENCE_STATUS_OFFLINE"
  386. _mock_method(client, "account", "set_presence_preference", resp)
  387. async with client:
  388. assert (await client.set_presence_preference(PresenceStatus.OFFLINE)).status is (
  389. PresenceStatus.OFFLINE
  390. )
  391. async def test_set_presence_preference_unspecified_rejected(client):
  392. async with client:
  393. with pytest.raises(ValueError):
  394. await client.set_presence_preference(PresenceStatus.UNSPECIFIED)
  395. async def test_refresh_presence(client):
  396. resp = presence_pb2.RefreshPresenceResponse()
  397. resp.preference.status = "PRESENCE_STATUS_ONLINE"
  398. resp.preference.revision = "rev-3"
  399. _mock_method(client, "account", "refresh_presence", resp)
  400. async with client:
  401. pref = await client.refresh_presence()
  402. assert pref is not None
  403. assert pref.status is PresenceStatus.ONLINE
  404. assert pref.revision == "rev-3"
  405. async def test_update_settings(client):
  406. resp = account_pb2.UpdateSettingsResponse()
  407. resp.settings.timezone = "Europe/Rome"
  408. resp.settings.time_format = "TIME_FORMAT_24_HOUR"
  409. _mock_method(client, "account", "update_settings", resp)
  410. async with client:
  411. settings = await client.update_settings(
  412. timezone="Europe/Rome", time_format=TimeFormat.HOUR_24
  413. )
  414. assert settings.timezone == "Europe/Rome"
  415. assert settings.time_format is TimeFormat.HOUR_24
  416. # --- Threads ----------------------------------------------------------
  417. async def test_list_followed_threads(client):
  418. resp = threads_pb2.ListFollowedThreadsResponse()
  419. ft = resp.threads.add()
  420. ft.room.id = "r1"
  421. ft.room.name = "general"
  422. ft.root_message.id = "e1"
  423. ft.root_message.room_id = "r1"
  424. ft.root_message.actor_id = "u1"
  425. ft.root_message.body = "root"
  426. ft.root_message.created_at.FromJsonString("2026-01-01T00:00:00Z")
  427. ft.thread.thread_root_event_id = "e1"
  428. ft.thread.reply_count = 3
  429. ft.thread.last_reply_at.FromJsonString("2026-01-02T00:00:00Z")
  430. ft.thread.viewer_state.is_following = True
  431. ft.thread.viewer_state.has_unread_replies = False
  432. resp.page.total_count = 1
  433. resp.page.has_more = False
  434. mocked = _mock_method(client, "threads", "list_followed_threads", resp)
  435. async with client:
  436. page = await client.list_followed_threads()
  437. await client.list_followed_threads(
  438. limit=5,
  439. offset=5,
  440. include_direct_message_threads=True,
  441. unread_only=True,
  442. root_author_filter=ThreadRootAuthorFilter.VIEWER,
  443. )
  444. await client.list_followed_threads(
  445. root_author_filter=ThreadRootAuthorFilter.OTHERS,
  446. )
  447. assert page.page.total_count == 1
  448. assert page.threads[0].thread is not None
  449. assert page.threads[0].thread.reply_count == 3
  450. assert page.threads[0].thread.is_following is True
  451. calls = [c.args[0] for c in mocked.call_args_list]
  452. assert calls[0].include_direct_message_threads is False
  453. assert calls[0].unread_only is False
  454. assert calls[0].root_author_filter == 0
  455. assert calls[1].include_direct_message_threads is True
  456. assert calls[1].unread_only is True
  457. assert calls[1].root_author_filter == threads_pb2.ThreadRootAuthorFilter.Value(
  458. "THREAD_ROOT_AUTHOR_FILTER_VIEWER"
  459. )
  460. assert calls[2].root_author_filter == threads_pb2.ThreadRootAuthorFilter.Value(
  461. "THREAD_ROOT_AUTHOR_FILTER_OTHERS"
  462. )
  463. assert calls[1].page.limit == 5
  464. assert calls[1].page.offset == 5
  465. async def test_follow_and_unfollow_thread(client):
  466. _mock_method(client, "threads", "follow_thread", threads_pb2.FollowThreadResponse())
  467. _mock_method(client, "threads", "unfollow_thread", threads_pb2.UnfollowThreadResponse())
  468. async with client:
  469. assert await client.follow_thread("r1", "e1") is None
  470. assert await client.unfollow_thread("r1", "e1") is None
  471. # --- Notifications ----------------------------------------------------
  472. async def test_list_notification_occurrences(client):
  473. resp = notifications_pb2.ListNotificationOccurrencesResponse()
  474. occ = resp.occurrences.add()
  475. occ.id = "n1"
  476. occ.created_at.FromJsonString("2026-01-01T00:00:00Z")
  477. occ.actor.id = "u1"
  478. occ.actor.login = "alice"
  479. occ.actor.display_name = "Alice"
  480. occ.unread = True
  481. sig = occ.signal.direct_mention_received
  482. sig.message.room.id = "r1"
  483. sig.message.room.name = "general"
  484. sig.message.event_id = "e1"
  485. resp.page.total_count = 1
  486. resp.unread_count = 1
  487. _mock_method(client, "notifications", "list_notification_occurrences", resp)
  488. async with client:
  489. page = await client.list_notification_occurrences()
  490. assert page.page.total_count == 1
  491. assert page.unread_count == 1
  492. assert page.occurrences[0].signal is not None
  493. assert page.occurrences[0].signal.kind == "direct_mention_received"
  494. assert page.occurrences[0].signal.message is not None
  495. assert page.occurrences[0].signal.message.event_id == "e1"
  496. assert page.occurrences[0].signal.message.room is not None
  497. assert page.occurrences[0].signal.message.room.name == "general"
  498. async def test_mark_and_delete_notification_occurrence(client):
  499. read_resp = notifications_pb2.MarkNotificationReadResponse()
  500. read_resp.occurrence.id = "n1"
  501. read_resp.occurrence.unread = False
  502. _mock_method(client, "notifications", "mark_notification_read", read_resp)
  503. del_resp = notifications_pb2.DeleteNotificationOccurrenceResponse(deleted=True)
  504. _mock_method(client, "notifications", "delete_notification_occurrence", del_resp)
  505. all_resp = notifications_pb2.DeleteAllNotificationOccurrencesResponse(deleted_count=3)
  506. _mock_method(client, "notifications", "delete_all_notification_occurrences", all_resp)
  507. async with client:
  508. occ = await client.mark_notification_read("n1")
  509. assert occ is not None and occ.unread is False
  510. assert await client.delete_notification_occurrence("n1") is True
  511. assert await client.delete_all_notification_occurrences() == 3
  512. async def test_notification_preference(client):
  513. resp = notification_preferences_pb2.UpdateNotificationPreferenceResponse()
  514. resp.preference.level = "NOTIFICATION_LEVEL_MUTED"
  515. resp.preference.effective_level = "NOTIFICATION_LEVEL_MUTED"
  516. _mock_method(
  517. client,
  518. "notification_prefs",
  519. "update_room_notification_preference",
  520. resp,
  521. )
  522. async with client:
  523. pref = await client.update_room_notification_preference("r1", NotificationLevel.MUTED)
  524. assert pref.level is NotificationLevel.MUTED
  525. assert pref.effective_level is NotificationLevel.MUTED
  526. # --- Push -------------------------------------------------------------
  527. async def test_subscribe_push(client):
  528. _mock_method(client, "push", "subscribe", push_notifications_pb2.SubscribeResponse())
  529. async with client:
  530. assert await client.subscribe_push("https://push", "k", "a") is True
  531. # --- Voice calls ------------------------------------------------------
  532. async def test_active_calls(client):
  533. resp = voice_calls_pb2.ListActiveCallsResponse()
  534. call = resp.calls.add()
  535. call.call_id = "c1"
  536. call.room.id = "r1"
  537. call.room.name = "voice"
  538. p = call.participants.add()
  539. p.user.id = "u1"
  540. p.user.login = "alice"
  541. p.user.display_name = "Alice"
  542. _mock_method(client, "voice_calls", "list_active_calls", resp)
  543. async with client:
  544. calls = await client.list_active_calls()
  545. assert len(calls) == 1
  546. assert calls[0].call_id == "c1"
  547. assert calls[0].room is not None
  548. assert calls[0].room.name == "voice"
  549. # --- Assets -----------------------------------------------------------
  550. async def test_get_asset_with_thumbnail(client):
  551. resp = attachments_pb2.GetAssetResponse()
  552. resp.asset.id = "a1"
  553. resp.asset.filename = "pic.png"
  554. resp.asset.content_type = "image/png"
  555. resp.asset.size = 1024
  556. resp.asset.asset_url.url = "https://cdn/pic.png"
  557. mock = _mock_method(client, "assets", "get_asset", resp)
  558. async with client:
  559. asset = await client.get_asset(
  560. "r1",
  561. "a1",
  562. thumbnail=ImageTransformOptions(128, 128, ImageFitMode.COVER),
  563. )
  564. assert asset is not None
  565. assert asset.filename == "pic.png"
  566. assert asset.asset_url is not None
  567. assert asset.asset_url.url == "https://cdn/pic.png"
  568. # verify the request encoded thumbnail options
  569. req = mock.call_args.args[0]
  570. assert req.thumbnail.width == 128
  571. assert req.thumbnail.height == 128
  572. # --- Roles ------------------------------------------------------------
  573. async def test_list_roles(client):
  574. resp = roles_pb2.ListRolesResponse()
  575. r1 = resp.roles.add()
  576. r1.name = "everyone"
  577. r1.display_name = "Everyone"
  578. r1.is_system = True
  579. r1.position = 0
  580. r2 = resp.roles.add()
  581. r2.name = "admin"
  582. r2.display_name = "Admin"
  583. r2.position = 10
  584. _mock_method(client, "roles", "list_roles", resp)
  585. async with client:
  586. roles = await client.list_roles()
  587. assert [r.name for r in roles] == ["everyone", "admin"]
  588. assert roles[0].is_system is True
  589. # --- Asset uploads ---------------------------------------------------
  590. async def test_asset_upload_end_to_end(client, tmp_path):
  591. import hashlib
  592. payload = b"hello world" * 100
  593. file = tmp_path / "greet.txt"
  594. file.write_bytes(payload)
  595. sha = hashlib.sha256(payload).hexdigest()
  596. create_resp = asset_uploads_pb2.CreateUploadResponse()
  597. create_resp.upload.upload_id = "up1"
  598. create_resp.upload.room_id = "r1"
  599. create_resp.upload.status = "ASSET_UPLOAD_STATUS_OPEN"
  600. create_resp.upload.committed_offset = 0
  601. create_resp.upload.size = len(payload)
  602. create_resp.upload.max_chunk_size = 4096
  603. create_resp.upload.sha256 = sha
  604. create_mock = _mock_method(client, "asset_uploads", "create_upload", create_resp)
  605. chunk_resp = asset_uploads_pb2.UploadChunkResponse()
  606. chunk_resp.upload.upload_id = "up1"
  607. chunk_resp.upload.room_id = "r1"
  608. chunk_resp.upload.status = "ASSET_UPLOAD_STATUS_OPEN"
  609. chunk_resp.upload.committed_offset = len(payload)
  610. chunk_resp.upload.size = len(payload)
  611. chunk_resp.upload.max_chunk_size = 4096
  612. chunk_resp.upload.sha256 = sha
  613. _mock_method(client, "asset_uploads", "upload_chunk", chunk_resp)
  614. complete_resp = asset_uploads_pb2.CompleteUploadResponse()
  615. complete_resp.upload.upload_id = "up1"
  616. complete_resp.upload.room_id = "r1"
  617. complete_resp.upload.status = "ASSET_UPLOAD_STATUS_COMPLETED"
  618. complete_resp.upload.committed_offset = len(payload)
  619. complete_resp.upload.size = len(payload)
  620. complete_resp.upload.sha256 = sha
  621. complete_resp.upload.asset_id = "a1"
  622. complete_resp.asset.id = "a1"
  623. complete_resp.asset.filename = "greet.txt"
  624. complete_resp.asset.content_type = "text/plain"
  625. complete_resp.asset.size = len(payload)
  626. _mock_method(client, "asset_uploads", "complete_upload", complete_resp)
  627. async with client:
  628. asset = await client.upload_attachment("r1", file, content_type="text/plain")
  629. assert asset.id == "a1"
  630. assert asset.filename == "greet.txt"
  631. create_req = create_mock.call_args.args[0]
  632. assert create_req.sha256 == sha
  633. assert create_req.size == len(payload)
  634. # --- External identities --------------------------------------------
  635. # --- Admin (structural smoke tests) ----------------------------------
  636. async def test_admin_list_room_groups(client):
  637. resp = room_layout_pb2.ListRoomGroupsResponse()
  638. g = resp.groups.add()
  639. g.id = "g1"
  640. g.name = "General"
  641. room_item = g.items.add()
  642. room_item.room.id = "r1"
  643. room_item.room.kind = "ROOM_KIND_CHANNEL"
  644. room_item.room.name = "chat"
  645. link_item = g.items.add()
  646. link_item.sidebar_link.id = "sl1"
  647. link_item.sidebar_link.label = "Docs"
  648. link_item.sidebar_link.url = "https://x"
  649. g.can_create_room = True
  650. _mock_method(client, "admin_room_layout", "list_room_groups", resp)
  651. async with client:
  652. groups = await client.admin_list_room_groups()
  653. assert len(groups) == 1
  654. assert groups[0].name == "General"
  655. assert groups[0].rooms[0].id == "r1"
  656. assert groups[0].sidebar_links[0].label == "Docs"
  657. assert groups[0].can_create_room is True
  658. async def test_admin_create_sidebar_link(client):
  659. resp = room_layout_pb2.CreateSidebarLinkResponse()
  660. resp.sidebar_link.id = "sl1"
  661. resp.sidebar_link.label = "Docs"
  662. resp.sidebar_link.url = "https://x"
  663. _mock_method(client, "admin_room_layout", "create_sidebar_link", resp)
  664. async with client:
  665. result = await client.admin_create_sidebar_link("g1", "Docs", "https://x")
  666. assert result == {"id": "sl1", "label": "Docs", "url": "https://x"}
  667. async def test_admin_move_room_group(client):
  668. resp = room_layout_pb2.MoveRoomGroupResponse()
  669. g = resp.groups.add()
  670. g.id = "g1"
  671. g.name = "General"
  672. mock = _mock_method(client, "admin_room_layout", "move_room_group", resp)
  673. async with client:
  674. groups = await client.admin_move_room_group("g1", before_group_id="g2")
  675. assert [g.id for g in groups] == ["g1"]
  676. sent = mock.call_args.args[0]
  677. assert sent.group_id == "g1"
  678. assert sent.before_group_id == "g2"
  679. async def test_admin_move_room_group_to_end(client):
  680. resp = room_layout_pb2.MoveRoomGroupResponse()
  681. mock = _mock_method(client, "admin_room_layout", "move_room_group", resp)
  682. async with client:
  683. await client.admin_move_room_group("g1")
  684. sent = mock.call_args.args[0]
  685. assert sent.group_id == "g1"
  686. assert not sent.HasField("before_group_id")
  687. async def test_admin_move_sidebar_item(client):
  688. resp = room_layout_pb2.MoveSidebarItemResponse()
  689. resp.group.id = "g1"
  690. resp.group.name = "General"
  691. mock = _mock_method(client, "admin_room_layout", "move_sidebar_item", resp)
  692. async with client:
  693. group = await client.admin_move_sidebar_item(
  694. ("sidebar_link", "sl1"),
  695. "g1",
  696. before=("room", "r1"),
  697. )
  698. assert group.id == "g1"
  699. sent = mock.call_args.args[0]
  700. assert sent.item.sidebar_link_id == "sl1"
  701. assert sent.before.room_id == "r1"
  702. assert sent.group_id == "g1"
  703. async def test_admin_get_system_info_livekit(client):
  704. resp = diagnostics_pb2.GetSystemInfoResponse()
  705. resp.projections_available = True
  706. resp.livekit.enabled = True
  707. resp.livekit.configured = True
  708. resp.livekit.url = "wss://livekit.example"
  709. resp.livekit.api_key = "AP1234"
  710. resp.livekit.separate_webhook_key = True
  711. resp.livekit.webhook_url = "https://chat.example/api/admin/livekit/webhook"
  712. resp.livekit.connection_state = diagnostics_pb2.ADMIN_LIVE_KIT_CONNECTION_STATE_OK
  713. resp.livekit.insecure_url = False
  714. _mock_method(client, "admin_diagnostics", "get_system_info", resp)
  715. async with client:
  716. data = await client.admin_get_system_info()
  717. lk = data["livekit"]
  718. assert lk["enabled"] is True
  719. assert lk["configured"] is True
  720. assert lk["url"] == "wss://livekit.example"
  721. assert lk["apiKey"] == "AP1234"
  722. assert lk["separateWebhookKey"] is True
  723. # enums render as their full name in Connect JSON (use_integers_for_enums=False)
  724. assert lk["connectionState"] == "ADMIN_LIVE_KIT_CONNECTION_STATE_OK"
  725. assert (
  726. diagnostics_pb2.AdminLiveKitConnectionState.Value(
  727. "ADMIN_LIVE_KIT_CONNECTION_STATE_UNREACHABLE"
  728. )
  729. == 3
  730. )
  731. assert "connectionError" not in lk # empty string omitted by MessageToDict
  732. async def test_admin_update_server_config(client):
  733. resp = admin_server_pb2.UpdateServerConfigResponse()
  734. resp.config.server_name = "MyServer"
  735. resp.config.motd = "hi"
  736. resp.public_profile.name = "MyServer"
  737. resp.public_profile.version = "0.4.2"
  738. _mock_method(client, "admin_server", "update_server_config", resp)
  739. async with client:
  740. config, profile = await client.admin_update_server_config(server_name="MyServer", motd="hi")
  741. assert config.server_name == "MyServer"
  742. assert profile.version == "0.4.2"
  743. # --- Neighbors (0.5.0-alpha.3) -----------------------------------------
  744. async def test_list_neighbors_public(client):
  745. resp = discovery_server_pb2.ListNeighborsResponse()
  746. resp.origins.extend(["https://a.example", "https://b.example"])
  747. _mock_method(client, "server_discovery", "list_neighbors", resp)
  748. async with client:
  749. origins = await client.list_neighbors()
  750. assert origins == ["https://a.example", "https://b.example"]
  751. async def test_admin_neighbor_crud(client):
  752. list_resp = admin_server_pb2.ListNeighborsResponse()
  753. n = list_resp.neighbors.add()
  754. n.id = "n1"
  755. n.origin = "https://a.example"
  756. n.revision = "rev1"
  757. _mock_method(client, "admin_server", "list_neighbors", list_resp)
  758. get_resp = admin_server_pb2.GetNeighborResponse()
  759. get_resp.neighbor.id = "n1"
  760. get_resp.neighbor.origin = "https://a.example"
  761. get_resp.neighbor.revision = "rev1"
  762. _mock_method(client, "admin_server", "get_neighbor", get_resp)
  763. create_resp = admin_server_pb2.CreateNeighborResponse()
  764. create_resp.neighbor.id = "n2"
  765. create_resp.neighbor.origin = "https://b.example"
  766. create_resp.neighbor.revision = "rev2"
  767. _mock_method(client, "admin_server", "create_neighbor", create_resp)
  768. update_resp = admin_server_pb2.UpdateNeighborResponse()
  769. update_resp.neighbor.id = "n2"
  770. update_resp.neighbor.origin = "https://b2.example"
  771. update_resp.neighbor.revision = "rev3"
  772. _mock_method(client, "admin_server", "update_neighbor", update_resp)
  773. del_resp = admin_server_pb2.DeleteNeighborResponse()
  774. _mock_method(client, "admin_server", "delete_neighbor", del_resp)
  775. async with client:
  776. listed = await client.admin_list_neighbors()
  777. assert [x.origin for x in listed] == ["https://a.example"]
  778. got = await client.admin_get_neighbor("n1")
  779. assert got is not None and got.revision == "rev1"
  780. created = await client.admin_create_neighbor("https://b.example")
  781. assert created.id == "n2" and created.revision == "rev2"
  782. updated = await client.admin_update_neighbor("n2", "https://b2.example", "rev2")
  783. assert updated.origin == "https://b2.example" and updated.revision == "rev3"
  784. await client.admin_delete_neighbor("n2", "rev3")
  785. async def test_update_settings_share_timezone(client):
  786. resp = account_pb2.UpdateSettingsResponse()
  787. resp.settings.timezone = "Europe/Berlin"
  788. resp.settings.share_timezone = True
  789. _mock_method(client, "account", "update_settings", resp)
  790. async with client:
  791. settings = await client.update_settings(timezone="Europe/Berlin", share_timezone=True)
  792. assert settings.timezone == "Europe/Berlin"
  793. assert settings.share_timezone is True
  794. def test_snake_case_is_acronym_aware():
  795. """``types._snake_case`` must not chop acronym runs (same bug class as
  796. the protoc plugin's old ``StartDM`` -> ``start_d_m``)."""
  797. from chattolib.types import _snake_case
  798. assert _snake_case("messagePosted") == "message_posted"
  799. assert _snake_case("directMessageReceived") == "direct_message_received"
  800. # hypothetical acronym field: DM stays whole, boundary before the word after it
  801. assert _snake_case("startDMReceived") == "start_dm_received"
  802. assert _snake_case("getDMStats") == "get_dm_stats"