test_client.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  1. """Tests for ChattoClient against mocked Connect endpoints."""
  2. from __future__ import annotations
  3. import httpx
  4. import pytest
  5. import respx
  6. from chattolib.client import ChattoClient
  7. from chattolib.exceptions import ChattoAuthError, ChattoConnectError
  8. from chattolib.types import (
  9. ImageFitMode,
  10. ImageTransformOptions,
  11. NotificationLevel,
  12. PresenceStatus,
  13. RoomKind,
  14. TimeFormat,
  15. )
  16. BASE = "https://chat.chatto.run"
  17. CONNECT = "/api/connect"
  18. API_V1 = "chatto.api.v1"
  19. ADMIN_V1 = "chatto.admin.v1"
  20. DISCOVERY_V1 = "chatto.discovery.v1"
  21. def _mount(api: respx.Router, service: str, method: str) -> respx.Route:
  22. return api.post(f"{CONNECT}/{service}/{method}")
  23. @pytest.fixture
  24. def mock_api():
  25. with respx.mock(base_url=BASE) as api:
  26. yield api
  27. @pytest.fixture
  28. def client():
  29. return ChattoClient(token="test-token")
  30. # --- Transport-level ----------------------------------------------------
  31. async def test_connect_error(mock_api, client):
  32. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  33. return_value=httpx.Response(
  34. 403,
  35. headers={"content-type": "application/json"},
  36. json={"code": "permission_denied", "message": "Nope"},
  37. )
  38. )
  39. async with client:
  40. with pytest.raises(ChattoConnectError) as exc:
  41. await client.me()
  42. assert exc.value.code == "permission_denied"
  43. assert exc.value.status_code == 403
  44. async def test_auth_error(mock_api, client):
  45. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  46. return_value=httpx.Response(401)
  47. )
  48. async with client:
  49. with pytest.raises(ChattoAuthError):
  50. await client.me()
  51. # --- Login flow ---------------------------------------------------------
  52. async def test_login_and_capture_session():
  53. with respx.mock(base_url=BASE) as api:
  54. api.post("/auth/login").mock(
  55. return_value=httpx.Response(
  56. 200,
  57. json={"success": True, "token": "cht_abc123"},
  58. headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
  59. )
  60. )
  61. client = await ChattoClient.login("alice", "password123")
  62. assert client.token == "cht_abc123"
  63. assert client.session_cookie == "xyz"
  64. await client.close()
  65. async def test_login_invalid():
  66. with respx.mock(base_url=BASE) as api:
  67. api.post("/auth/login").mock(
  68. return_value=httpx.Response(401, json={"error": "Invalid credentials"})
  69. )
  70. with pytest.raises(ChattoAuthError, match="Invalid credentials"):
  71. await ChattoClient.login("bad", "creds")
  72. # --- Server discovery ---------------------------------------------------
  73. async def test_get_server(mock_api):
  74. _mount(mock_api, f"{DISCOVERY_V1}.ServerDiscoveryService", "GetServer").mock(
  75. return_value=httpx.Response(
  76. 200,
  77. json={
  78. "profile": {"name": "Chatto HQ", "version": "0.4.2"},
  79. "login": {"directRegistrationEnabled": True, "authorizeUrl": "/oauth"},
  80. },
  81. )
  82. )
  83. async with ChattoClient() as c:
  84. profile, login = await c.get_server()
  85. assert profile.name == "Chatto HQ"
  86. assert profile.version == "0.4.2"
  87. assert login.direct_registration_enabled is True
  88. assert login.authorize_url == "/oauth"
  89. # --- Viewer -------------------------------------------------------------
  90. async def test_me(mock_api, client):
  91. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  92. return_value=httpx.Response(
  93. 200,
  94. json={
  95. "user": {
  96. "profile": {
  97. "id": "u1",
  98. "login": "alice",
  99. "displayName": "Alice",
  100. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  101. },
  102. "hasVerifiedEmail": True,
  103. "settings": {"timezone": "UTC", "timeFormat": "TIME_FORMAT_24_HOUR"},
  104. "hasPassword": True,
  105. }
  106. },
  107. )
  108. )
  109. async with client:
  110. user = await client.me()
  111. assert user.id == "u1"
  112. assert user.login == "alice"
  113. assert user.display_name == "Alice"
  114. assert user.presence_status is PresenceStatus.ONLINE
  115. # --- Room directory ----------------------------------------------------
  116. async def test_list_rooms(mock_api, client):
  117. _mount(mock_api, f"{API_V1}.RoomDirectoryService", "ListRooms").mock(
  118. return_value=httpx.Response(
  119. 200,
  120. json={
  121. "rooms": [
  122. {
  123. "room": {
  124. "id": "r1",
  125. "kind": "ROOM_KIND_CHANNEL",
  126. "name": "general",
  127. "groupId": "g1",
  128. },
  129. "viewerState": {"isMember": True, "hasUnread": False},
  130. }
  131. ]
  132. },
  133. )
  134. )
  135. async with client:
  136. rooms = await client.list_rooms()
  137. assert len(rooms) == 1
  138. assert rooms[0].room is not None
  139. assert rooms[0].room.name == "general"
  140. assert rooms[0].room.kind is RoomKind.CHANNEL
  141. assert rooms[0].viewer_state.is_member is True
  142. # --- Messages ----------------------------------------------------------
  143. async def test_post_message(mock_api, client):
  144. _mount(mock_api, f"{API_V1}.MessageService", "CreateMessage").mock(
  145. return_value=httpx.Response(
  146. 200,
  147. json={
  148. "message": {
  149. "id": "e1",
  150. "roomId": "r1",
  151. "createdAt": "2026-01-01T00:00:00Z",
  152. "actorId": "u1",
  153. "body": "Hello!",
  154. }
  155. },
  156. )
  157. )
  158. async with client:
  159. message = await client.post_message("r1", "Hello!")
  160. assert message.id == "e1"
  161. assert message.body == "Hello!"
  162. assert message.room_id == "r1"
  163. async def test_update_message(mock_api, client):
  164. _mount(mock_api, f"{API_V1}.MessageService", "UpdateMessage").mock(
  165. return_value=httpx.Response(
  166. 200,
  167. json={
  168. "message": {
  169. "id": "e1",
  170. "roomId": "r1",
  171. "createdAt": "2026-01-01T00:00:00Z",
  172. "actorId": "u1",
  173. "body": "edited",
  174. "updatedAt": "2026-01-01T00:01:00Z",
  175. }
  176. },
  177. )
  178. )
  179. async with client:
  180. m = await client.update_message("r1", "e1", body="edited")
  181. assert m.body == "edited"
  182. assert m.updated_at is not None
  183. async def test_delete_message(mock_api, client):
  184. _mount(mock_api, f"{API_V1}.MessageService", "DeleteMessage").mock(
  185. return_value=httpx.Response(200, json={"deleted": True})
  186. )
  187. async with client:
  188. assert await client.delete_message("r1", "e1") is True
  189. async def test_add_reaction(mock_api, client):
  190. _mount(mock_api, f"{API_V1}.MessageService", "AddReaction").mock(
  191. return_value=httpx.Response(
  192. 200,
  193. json={"added": True, "reaction": {"emoji": "thumbsup", "count": 1}},
  194. )
  195. )
  196. async with client:
  197. assert await client.add_reaction("r1", "e1", "thumbsup") is True
  198. async def test_remove_reaction(mock_api, client):
  199. _mount(mock_api, f"{API_V1}.MessageService", "RemoveReaction").mock(
  200. return_value=httpx.Response(200, json={"removed": True})
  201. )
  202. async with client:
  203. assert await client.remove_reaction("r1", "e1", "thumbsup") is True
  204. # --- Room lifecycle ---------------------------------------------------
  205. async def test_create_room(mock_api, client):
  206. _mount(mock_api, f"{API_V1}.RoomService", "CreateRoom").mock(
  207. return_value=httpx.Response(
  208. 200,
  209. json={
  210. "room": {
  211. "id": "r1",
  212. "kind": "ROOM_KIND_CHANNEL",
  213. "name": "general",
  214. "groupId": "g1",
  215. }
  216. },
  217. )
  218. )
  219. async with client:
  220. room = await client.create_room("general", "g1")
  221. assert room.id == "r1"
  222. assert room.name == "general"
  223. assert room.group_id == "g1"
  224. async def test_archive_room(mock_api, client):
  225. _mount(mock_api, f"{API_V1}.RoomService", "ArchiveRoom").mock(
  226. return_value=httpx.Response(
  227. 200,
  228. json={"room": {"id": "r1", "name": "general", "archived": True}},
  229. )
  230. )
  231. async with client:
  232. room = await client.archive_room("r1")
  233. assert room.archived is True
  234. async def test_ban_member(mock_api, client):
  235. _mount(mock_api, f"{API_V1}.RoomService", "BanMember").mock(
  236. return_value=httpx.Response(200, json={"banned": True})
  237. )
  238. async with client:
  239. assert await client.ban_member("r1", "u2", "spam") is True
  240. async def test_start_dm(mock_api, client):
  241. _mount(mock_api, f"{API_V1}.RoomService", "StartDM").mock(
  242. return_value=httpx.Response(
  243. 200, json={"room": {"id": "r1", "kind": "ROOM_KIND_DM"}}
  244. )
  245. )
  246. async with client:
  247. room = await client.start_dm(["u1", "u2"])
  248. assert room.id == "r1"
  249. assert room.kind is RoomKind.DM
  250. # --- Room timeline ----------------------------------------------------
  251. async def test_get_room_events(mock_api, client):
  252. _mount(mock_api, f"{API_V1}.RoomService", "GetRoomEvents").mock(
  253. return_value=httpx.Response(
  254. 200,
  255. json={
  256. "page": {
  257. "events": [
  258. {
  259. "id": "e1",
  260. "createdAt": "2026-01-01T00:00:00Z",
  261. "actorId": "u1",
  262. "messagePosted": {
  263. "message": {
  264. "id": "e1",
  265. "roomId": "r1",
  266. "createdAt": "2026-01-01T00:00:00Z",
  267. "actorId": "u1",
  268. "body": "hi",
  269. }
  270. },
  271. }
  272. ],
  273. "hasOlder": False,
  274. "hasNewer": False,
  275. "startCursor": "cA",
  276. "endCursor": "cB",
  277. "includes": {
  278. "users": {
  279. "u1": {
  280. "id": "u1",
  281. "login": "alice",
  282. "displayName": "Alice",
  283. }
  284. }
  285. },
  286. }
  287. },
  288. )
  289. )
  290. async with client:
  291. page = await client.get_room_events("r1", limit=50)
  292. assert len(page.events) == 1
  293. assert page.events[0].kind == "message_posted"
  294. assert page.events[0].message is not None
  295. assert page.events[0].message.body == "hi"
  296. assert page.users_by_id["u1"].login == "alice"
  297. async def test_mark_room_as_read(mock_api, client):
  298. _mount(mock_api, f"{API_V1}.RoomService", "MarkRoomAsRead").mock(
  299. return_value=httpx.Response(
  300. 200,
  301. json={
  302. "lastReadAt": "2026-01-01T00:00:00Z",
  303. "previousLastReadAt": "2025-12-31T00:00:00Z",
  304. },
  305. )
  306. )
  307. async with client:
  308. last, previous = await client.mark_room_as_read("r1", up_to_event_id="e1")
  309. assert last is not None
  310. assert previous is not None
  311. # --- Profile / account ------------------------------------------------
  312. async def test_update_profile(mock_api, client):
  313. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdateProfile").mock(
  314. return_value=httpx.Response(
  315. 200,
  316. json={
  317. "user": {
  318. "id": "u1",
  319. "login": "newname",
  320. "displayName": "New Name",
  321. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  322. }
  323. },
  324. )
  325. )
  326. async with client:
  327. user = await client.update_profile(login="newname", display_name="New Name")
  328. assert user.login == "newname"
  329. assert user.display_name == "New Name"
  330. async def test_upload_avatar(mock_api, client, tmp_path):
  331. _mount(mock_api, f"{API_V1}.MyAccountService", "UploadAvatar").mock(
  332. return_value=httpx.Response(
  333. 200,
  334. json={
  335. "user": {
  336. "id": "u1",
  337. "login": "alice",
  338. "displayName": "Alice",
  339. "avatarUrl": "https://example.com/avatar.jpg",
  340. }
  341. },
  342. )
  343. )
  344. avatar = tmp_path / "avatar.png"
  345. avatar.write_bytes(b"\x89PNGfake")
  346. async with client:
  347. user = await client.upload_avatar(str(avatar), content_type="image/png")
  348. assert user.avatar_url == "https://example.com/avatar.jpg"
  349. async def test_update_presence(mock_api, client):
  350. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdatePresence").mock(
  351. return_value=httpx.Response(
  352. 200, json={"status": "PRESENCE_STATUS_ONLINE"}
  353. )
  354. )
  355. async with client:
  356. result = await client.update_presence(PresenceStatus.ONLINE)
  357. assert result is PresenceStatus.ONLINE
  358. async def test_update_presence_offline_rejected(client):
  359. async with client:
  360. with pytest.raises(ValueError):
  361. await client.update_presence(PresenceStatus.OFFLINE)
  362. async def test_update_settings(mock_api, client):
  363. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdateSettings").mock(
  364. return_value=httpx.Response(
  365. 200,
  366. json={
  367. "settings": {"timezone": "Europe/Rome", "timeFormat": "TIME_FORMAT_24_HOUR"}
  368. },
  369. )
  370. )
  371. async with client:
  372. settings = await client.update_settings(
  373. timezone="Europe/Rome", time_format=TimeFormat.HOUR_24
  374. )
  375. assert settings.timezone == "Europe/Rome"
  376. assert settings.time_format is TimeFormat.HOUR_24
  377. # --- Users -------------------------------------------------------------
  378. async def test_get_user_by_id(mock_api, client):
  379. _mount(mock_api, f"{API_V1}.UserService", "GetUser").mock(
  380. return_value=httpx.Response(
  381. 200,
  382. json={
  383. "user": {
  384. "user": {
  385. "id": "u1",
  386. "login": "alice",
  387. "displayName": "Alice",
  388. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  389. },
  390. "roles": ["everyone"],
  391. }
  392. },
  393. )
  394. )
  395. async with client:
  396. member = await client.get_user(user_id="u1")
  397. assert member is not None
  398. assert member.user is not None
  399. assert member.user.login == "alice"
  400. assert "everyone" in member.roles
  401. async def test_get_user_requires_one_target(client):
  402. with pytest.raises(ValueError):
  403. await client.get_user()
  404. with pytest.raises(ValueError):
  405. await client.get_user(user_id="u1", login="alice")
  406. # --- Threads ----------------------------------------------------------
  407. async def test_list_followed_threads(mock_api, client):
  408. _mount(mock_api, f"{API_V1}.ThreadService", "ListFollowedThreads").mock(
  409. return_value=httpx.Response(
  410. 200,
  411. json={
  412. "threads": [
  413. {
  414. "room": {"id": "r1", "name": "general"},
  415. "rootMessage": {
  416. "id": "e1",
  417. "roomId": "r1",
  418. "createdAt": "2026-01-01T00:00:00Z",
  419. "actorId": "u1",
  420. "body": "root",
  421. },
  422. "thread": {
  423. "threadRootEventId": "e1",
  424. "replyCount": 3,
  425. "lastReplyAt": "2026-01-02T00:00:00Z",
  426. "viewerState": {"isFollowing": True, "hasUnread": False},
  427. },
  428. }
  429. ],
  430. "page": {"totalCount": 1, "hasMore": False},
  431. },
  432. )
  433. )
  434. async with client:
  435. page = await client.list_followed_threads()
  436. assert page.page.total_count == 1
  437. assert page.threads[0].thread is not None
  438. assert page.threads[0].thread.reply_count == 3
  439. assert page.threads[0].thread.is_following is True
  440. async def test_follow_and_unfollow_thread(mock_api, client):
  441. _mount(mock_api, f"{API_V1}.ThreadService", "FollowThread").mock(
  442. return_value=httpx.Response(200, json={"following": True})
  443. )
  444. _mount(mock_api, f"{API_V1}.ThreadService", "UnfollowThread").mock(
  445. return_value=httpx.Response(200, json={"following": False})
  446. )
  447. async with client:
  448. assert await client.follow_thread("r1", "e1") is True
  449. assert await client.unfollow_thread("r1", "e1") is False
  450. # --- Notifications ----------------------------------------------------
  451. async def test_list_notifications(mock_api, client):
  452. _mount(mock_api, f"{API_V1}.NotificationService", "ListNotifications").mock(
  453. return_value=httpx.Response(
  454. 200,
  455. json={
  456. "notifications": [
  457. {
  458. "id": "n1",
  459. "createdAt": "2026-01-01T00:00:00Z",
  460. "actor": {
  461. "id": "u1",
  462. "login": "alice",
  463. "displayName": "Alice",
  464. },
  465. "mention": {
  466. "room": {"id": "r1", "name": "general"},
  467. "eventId": "e1",
  468. },
  469. }
  470. ],
  471. "page": {"totalCount": 1, "hasMore": False},
  472. },
  473. )
  474. )
  475. async with client:
  476. page = await client.list_notifications()
  477. assert page.page.total_count == 1
  478. assert page.notifications[0].kind == "mention"
  479. assert page.notifications[0].room is not None
  480. assert page.notifications[0].room.name == "general"
  481. async def test_dismiss_notification(mock_api, client):
  482. _mount(mock_api, f"{API_V1}.NotificationService", "DismissNotification").mock(
  483. return_value=httpx.Response(200, json={"dismissed": True})
  484. )
  485. async with client:
  486. assert await client.dismiss_notification("n1") is True
  487. async def test_notification_preference(mock_api, client):
  488. _mount(
  489. mock_api,
  490. f"{API_V1}.NotificationPreferencesService",
  491. "UpdateRoomNotificationPreference",
  492. ).mock(
  493. return_value=httpx.Response(
  494. 200,
  495. json={
  496. "preference": {
  497. "level": "NOTIFICATION_LEVEL_MUTED",
  498. "effectiveLevel": "NOTIFICATION_LEVEL_MUTED",
  499. }
  500. },
  501. )
  502. )
  503. async with client:
  504. pref = await client.update_room_notification_preference(
  505. "r1", NotificationLevel.MUTED
  506. )
  507. assert pref.level is NotificationLevel.MUTED
  508. assert pref.effective_level is NotificationLevel.MUTED
  509. # --- Push -------------------------------------------------------------
  510. async def test_subscribe_push(mock_api, client):
  511. _mount(mock_api, f"{API_V1}.PushNotificationService", "Subscribe").mock(
  512. return_value=httpx.Response(200, json={"subscribed": True})
  513. )
  514. async with client:
  515. assert await client.subscribe_push("https://push", "k", "a") is True
  516. # --- Voice calls ------------------------------------------------------
  517. async def test_active_calls(mock_api, client):
  518. _mount(mock_api, f"{API_V1}.VoiceCallService", "ListActiveCalls").mock(
  519. return_value=httpx.Response(
  520. 200,
  521. json={
  522. "calls": [
  523. {
  524. "callId": "c1",
  525. "room": {"id": "r1", "name": "voice"},
  526. "participants": [{"userId": "u1"}],
  527. }
  528. ]
  529. },
  530. )
  531. )
  532. async with client:
  533. calls = await client.list_active_calls()
  534. assert len(calls) == 1
  535. assert calls[0].call_id == "c1"
  536. assert calls[0].room is not None
  537. assert calls[0].room.name == "voice"
  538. # --- Assets -----------------------------------------------------------
  539. async def test_get_asset_with_thumbnail(mock_api, client):
  540. route = _mount(mock_api, f"{API_V1}.AssetService", "GetAsset").mock(
  541. return_value=httpx.Response(
  542. 200,
  543. json={
  544. "asset": {
  545. "id": "a1",
  546. "filename": "pic.png",
  547. "contentType": "image/png",
  548. "size": 1024,
  549. "assetUrl": {"url": "https://cdn/pic.png"},
  550. }
  551. },
  552. )
  553. )
  554. async with client:
  555. asset = await client.get_asset(
  556. "r1",
  557. "a1",
  558. thumbnail=ImageTransformOptions(128, 128, ImageFitMode.COVER),
  559. )
  560. assert asset is not None
  561. assert asset.filename == "pic.png"
  562. assert asset.asset_url is not None
  563. assert asset.asset_url.url == "https://cdn/pic.png"
  564. # ensure the request encoded thumbnail options with the wire enum
  565. body = route.calls.last.request.content
  566. assert b'"IMAGE_FIT_MODE_COVER"' in body
  567. # --- Roles ------------------------------------------------------------
  568. async def test_list_roles(mock_api, client):
  569. _mount(mock_api, f"{API_V1}.RoleService", "ListRoles").mock(
  570. return_value=httpx.Response(
  571. 200,
  572. json={
  573. "roles": [
  574. {
  575. "name": "everyone",
  576. "displayName": "Everyone",
  577. "isSystem": True,
  578. "position": 0,
  579. },
  580. {"name": "admin", "displayName": "Admin", "position": 10},
  581. ]
  582. },
  583. )
  584. )
  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(mock_api, client, tmp_path):
  591. import hashlib
  592. payload = b"hello world" * 100 # 1100 bytes; well under the mock chunk size
  593. file = tmp_path / "greet.txt"
  594. file.write_bytes(payload)
  595. sha = hashlib.sha256(payload).hexdigest()
  596. create = _mount(mock_api, f"{API_V1}.AssetUploadService", "CreateUpload").mock(
  597. return_value=httpx.Response(
  598. 200,
  599. json={
  600. "upload": {
  601. "uploadId": "up1",
  602. "roomId": "r1",
  603. "status": "ASSET_UPLOAD_STATUS_OPEN",
  604. "committedOffset": 0,
  605. "size": len(payload),
  606. "maxChunkSize": 4096,
  607. "sha256": sha,
  608. }
  609. },
  610. )
  611. )
  612. _mount(mock_api, f"{API_V1}.AssetUploadService", "UploadChunk").mock(
  613. return_value=httpx.Response(
  614. 200,
  615. json={
  616. "upload": {
  617. "uploadId": "up1",
  618. "roomId": "r1",
  619. "status": "ASSET_UPLOAD_STATUS_OPEN",
  620. "committedOffset": len(payload),
  621. "size": len(payload),
  622. "maxChunkSize": 4096,
  623. "sha256": sha,
  624. }
  625. },
  626. )
  627. )
  628. _mount(mock_api, f"{API_V1}.AssetUploadService", "CompleteUpload").mock(
  629. return_value=httpx.Response(
  630. 200,
  631. json={
  632. "upload": {
  633. "uploadId": "up1",
  634. "roomId": "r1",
  635. "status": "ASSET_UPLOAD_STATUS_COMPLETED",
  636. "committedOffset": len(payload),
  637. "size": len(payload),
  638. "sha256": sha,
  639. "assetId": "a1",
  640. },
  641. "asset": {
  642. "id": "a1",
  643. "filename": "greet.txt",
  644. "contentType": "text/plain",
  645. "size": len(payload),
  646. },
  647. },
  648. )
  649. )
  650. async with client:
  651. asset = await client.upload_attachment(
  652. "r1", file, content_type="text/plain"
  653. )
  654. assert asset.id == "a1"
  655. assert asset.filename == "greet.txt"
  656. # CreateUpload should have carried the correct sha and size.
  657. body = create.calls.last.request.content
  658. assert sha.encode() in body
  659. assert str(len(payload)).encode() in body
  660. # --- External identities --------------------------------------------
  661. async def test_list_external_identities(mock_api, client):
  662. _mount(mock_api, f"{API_V1}.MyAccountService", "ListExternalIdentities").mock(
  663. return_value=httpx.Response(
  664. 200,
  665. json={
  666. "providers": [
  667. {
  668. "provider": {
  669. "id": "github",
  670. "type": "github",
  671. "label": "GitHub",
  672. "loginUrl": "/auth/github/login",
  673. },
  674. "linkUrl": "/auth/github/link",
  675. "linked": True,
  676. "linkedIdentitySubjectHash": "sha_xyz",
  677. }
  678. ],
  679. "linkedIdentities": [
  680. {
  681. "providerId": "github",
  682. "providerType": "github",
  683. "providerLabel": "GitHub",
  684. "subjectHash": "sha_xyz",
  685. }
  686. ],
  687. },
  688. )
  689. )
  690. async with client:
  691. providers, linked = await client.list_external_identities()
  692. assert providers[0].linked is True
  693. assert providers[0].provider is not None
  694. assert providers[0].provider.id == "github"
  695. assert linked[0].subject_hash == "sha_xyz"
  696. # --- Admin (structural smoke tests) ----------------------------------
  697. async def test_admin_list_room_groups(mock_api, client):
  698. _mount(mock_api, f"{ADMIN_V1}.AdminRoomLayoutService", "ListRoomGroups").mock(
  699. return_value=httpx.Response(
  700. 200,
  701. json={
  702. "groups": [
  703. {
  704. "id": "g1",
  705. "name": "General",
  706. "items": [
  707. {"room": {"id": "r1", "kind": "ROOM_KIND_CHANNEL", "name": "chat"}},
  708. {"sidebarLink": {"id": "sl1", "label": "Docs", "url": "https://x"}},
  709. ],
  710. "canCreateRoom": True,
  711. }
  712. ]
  713. },
  714. )
  715. )
  716. async with client:
  717. groups = await client.admin_list_room_groups()
  718. assert len(groups) == 1
  719. assert groups[0].name == "General"
  720. assert groups[0].rooms[0].id == "r1"
  721. assert groups[0].sidebar_links[0].label == "Docs"
  722. assert groups[0].can_create_room is True
  723. async def test_admin_create_sidebar_link(mock_api, client):
  724. _mount(mock_api, f"{ADMIN_V1}.AdminRoomLayoutService", "CreateSidebarLink").mock(
  725. return_value=httpx.Response(
  726. 200,
  727. json={"sidebarLink": {"id": "sl1", "label": "Docs", "url": "https://x"}},
  728. )
  729. )
  730. async with client:
  731. result = await client.admin_create_sidebar_link("g1", "Docs", "https://x")
  732. assert result == {"id": "sl1", "label": "Docs", "url": "https://x"}
  733. async def test_admin_update_server_config(mock_api, client):
  734. _mount(mock_api, f"{ADMIN_V1}.AdminServerService", "UpdateServerConfig").mock(
  735. return_value=httpx.Response(
  736. 200,
  737. json={
  738. "config": {"serverName": "MyServer", "motd": "hi"},
  739. "publicProfile": {"name": "MyServer", "version": "0.4.2"},
  740. },
  741. )
  742. )
  743. async with client:
  744. config, profile = await client.admin_update_server_config(
  745. server_name="MyServer", motd="hi"
  746. )
  747. assert config.server_name == "MyServer"
  748. assert profile.version == "0.4.2"