test_client.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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. from unittest.mock import AsyncMock
  9. import httpx
  10. import pytest
  11. import respx
  12. from chattolib._pb.chatto.admin.v1 import (
  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. external_identities_pb2,
  23. member_directory_pb2,
  24. messages_pb2,
  25. notification_preferences_pb2,
  26. notifications_pb2,
  27. presence_pb2,
  28. push_notifications_pb2,
  29. reactions_pb2,
  30. read_state_pb2,
  31. roles_pb2,
  32. room_directory_pb2,
  33. room_timeline_pb2,
  34. rooms_pb2,
  35. threads_pb2,
  36. viewer_pb2,
  37. voice_calls_pb2,
  38. )
  39. from chattolib._pb.chatto.discovery.v1 import server_pb2 as discovery_server_pb2
  40. from chattolib.client import ChattoClient
  41. from chattolib.exceptions import ChattoAuthError
  42. from chattolib.types import (
  43. ImageFitMode,
  44. ImageTransformOptions,
  45. NotificationLevel,
  46. PresenceStatus,
  47. RoomKind,
  48. TimeFormat,
  49. )
  50. BASE = "https://chat.chatto.run"
  51. @pytest.fixture
  52. def client():
  53. return ChattoClient(token="test-token", base_url=BASE)
  54. def _mock_method(client: ChattoClient, service: str, method: str, response):
  55. """Replace a service-client method with an AsyncMock returning ``response``."""
  56. svc = getattr(client._svc, service)
  57. setattr(svc, method, AsyncMock(return_value=response))
  58. return getattr(svc, method)
  59. # --- Login flow ---------------------------------------------------------
  60. async def test_login_and_capture_session():
  61. with respx.mock(base_url=BASE) as api:
  62. api.post("/auth/login").mock(
  63. return_value=httpx.Response(
  64. 200,
  65. json={"success": True, "token": "cht_abc123"},
  66. headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
  67. )
  68. )
  69. c = await ChattoClient.login("alice", "password123")
  70. assert c.token == "cht_abc123"
  71. assert c.session_cookie == "xyz"
  72. await c.close()
  73. async def test_login_invalid():
  74. with respx.mock(base_url=BASE) as api:
  75. api.post("/auth/login").mock(
  76. return_value=httpx.Response(401, json={"error": "Invalid credentials"})
  77. )
  78. with pytest.raises(ChattoAuthError, match="Invalid credentials"):
  79. await ChattoClient.login("bad", "creds")
  80. # --- Server discovery ---------------------------------------------------
  81. async def test_get_server(client):
  82. resp = discovery_server_pb2.GetServerResponse()
  83. resp.profile.name = "Chatto HQ"
  84. resp.profile.version = "0.4.2"
  85. resp.login.direct_registration_enabled = True
  86. resp.login.authorize_url = "/oauth"
  87. _mock_method(client, "server_discovery", "get_server", resp)
  88. async with client:
  89. profile, login = await client.get_server()
  90. assert profile.name == "Chatto HQ"
  91. assert profile.version == "0.4.2"
  92. assert login.direct_registration_enabled is True
  93. assert login.authorize_url == "/oauth"
  94. # --- Viewer -------------------------------------------------------------
  95. async def test_me(client):
  96. resp = viewer_pb2.GetViewerResponse()
  97. resp.user.profile.id = "u1"
  98. resp.user.profile.login = "alice"
  99. resp.user.profile.display_name = "Alice"
  100. resp.user.profile.presence_status = "PRESENCE_STATUS_ONLINE"
  101. resp.user.has_verified_email = True
  102. resp.user.settings.timezone = "UTC"
  103. resp.user.settings.time_format = "TIME_FORMAT_24_HOUR"
  104. resp.user.has_password = True
  105. _mock_method(client, "viewer", "get_viewer", resp)
  106. async with client:
  107. user = await client.me()
  108. assert user.id == "u1"
  109. assert user.login == "alice"
  110. assert user.display_name == "Alice"
  111. assert user.presence_status is PresenceStatus.ONLINE
  112. # --- Room directory ----------------------------------------------------
  113. async def test_list_rooms(client):
  114. resp = room_directory_pb2.ListRoomsResponse()
  115. entry = resp.rooms.add()
  116. entry.room.id = "r1"
  117. entry.room.kind = "ROOM_KIND_CHANNEL"
  118. entry.room.name = "general"
  119. entry.room.group_id = "g1"
  120. entry.viewer_state.is_member = True
  121. entry.viewer_state.has_unread = False
  122. _mock_method(client, "room_directory", "list_rooms", resp)
  123. async with client:
  124. rooms = await client.list_rooms()
  125. assert len(rooms) == 1
  126. assert rooms[0].room is not None
  127. assert rooms[0].room.name == "general"
  128. assert rooms[0].room.kind is RoomKind.CHANNEL
  129. assert rooms[0].viewer_state.is_member is True
  130. # --- Messages ----------------------------------------------------------
  131. async def test_post_message(client):
  132. resp = messages_pb2.CreateMessageResponse()
  133. resp.message.id = "e1"
  134. resp.message.room_id = "r1"
  135. resp.message.actor_id = "u1"
  136. resp.message.body = "Hello!"
  137. _mock_method(client, "messages", "create_message", resp)
  138. async with client:
  139. message = await client.post_message("r1", "Hello!")
  140. assert message.id == "e1"
  141. assert message.body == "Hello!"
  142. assert message.room_id == "r1"
  143. async def test_update_message(client):
  144. resp = messages_pb2.UpdateMessageResponse()
  145. resp.message.id = "e1"
  146. resp.message.room_id = "r1"
  147. resp.message.actor_id = "u1"
  148. resp.message.body = "edited"
  149. resp.message.updated_at.FromJsonString("2026-01-01T00:01:00Z")
  150. m = _mock_method(client, "messages", "update_message", resp)
  151. async with client:
  152. msg = await client.update_message("r1", "e1", body="edited")
  153. assert msg.body == "edited"
  154. assert msg.updated_at is not None
  155. # verify the request had the optional body field explicitly set
  156. call_args = m.call_args
  157. req = call_args.args[0]
  158. assert isinstance(req, messages_pb2.UpdateMessageRequest)
  159. assert req.HasField("body")
  160. assert req.body == "edited"
  161. async def test_delete_message(client):
  162. resp = messages_pb2.DeleteMessageResponse(deleted=True)
  163. _mock_method(client, "messages", "delete_message", resp)
  164. async with client:
  165. assert await client.delete_message("r1", "e1") is True
  166. async def test_add_reaction(client):
  167. resp = reactions_pb2.AddReactionResponse(added=True)
  168. _mock_method(client, "messages", "add_reaction", resp)
  169. async with client:
  170. assert await client.add_reaction("r1", "e1", "thumbsup") is True
  171. async def test_remove_reaction(client):
  172. resp = reactions_pb2.RemoveReactionResponse(removed=True)
  173. _mock_method(client, "messages", "remove_reaction", resp)
  174. async with client:
  175. assert await client.remove_reaction("r1", "e1", "thumbsup") is True
  176. # --- Room lifecycle ---------------------------------------------------
  177. async def test_create_room(client):
  178. resp = rooms_pb2.CreateRoomResponse()
  179. resp.room.id = "r1"
  180. resp.room.kind = "ROOM_KIND_CHANNEL"
  181. resp.room.name = "general"
  182. resp.room.group_id = "g1"
  183. _mock_method(client, "rooms", "create_room", resp)
  184. async with client:
  185. room = await client.create_room("general", "g1")
  186. assert room.id == "r1"
  187. assert room.name == "general"
  188. assert room.group_id == "g1"
  189. async def test_archive_room(client):
  190. resp = rooms_pb2.ArchiveRoomResponse()
  191. resp.room.id = "r1"
  192. resp.room.name = "general"
  193. resp.room.archived = True
  194. _mock_method(client, "rooms", "archive_room", resp)
  195. async with client:
  196. room = await client.archive_room("r1")
  197. assert room.archived is True
  198. async def test_ban_member(client):
  199. resp = rooms_pb2.BanMemberResponse(banned=True)
  200. _mock_method(client, "rooms", "ban_member", resp)
  201. async with client:
  202. assert await client.ban_member("r1", "u2", "spam") is True
  203. async def test_start_dm(client):
  204. resp = rooms_pb2.StartDMResponse()
  205. resp.room.id = "r1"
  206. resp.room.kind = "ROOM_KIND_DM"
  207. _mock_method(client, "rooms", "start_dm", resp)
  208. async with client:
  209. room = await client.start_dm(["u1", "u2"])
  210. assert room.id == "r1"
  211. assert room.kind is RoomKind.DM
  212. # --- Room timeline ----------------------------------------------------
  213. async def test_get_room_events(client):
  214. resp = room_timeline_pb2.GetRoomEventsResponse()
  215. event = resp.page.events.add()
  216. event.id = "e1"
  217. event.actor_id = "u1"
  218. event.created_at.FromJsonString("2026-01-01T00:00:00Z")
  219. event.message_posted.message.id = "e1"
  220. event.message_posted.message.room_id = "r1"
  221. event.message_posted.message.actor_id = "u1"
  222. event.message_posted.message.body = "hi"
  223. event.message_posted.message.created_at.FromJsonString("2026-01-01T00:00:00Z")
  224. resp.page.start_cursor = "cA"
  225. resp.page.end_cursor = "cB"
  226. resp.page.includes.users["u1"].id = "u1"
  227. resp.page.includes.users["u1"].login = "alice"
  228. resp.page.includes.users["u1"].display_name = "Alice"
  229. _mock_method(client, "rooms", "get_room_events", resp)
  230. async with client:
  231. page = await client.get_room_events("r1", limit=50)
  232. assert len(page.events) == 1
  233. assert page.events[0].kind == "message_posted"
  234. assert page.events[0].message is not None
  235. assert page.events[0].message.body == "hi"
  236. assert page.users_by_id["u1"].login == "alice"
  237. async def test_mark_room_as_read(client):
  238. resp = read_state_pb2.MarkRoomAsReadResponse()
  239. resp.last_read_at.FromJsonString("2026-01-01T00:00:00Z")
  240. resp.previous_last_read_at.FromJsonString("2025-12-31T00:00:00Z")
  241. _mock_method(client, "rooms", "mark_room_as_read", resp)
  242. async with client:
  243. last, previous = await client.mark_room_as_read("r1", up_to_event_id="e1")
  244. assert last is not None
  245. assert previous is not None
  246. # --- Profile / account ------------------------------------------------
  247. async def test_update_profile(client):
  248. resp = account_pb2.UpdateProfileResponse()
  249. resp.user.id = "u1"
  250. resp.user.login = "newname"
  251. resp.user.display_name = "New Name"
  252. resp.user.presence_status = "PRESENCE_STATUS_ONLINE"
  253. _mock_method(client, "account", "update_profile", resp)
  254. async with client:
  255. user = await client.update_profile(login="newname", display_name="New Name")
  256. assert user.login == "newname"
  257. assert user.display_name == "New Name"
  258. async def test_upload_avatar(client, tmp_path):
  259. resp = account_pb2.UploadAvatarResponse()
  260. resp.user.id = "u1"
  261. resp.user.login = "alice"
  262. resp.user.display_name = "Alice"
  263. resp.user.avatar_url = "https://example.com/avatar.jpg"
  264. mock = _mock_method(client, "account", "upload_avatar", resp)
  265. avatar = tmp_path / "avatar.png"
  266. avatar.write_bytes(b"\x89PNGfake")
  267. async with client:
  268. user = await client.upload_avatar(str(avatar), content_type="image/png")
  269. assert user.avatar_url == "https://example.com/avatar.jpg"
  270. req = mock.call_args.args[0]
  271. assert req.image.image == b"\x89PNGfake"
  272. assert req.image.filename == "avatar.png"
  273. assert req.image.content_type == "image/png"
  274. async def test_update_presence(client):
  275. resp = presence_pb2.UpdatePresenceResponse()
  276. resp.status = "PRESENCE_STATUS_ONLINE"
  277. _mock_method(client, "account", "update_presence", resp)
  278. async with client:
  279. result = await client.update_presence(PresenceStatus.ONLINE)
  280. assert result is PresenceStatus.ONLINE
  281. async def test_update_presence_offline_rejected(client):
  282. async with client:
  283. with pytest.raises(ValueError):
  284. await client.update_presence(PresenceStatus.OFFLINE)
  285. async def test_update_settings(client):
  286. resp = account_pb2.UpdateSettingsResponse()
  287. resp.settings.timezone = "Europe/Rome"
  288. resp.settings.time_format = "TIME_FORMAT_24_HOUR"
  289. _mock_method(client, "account", "update_settings", resp)
  290. async with client:
  291. settings = await client.update_settings(
  292. timezone="Europe/Rome", time_format=TimeFormat.HOUR_24
  293. )
  294. assert settings.timezone == "Europe/Rome"
  295. assert settings.time_format is TimeFormat.HOUR_24
  296. # --- Users -------------------------------------------------------------
  297. async def test_get_user_by_id(client):
  298. resp = member_directory_pb2.GetUserResponse()
  299. resp.user.user.id = "u1"
  300. resp.user.user.login = "alice"
  301. resp.user.user.display_name = "Alice"
  302. resp.user.user.presence_status = "PRESENCE_STATUS_ONLINE"
  303. resp.user.roles.append("everyone")
  304. _mock_method(client, "users", "get_user", resp)
  305. async with client:
  306. member = await client.get_user(user_id="u1")
  307. assert member is not None
  308. assert member.user is not None
  309. assert member.user.login == "alice"
  310. assert "everyone" in member.roles
  311. async def test_get_user_requires_one_target(client):
  312. with pytest.raises(ValueError):
  313. await client.get_user()
  314. with pytest.raises(ValueError):
  315. await client.get_user(user_id="u1", login="alice")
  316. # --- Threads ----------------------------------------------------------
  317. async def test_list_followed_threads(client):
  318. resp = threads_pb2.ListFollowedThreadsResponse()
  319. ft = resp.threads.add()
  320. ft.room.id = "r1"
  321. ft.room.name = "general"
  322. ft.root_message.id = "e1"
  323. ft.root_message.room_id = "r1"
  324. ft.root_message.actor_id = "u1"
  325. ft.root_message.body = "root"
  326. ft.root_message.created_at.FromJsonString("2026-01-01T00:00:00Z")
  327. ft.thread.thread_root_event_id = "e1"
  328. ft.thread.reply_count = 3
  329. ft.thread.last_reply_at.FromJsonString("2026-01-02T00:00:00Z")
  330. ft.thread.viewer_state.is_following = True
  331. ft.thread.viewer_state.has_unread = False
  332. resp.page.total_count = 1
  333. resp.page.has_more = False
  334. _mock_method(client, "threads", "list_followed_threads", resp)
  335. async with client:
  336. page = await client.list_followed_threads()
  337. assert page.page.total_count == 1
  338. assert page.threads[0].thread is not None
  339. assert page.threads[0].thread.reply_count == 3
  340. assert page.threads[0].thread.is_following is True
  341. async def test_follow_and_unfollow_thread(client):
  342. follow_resp = threads_pb2.FollowThreadResponse(following=True)
  343. unfollow_resp = threads_pb2.UnfollowThreadResponse(following=False)
  344. _mock_method(client, "threads", "follow_thread", follow_resp)
  345. _mock_method(client, "threads", "unfollow_thread", unfollow_resp)
  346. async with client:
  347. assert await client.follow_thread("r1", "e1") is True
  348. assert await client.unfollow_thread("r1", "e1") is False
  349. # --- Notifications ----------------------------------------------------
  350. async def test_list_notification_occurrences(client):
  351. resp = notifications_pb2.ListNotificationOccurrencesResponse()
  352. occ = resp.occurrences.add()
  353. occ.id = "n1"
  354. occ.created_at.FromJsonString("2026-01-01T00:00:00Z")
  355. occ.actor.id = "u1"
  356. occ.actor.login = "alice"
  357. occ.actor.display_name = "Alice"
  358. occ.unread = True
  359. sig = occ.signal.direct_mention_received
  360. sig.message.room.id = "r1"
  361. sig.message.room.name = "general"
  362. sig.message.event_id = "e1"
  363. resp.page.total_count = 1
  364. resp.unread_count = 1
  365. _mock_method(client, "notifications", "list_notification_occurrences", resp)
  366. async with client:
  367. page = await client.list_notification_occurrences()
  368. assert page.page.total_count == 1
  369. assert page.unread_count == 1
  370. assert page.occurrences[0].signal is not None
  371. assert page.occurrences[0].signal.kind == "direct_mention_received"
  372. assert page.occurrences[0].signal.message is not None
  373. assert page.occurrences[0].signal.message.event_id == "e1"
  374. assert page.occurrences[0].signal.message.room is not None
  375. assert page.occurrences[0].signal.message.room.name == "general"
  376. async def test_mark_and_delete_notification_occurrence(client):
  377. read_resp = notifications_pb2.MarkNotificationReadResponse()
  378. read_resp.occurrence.id = "n1"
  379. read_resp.occurrence.unread = False
  380. _mock_method(client, "notifications", "mark_notification_read", read_resp)
  381. del_resp = notifications_pb2.DeleteNotificationOccurrenceResponse(deleted=True)
  382. _mock_method(client, "notifications", "delete_notification_occurrence", del_resp)
  383. all_resp = notifications_pb2.DeleteAllNotificationOccurrencesResponse(deleted_count=3)
  384. _mock_method(client, "notifications", "delete_all_notification_occurrences", all_resp)
  385. async with client:
  386. occ = await client.mark_notification_read("n1")
  387. assert occ is not None and occ.unread is False
  388. assert await client.delete_notification_occurrence("n1") is True
  389. assert await client.delete_all_notification_occurrences() == 3
  390. async def test_notification_preference(client):
  391. resp = notification_preferences_pb2.UpdateNotificationPreferenceResponse()
  392. resp.preference.level = "NOTIFICATION_LEVEL_MUTED"
  393. resp.preference.effective_level = "NOTIFICATION_LEVEL_MUTED"
  394. _mock_method(
  395. client,
  396. "notification_prefs",
  397. "update_room_notification_preference",
  398. resp,
  399. )
  400. async with client:
  401. pref = await client.update_room_notification_preference("r1", NotificationLevel.MUTED)
  402. assert pref.level is NotificationLevel.MUTED
  403. assert pref.effective_level is NotificationLevel.MUTED
  404. # --- Push -------------------------------------------------------------
  405. async def test_subscribe_push(client):
  406. resp = push_notifications_pb2.SubscribePushResponse(subscribed=True)
  407. _mock_method(client, "push", "subscribe", resp)
  408. async with client:
  409. assert await client.subscribe_push("https://push", "k", "a") is True
  410. # --- Voice calls ------------------------------------------------------
  411. async def test_active_calls(client):
  412. resp = voice_calls_pb2.ListActiveCallsResponse()
  413. call = resp.calls.add()
  414. call.call_id = "c1"
  415. call.room.id = "r1"
  416. call.room.name = "voice"
  417. p = call.participants.add()
  418. p.user.id = "u1"
  419. p.user.login = "alice"
  420. p.user.display_name = "Alice"
  421. _mock_method(client, "voice_calls", "list_active_calls", resp)
  422. async with client:
  423. calls = await client.list_active_calls()
  424. assert len(calls) == 1
  425. assert calls[0].call_id == "c1"
  426. assert calls[0].room is not None
  427. assert calls[0].room.name == "voice"
  428. # --- Assets -----------------------------------------------------------
  429. async def test_get_asset_with_thumbnail(client):
  430. resp = attachments_pb2.GetAssetResponse()
  431. resp.asset.id = "a1"
  432. resp.asset.filename = "pic.png"
  433. resp.asset.content_type = "image/png"
  434. resp.asset.size = 1024
  435. resp.asset.asset_url.url = "https://cdn/pic.png"
  436. mock = _mock_method(client, "assets", "get_asset", resp)
  437. async with client:
  438. asset = await client.get_asset(
  439. "r1",
  440. "a1",
  441. thumbnail=ImageTransformOptions(128, 128, ImageFitMode.COVER),
  442. )
  443. assert asset is not None
  444. assert asset.filename == "pic.png"
  445. assert asset.asset_url is not None
  446. assert asset.asset_url.url == "https://cdn/pic.png"
  447. # verify the request encoded thumbnail options
  448. req = mock.call_args.args[0]
  449. assert req.thumbnail.width == 128
  450. assert req.thumbnail.height == 128
  451. # --- Roles ------------------------------------------------------------
  452. async def test_list_roles(client):
  453. resp = roles_pb2.ListRolesResponse()
  454. r1 = resp.roles.add()
  455. r1.name = "everyone"
  456. r1.display_name = "Everyone"
  457. r1.is_system = True
  458. r1.position = 0
  459. r2 = resp.roles.add()
  460. r2.name = "admin"
  461. r2.display_name = "Admin"
  462. r2.position = 10
  463. _mock_method(client, "roles", "list_roles", resp)
  464. async with client:
  465. roles = await client.list_roles()
  466. assert [r.name for r in roles] == ["everyone", "admin"]
  467. assert roles[0].is_system is True
  468. # --- Asset uploads ---------------------------------------------------
  469. async def test_asset_upload_end_to_end(client, tmp_path):
  470. import hashlib
  471. payload = b"hello world" * 100
  472. file = tmp_path / "greet.txt"
  473. file.write_bytes(payload)
  474. sha = hashlib.sha256(payload).hexdigest()
  475. create_resp = asset_uploads_pb2.CreateUploadResponse()
  476. create_resp.upload.upload_id = "up1"
  477. create_resp.upload.room_id = "r1"
  478. create_resp.upload.status = "ASSET_UPLOAD_STATUS_OPEN"
  479. create_resp.upload.committed_offset = 0
  480. create_resp.upload.size = len(payload)
  481. create_resp.upload.max_chunk_size = 4096
  482. create_resp.upload.sha256 = sha
  483. create_mock = _mock_method(client, "asset_uploads", "create_upload", create_resp)
  484. chunk_resp = asset_uploads_pb2.UploadChunkResponse()
  485. chunk_resp.upload.upload_id = "up1"
  486. chunk_resp.upload.room_id = "r1"
  487. chunk_resp.upload.status = "ASSET_UPLOAD_STATUS_OPEN"
  488. chunk_resp.upload.committed_offset = len(payload)
  489. chunk_resp.upload.size = len(payload)
  490. chunk_resp.upload.max_chunk_size = 4096
  491. chunk_resp.upload.sha256 = sha
  492. _mock_method(client, "asset_uploads", "upload_chunk", chunk_resp)
  493. complete_resp = asset_uploads_pb2.CompleteUploadResponse()
  494. complete_resp.upload.upload_id = "up1"
  495. complete_resp.upload.room_id = "r1"
  496. complete_resp.upload.status = "ASSET_UPLOAD_STATUS_COMPLETED"
  497. complete_resp.upload.committed_offset = len(payload)
  498. complete_resp.upload.size = len(payload)
  499. complete_resp.upload.sha256 = sha
  500. complete_resp.upload.asset_id = "a1"
  501. complete_resp.asset.id = "a1"
  502. complete_resp.asset.filename = "greet.txt"
  503. complete_resp.asset.content_type = "text/plain"
  504. complete_resp.asset.size = len(payload)
  505. _mock_method(client, "asset_uploads", "complete_upload", complete_resp)
  506. async with client:
  507. asset = await client.upload_attachment("r1", file, content_type="text/plain")
  508. assert asset.id == "a1"
  509. assert asset.filename == "greet.txt"
  510. create_req = create_mock.call_args.args[0]
  511. assert create_req.sha256 == sha
  512. assert create_req.size == len(payload)
  513. # --- External identities --------------------------------------------
  514. async def test_list_external_identities(client):
  515. resp = external_identities_pb2.ListExternalIdentitiesResponse()
  516. p = resp.providers.add()
  517. p.provider.id = "github"
  518. p.provider.type = "github"
  519. p.provider.label = "GitHub"
  520. p.provider.login_url = "/auth/github/login"
  521. p.link_url = "/auth/github/link"
  522. p.linked = True
  523. p.linked_identity_subject_hash = "sha_xyz"
  524. li = resp.linked_identities.add()
  525. li.provider_id = "github"
  526. li.provider_type = "github"
  527. li.provider_label = "GitHub"
  528. li.subject_hash = "sha_xyz"
  529. _mock_method(client, "account", "list_external_identities", resp)
  530. async with client:
  531. providers, linked = await client.list_external_identities()
  532. assert providers[0].linked is True
  533. assert providers[0].provider is not None
  534. assert providers[0].provider.id == "github"
  535. assert linked[0].subject_hash == "sha_xyz"
  536. # --- Admin (structural smoke tests) ----------------------------------
  537. async def test_admin_list_room_groups(client):
  538. resp = room_layout_pb2.ListRoomGroupsResponse()
  539. g = resp.groups.add()
  540. g.id = "g1"
  541. g.name = "General"
  542. room_item = g.items.add()
  543. room_item.room.id = "r1"
  544. room_item.room.kind = "ROOM_KIND_CHANNEL"
  545. room_item.room.name = "chat"
  546. link_item = g.items.add()
  547. link_item.sidebar_link.id = "sl1"
  548. link_item.sidebar_link.label = "Docs"
  549. link_item.sidebar_link.url = "https://x"
  550. g.can_create_room = True
  551. _mock_method(client, "admin_room_layout", "list_room_groups", resp)
  552. async with client:
  553. groups = await client.admin_list_room_groups()
  554. assert len(groups) == 1
  555. assert groups[0].name == "General"
  556. assert groups[0].rooms[0].id == "r1"
  557. assert groups[0].sidebar_links[0].label == "Docs"
  558. assert groups[0].can_create_room is True
  559. async def test_admin_create_sidebar_link(client):
  560. resp = room_layout_pb2.CreateSidebarLinkResponse()
  561. resp.sidebar_link.id = "sl1"
  562. resp.sidebar_link.label = "Docs"
  563. resp.sidebar_link.url = "https://x"
  564. _mock_method(client, "admin_room_layout", "create_sidebar_link", resp)
  565. async with client:
  566. result = await client.admin_create_sidebar_link("g1", "Docs", "https://x")
  567. assert result == {"id": "sl1", "label": "Docs", "url": "https://x"}
  568. async def test_admin_update_server_config(client):
  569. resp = admin_server_pb2.UpdateServerConfigResponse()
  570. resp.config.server_name = "MyServer"
  571. resp.config.motd = "hi"
  572. resp.public_profile.name = "MyServer"
  573. resp.public_profile.version = "0.4.2"
  574. _mock_method(client, "admin_server", "update_server_config", resp)
  575. async with client:
  576. config, profile = await client.admin_update_server_config(server_name="MyServer", motd="hi")
  577. assert config.server_name == "MyServer"
  578. assert profile.version == "0.4.2"
  579. # --- Neighbors (0.5.0-alpha.3) -----------------------------------------
  580. async def test_list_neighbors_public(client):
  581. resp = discovery_server_pb2.ListNeighborsResponse()
  582. resp.origins.extend(["https://a.example", "https://b.example"])
  583. _mock_method(client, "server_discovery", "list_neighbors", resp)
  584. async with client:
  585. origins = await client.list_neighbors()
  586. assert origins == ["https://a.example", "https://b.example"]
  587. async def test_admin_neighbor_crud(client):
  588. list_resp = admin_server_pb2.ListNeighborsResponse()
  589. n = list_resp.neighbors.add()
  590. n.id = "n1"
  591. n.origin = "https://a.example"
  592. n.revision = "rev1"
  593. _mock_method(client, "admin_server", "list_neighbors", list_resp)
  594. get_resp = admin_server_pb2.GetNeighborResponse()
  595. get_resp.neighbor.id = "n1"
  596. get_resp.neighbor.origin = "https://a.example"
  597. get_resp.neighbor.revision = "rev1"
  598. _mock_method(client, "admin_server", "get_neighbor", get_resp)
  599. create_resp = admin_server_pb2.CreateNeighborResponse()
  600. create_resp.neighbor.id = "n2"
  601. create_resp.neighbor.origin = "https://b.example"
  602. create_resp.neighbor.revision = "rev2"
  603. _mock_method(client, "admin_server", "create_neighbor", create_resp)
  604. update_resp = admin_server_pb2.UpdateNeighborResponse()
  605. update_resp.neighbor.id = "n2"
  606. update_resp.neighbor.origin = "https://b2.example"
  607. update_resp.neighbor.revision = "rev3"
  608. _mock_method(client, "admin_server", "update_neighbor", update_resp)
  609. del_resp = admin_server_pb2.DeleteNeighborResponse()
  610. _mock_method(client, "admin_server", "delete_neighbor", del_resp)
  611. async with client:
  612. listed = await client.admin_list_neighbors()
  613. assert [x.origin for x in listed] == ["https://a.example"]
  614. got = await client.admin_get_neighbor("n1")
  615. assert got is not None and got.revision == "rev1"
  616. created = await client.admin_create_neighbor("https://b.example")
  617. assert created.id == "n2" and created.revision == "rev2"
  618. updated = await client.admin_update_neighbor("n2", "https://b2.example", "rev2")
  619. assert updated.origin == "https://b2.example" and updated.revision == "rev3"
  620. await client.admin_delete_neighbor("n2", "rev3")
  621. async def test_update_settings_share_timezone(client):
  622. resp = account_pb2.UpdateSettingsResponse()
  623. resp.settings.timezone = "Europe/Berlin"
  624. resp.settings.share_timezone = True
  625. _mock_method(client, "account", "update_settings", resp)
  626. async with client:
  627. settings = await client.update_settings(timezone="Europe/Berlin", share_timezone=True)
  628. assert settings.timezone == "Europe/Berlin"
  629. assert settings.share_timezone is True