test_client.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  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. DISCOVERY_V1 = "chatto.discovery.v1"
  20. def _mount(api: respx.Router, service: str, method: str) -> respx.Route:
  21. return api.post(f"{CONNECT}/{service}/{method}")
  22. @pytest.fixture
  23. def mock_api():
  24. with respx.mock(base_url=BASE) as api:
  25. yield api
  26. @pytest.fixture
  27. def client():
  28. return ChattoClient(token="test-token")
  29. # --- Transport-level ----------------------------------------------------
  30. async def test_connect_error(mock_api, client):
  31. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  32. return_value=httpx.Response(
  33. 403,
  34. headers={"content-type": "application/json"},
  35. json={"code": "permission_denied", "message": "Nope"},
  36. )
  37. )
  38. async with client:
  39. with pytest.raises(ChattoConnectError) as exc:
  40. await client.me()
  41. assert exc.value.code == "permission_denied"
  42. assert exc.value.status_code == 403
  43. async def test_auth_error(mock_api, client):
  44. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  45. return_value=httpx.Response(401)
  46. )
  47. async with client:
  48. with pytest.raises(ChattoAuthError):
  49. await client.me()
  50. # --- Login flow ---------------------------------------------------------
  51. async def test_login_and_capture_session():
  52. with respx.mock(base_url=BASE) as api:
  53. api.post("/auth/login").mock(
  54. return_value=httpx.Response(
  55. 200,
  56. json={"success": True, "token": "cht_abc123"},
  57. headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
  58. )
  59. )
  60. client = await ChattoClient.login("alice", "password123")
  61. assert client.token == "cht_abc123"
  62. assert client.session_cookie == "xyz"
  63. await client.close()
  64. async def test_login_invalid():
  65. with respx.mock(base_url=BASE) as api:
  66. api.post("/auth/login").mock(
  67. return_value=httpx.Response(401, json={"error": "Invalid credentials"})
  68. )
  69. with pytest.raises(ChattoAuthError, match="Invalid credentials"):
  70. await ChattoClient.login("bad", "creds")
  71. # --- Server discovery ---------------------------------------------------
  72. async def test_get_server(mock_api):
  73. _mount(mock_api, f"{DISCOVERY_V1}.ServerDiscoveryService", "GetServer").mock(
  74. return_value=httpx.Response(
  75. 200,
  76. json={
  77. "profile": {"name": "Chatto HQ", "version": "0.4.2"},
  78. "login": {"directRegistrationEnabled": True, "authorizeUrl": "/oauth"},
  79. },
  80. )
  81. )
  82. async with ChattoClient() as c:
  83. profile, login = await c.get_server()
  84. assert profile.name == "Chatto HQ"
  85. assert profile.version == "0.4.2"
  86. assert login.direct_registration_enabled is True
  87. assert login.authorize_url == "/oauth"
  88. # --- Viewer -------------------------------------------------------------
  89. async def test_me(mock_api, client):
  90. _mount(mock_api, f"{API_V1}.ViewerService", "GetViewer").mock(
  91. return_value=httpx.Response(
  92. 200,
  93. json={
  94. "user": {
  95. "profile": {
  96. "id": "u1",
  97. "login": "alice",
  98. "displayName": "Alice",
  99. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  100. },
  101. "hasVerifiedEmail": True,
  102. "settings": {"timezone": "UTC", "timeFormat": "TIME_FORMAT_24_HOUR"},
  103. "hasPassword": True,
  104. }
  105. },
  106. )
  107. )
  108. async with client:
  109. user = await client.me()
  110. assert user.id == "u1"
  111. assert user.login == "alice"
  112. assert user.display_name == "Alice"
  113. assert user.presence_status is PresenceStatus.ONLINE
  114. # --- Room directory ----------------------------------------------------
  115. async def test_list_rooms(mock_api, client):
  116. _mount(mock_api, f"{API_V1}.RoomDirectoryService", "ListRooms").mock(
  117. return_value=httpx.Response(
  118. 200,
  119. json={
  120. "rooms": [
  121. {
  122. "room": {
  123. "id": "r1",
  124. "kind": "ROOM_KIND_CHANNEL",
  125. "name": "general",
  126. "groupId": "g1",
  127. },
  128. "viewerState": {"isMember": True, "hasUnread": False},
  129. }
  130. ]
  131. },
  132. )
  133. )
  134. async with client:
  135. rooms = await client.list_rooms()
  136. assert len(rooms) == 1
  137. assert rooms[0].room is not None
  138. assert rooms[0].room.name == "general"
  139. assert rooms[0].room.kind is RoomKind.CHANNEL
  140. assert rooms[0].viewer_state.is_member is True
  141. # --- Messages ----------------------------------------------------------
  142. async def test_post_message(mock_api, client):
  143. _mount(mock_api, f"{API_V1}.MessageService", "CreateMessage").mock(
  144. return_value=httpx.Response(
  145. 200,
  146. json={
  147. "message": {
  148. "id": "e1",
  149. "roomId": "r1",
  150. "createdAt": "2026-01-01T00:00:00Z",
  151. "actorId": "u1",
  152. "body": "Hello!",
  153. }
  154. },
  155. )
  156. )
  157. async with client:
  158. message = await client.post_message("r1", "Hello!")
  159. assert message.id == "e1"
  160. assert message.body == "Hello!"
  161. assert message.room_id == "r1"
  162. async def test_update_message(mock_api, client):
  163. _mount(mock_api, f"{API_V1}.MessageService", "UpdateMessage").mock(
  164. return_value=httpx.Response(
  165. 200,
  166. json={
  167. "message": {
  168. "id": "e1",
  169. "roomId": "r1",
  170. "createdAt": "2026-01-01T00:00:00Z",
  171. "actorId": "u1",
  172. "body": "edited",
  173. "updatedAt": "2026-01-01T00:01:00Z",
  174. }
  175. },
  176. )
  177. )
  178. async with client:
  179. m = await client.update_message("r1", "e1", body="edited")
  180. assert m.body == "edited"
  181. assert m.updated_at is not None
  182. async def test_delete_message(mock_api, client):
  183. _mount(mock_api, f"{API_V1}.MessageService", "DeleteMessage").mock(
  184. return_value=httpx.Response(200, json={"deleted": True})
  185. )
  186. async with client:
  187. assert await client.delete_message("r1", "e1") is True
  188. async def test_add_reaction(mock_api, client):
  189. _mount(mock_api, f"{API_V1}.MessageService", "AddReaction").mock(
  190. return_value=httpx.Response(
  191. 200,
  192. json={"added": True, "reaction": {"emoji": "thumbsup", "count": 1}},
  193. )
  194. )
  195. async with client:
  196. assert await client.add_reaction("r1", "e1", "thumbsup") is True
  197. async def test_remove_reaction(mock_api, client):
  198. _mount(mock_api, f"{API_V1}.MessageService", "RemoveReaction").mock(
  199. return_value=httpx.Response(200, json={"removed": True})
  200. )
  201. async with client:
  202. assert await client.remove_reaction("r1", "e1", "thumbsup") is True
  203. # --- Room lifecycle ---------------------------------------------------
  204. async def test_create_room(mock_api, client):
  205. _mount(mock_api, f"{API_V1}.RoomService", "CreateRoom").mock(
  206. return_value=httpx.Response(
  207. 200,
  208. json={
  209. "room": {
  210. "id": "r1",
  211. "kind": "ROOM_KIND_CHANNEL",
  212. "name": "general",
  213. "groupId": "g1",
  214. }
  215. },
  216. )
  217. )
  218. async with client:
  219. room = await client.create_room("general", "g1")
  220. assert room.id == "r1"
  221. assert room.name == "general"
  222. assert room.group_id == "g1"
  223. async def test_archive_room(mock_api, client):
  224. _mount(mock_api, f"{API_V1}.RoomService", "ArchiveRoom").mock(
  225. return_value=httpx.Response(
  226. 200,
  227. json={"room": {"id": "r1", "name": "general", "archived": True}},
  228. )
  229. )
  230. async with client:
  231. room = await client.archive_room("r1")
  232. assert room.archived is True
  233. async def test_ban_member(mock_api, client):
  234. _mount(mock_api, f"{API_V1}.RoomService", "BanMember").mock(
  235. return_value=httpx.Response(200, json={"banned": True})
  236. )
  237. async with client:
  238. assert await client.ban_member("r1", "u2", "spam") is True
  239. async def test_start_dm(mock_api, client):
  240. _mount(mock_api, f"{API_V1}.RoomService", "StartDM").mock(
  241. return_value=httpx.Response(
  242. 200, json={"room": {"id": "r1", "kind": "ROOM_KIND_DM"}}
  243. )
  244. )
  245. async with client:
  246. room = await client.start_dm(["u1", "u2"])
  247. assert room.id == "r1"
  248. assert room.kind is RoomKind.DM
  249. # --- Room timeline ----------------------------------------------------
  250. async def test_get_room_events(mock_api, client):
  251. _mount(mock_api, f"{API_V1}.RoomService", "GetRoomEvents").mock(
  252. return_value=httpx.Response(
  253. 200,
  254. json={
  255. "page": {
  256. "events": [
  257. {
  258. "id": "e1",
  259. "createdAt": "2026-01-01T00:00:00Z",
  260. "actorId": "u1",
  261. "messagePosted": {
  262. "message": {
  263. "id": "e1",
  264. "roomId": "r1",
  265. "createdAt": "2026-01-01T00:00:00Z",
  266. "actorId": "u1",
  267. "body": "hi",
  268. }
  269. },
  270. }
  271. ],
  272. "hasOlder": False,
  273. "hasNewer": False,
  274. "startCursor": "cA",
  275. "endCursor": "cB",
  276. "includes": {
  277. "users": {
  278. "u1": {
  279. "id": "u1",
  280. "login": "alice",
  281. "displayName": "Alice",
  282. }
  283. }
  284. },
  285. }
  286. },
  287. )
  288. )
  289. async with client:
  290. page = await client.get_room_events("r1", limit=50)
  291. assert len(page.events) == 1
  292. assert page.events[0].kind == "message_posted"
  293. assert page.events[0].message is not None
  294. assert page.events[0].message.body == "hi"
  295. assert page.users_by_id["u1"].login == "alice"
  296. async def test_mark_room_as_read(mock_api, client):
  297. _mount(mock_api, f"{API_V1}.RoomService", "MarkRoomAsRead").mock(
  298. return_value=httpx.Response(
  299. 200,
  300. json={
  301. "lastReadAt": "2026-01-01T00:00:00Z",
  302. "previousLastReadAt": "2025-12-31T00:00:00Z",
  303. },
  304. )
  305. )
  306. async with client:
  307. last, previous = await client.mark_room_as_read("r1", up_to_event_id="e1")
  308. assert last is not None
  309. assert previous is not None
  310. # --- Profile / account ------------------------------------------------
  311. async def test_update_profile(mock_api, client):
  312. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdateProfile").mock(
  313. return_value=httpx.Response(
  314. 200,
  315. json={
  316. "user": {
  317. "id": "u1",
  318. "login": "newname",
  319. "displayName": "New Name",
  320. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  321. }
  322. },
  323. )
  324. )
  325. async with client:
  326. user = await client.update_profile(login="newname", display_name="New Name")
  327. assert user.login == "newname"
  328. assert user.display_name == "New Name"
  329. async def test_upload_avatar(mock_api, client, tmp_path):
  330. _mount(mock_api, f"{API_V1}.MyAccountService", "UploadAvatar").mock(
  331. return_value=httpx.Response(
  332. 200,
  333. json={
  334. "user": {
  335. "id": "u1",
  336. "login": "alice",
  337. "displayName": "Alice",
  338. "avatarUrl": "https://example.com/avatar.jpg",
  339. }
  340. },
  341. )
  342. )
  343. avatar = tmp_path / "avatar.png"
  344. avatar.write_bytes(b"\x89PNGfake")
  345. async with client:
  346. user = await client.upload_avatar(str(avatar), content_type="image/png")
  347. assert user.avatar_url == "https://example.com/avatar.jpg"
  348. async def test_update_presence(mock_api, client):
  349. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdatePresence").mock(
  350. return_value=httpx.Response(
  351. 200, json={"status": "PRESENCE_STATUS_ONLINE"}
  352. )
  353. )
  354. async with client:
  355. result = await client.update_presence(PresenceStatus.ONLINE)
  356. assert result is PresenceStatus.ONLINE
  357. async def test_update_presence_offline_rejected(client):
  358. async with client:
  359. with pytest.raises(ValueError):
  360. await client.update_presence(PresenceStatus.OFFLINE)
  361. async def test_update_settings(mock_api, client):
  362. _mount(mock_api, f"{API_V1}.MyAccountService", "UpdateSettings").mock(
  363. return_value=httpx.Response(
  364. 200,
  365. json={
  366. "settings": {"timezone": "Europe/Rome", "timeFormat": "TIME_FORMAT_24_HOUR"}
  367. },
  368. )
  369. )
  370. async with client:
  371. settings = await client.update_settings(
  372. timezone="Europe/Rome", time_format=TimeFormat.HOUR_24
  373. )
  374. assert settings.timezone == "Europe/Rome"
  375. assert settings.time_format is TimeFormat.HOUR_24
  376. # --- Users -------------------------------------------------------------
  377. async def test_get_user_by_id(mock_api, client):
  378. _mount(mock_api, f"{API_V1}.UserService", "GetUser").mock(
  379. return_value=httpx.Response(
  380. 200,
  381. json={
  382. "user": {
  383. "user": {
  384. "id": "u1",
  385. "login": "alice",
  386. "displayName": "Alice",
  387. "presenceStatus": "PRESENCE_STATUS_ONLINE",
  388. },
  389. "roles": ["everyone"],
  390. }
  391. },
  392. )
  393. )
  394. async with client:
  395. member = await client.get_user(user_id="u1")
  396. assert member is not None
  397. assert member.user is not None
  398. assert member.user.login == "alice"
  399. assert "everyone" in member.roles
  400. async def test_get_user_requires_one_target(client):
  401. with pytest.raises(ValueError):
  402. await client.get_user()
  403. with pytest.raises(ValueError):
  404. await client.get_user(user_id="u1", login="alice")
  405. # --- Threads ----------------------------------------------------------
  406. async def test_list_followed_threads(mock_api, client):
  407. _mount(mock_api, f"{API_V1}.ThreadService", "ListFollowedThreads").mock(
  408. return_value=httpx.Response(
  409. 200,
  410. json={
  411. "threads": [
  412. {
  413. "room": {"id": "r1", "name": "general"},
  414. "rootMessage": {
  415. "id": "e1",
  416. "roomId": "r1",
  417. "createdAt": "2026-01-01T00:00:00Z",
  418. "actorId": "u1",
  419. "body": "root",
  420. },
  421. "thread": {
  422. "threadRootEventId": "e1",
  423. "replyCount": 3,
  424. "lastReplyAt": "2026-01-02T00:00:00Z",
  425. "viewerState": {"isFollowing": True, "hasUnread": False},
  426. },
  427. }
  428. ],
  429. "page": {"totalCount": 1, "hasMore": False},
  430. },
  431. )
  432. )
  433. async with client:
  434. page = await client.list_followed_threads()
  435. assert page.page.total_count == 1
  436. assert page.threads[0].thread is not None
  437. assert page.threads[0].thread.reply_count == 3
  438. assert page.threads[0].thread.is_following is True
  439. async def test_follow_and_unfollow_thread(mock_api, client):
  440. _mount(mock_api, f"{API_V1}.ThreadService", "FollowThread").mock(
  441. return_value=httpx.Response(200, json={"following": True})
  442. )
  443. _mount(mock_api, f"{API_V1}.ThreadService", "UnfollowThread").mock(
  444. return_value=httpx.Response(200, json={"following": False})
  445. )
  446. async with client:
  447. assert await client.follow_thread("r1", "e1") is True
  448. assert await client.unfollow_thread("r1", "e1") is False
  449. # --- Notifications ----------------------------------------------------
  450. async def test_list_notifications(mock_api, client):
  451. _mount(mock_api, f"{API_V1}.NotificationService", "ListNotifications").mock(
  452. return_value=httpx.Response(
  453. 200,
  454. json={
  455. "notifications": [
  456. {
  457. "id": "n1",
  458. "createdAt": "2026-01-01T00:00:00Z",
  459. "actor": {
  460. "id": "u1",
  461. "login": "alice",
  462. "displayName": "Alice",
  463. },
  464. "mention": {
  465. "room": {"id": "r1", "name": "general"},
  466. "eventId": "e1",
  467. },
  468. }
  469. ],
  470. "page": {"totalCount": 1, "hasMore": False},
  471. },
  472. )
  473. )
  474. async with client:
  475. page = await client.list_notifications()
  476. assert page.page.total_count == 1
  477. assert page.notifications[0].kind == "mention"
  478. assert page.notifications[0].room is not None
  479. assert page.notifications[0].room.name == "general"
  480. async def test_dismiss_notification(mock_api, client):
  481. _mount(mock_api, f"{API_V1}.NotificationService", "DismissNotification").mock(
  482. return_value=httpx.Response(200, json={"dismissed": True})
  483. )
  484. async with client:
  485. assert await client.dismiss_notification("n1") is True
  486. async def test_notification_preference(mock_api, client):
  487. _mount(
  488. mock_api,
  489. f"{API_V1}.NotificationPreferencesService",
  490. "UpdateRoomNotificationPreference",
  491. ).mock(
  492. return_value=httpx.Response(
  493. 200,
  494. json={
  495. "preference": {
  496. "level": "NOTIFICATION_LEVEL_MUTED",
  497. "effectiveLevel": "NOTIFICATION_LEVEL_MUTED",
  498. }
  499. },
  500. )
  501. )
  502. async with client:
  503. pref = await client.update_room_notification_preference(
  504. "r1", NotificationLevel.MUTED
  505. )
  506. assert pref.level is NotificationLevel.MUTED
  507. assert pref.effective_level is NotificationLevel.MUTED
  508. # --- Push -------------------------------------------------------------
  509. async def test_subscribe_push(mock_api, client):
  510. _mount(mock_api, f"{API_V1}.PushNotificationService", "Subscribe").mock(
  511. return_value=httpx.Response(200, json={"subscribed": True})
  512. )
  513. async with client:
  514. assert await client.subscribe_push("https://push", "k", "a") is True
  515. # --- Voice calls ------------------------------------------------------
  516. async def test_active_calls(mock_api, client):
  517. _mount(mock_api, f"{API_V1}.VoiceCallService", "ListActiveCalls").mock(
  518. return_value=httpx.Response(
  519. 200,
  520. json={
  521. "calls": [
  522. {
  523. "callId": "c1",
  524. "room": {"id": "r1", "name": "voice"},
  525. "participants": [{"userId": "u1"}],
  526. }
  527. ]
  528. },
  529. )
  530. )
  531. async with client:
  532. calls = await client.list_active_calls()
  533. assert len(calls) == 1
  534. assert calls[0].call_id == "c1"
  535. assert calls[0].room is not None
  536. assert calls[0].room.name == "voice"
  537. # --- Assets -----------------------------------------------------------
  538. async def test_get_asset_with_thumbnail(mock_api, client):
  539. route = _mount(mock_api, f"{API_V1}.AssetService", "GetAsset").mock(
  540. return_value=httpx.Response(
  541. 200,
  542. json={
  543. "asset": {
  544. "id": "a1",
  545. "filename": "pic.png",
  546. "contentType": "image/png",
  547. "size": 1024,
  548. "assetUrl": {"url": "https://cdn/pic.png"},
  549. }
  550. },
  551. )
  552. )
  553. async with client:
  554. asset = await client.get_asset(
  555. "r1",
  556. "a1",
  557. thumbnail=ImageTransformOptions(128, 128, ImageFitMode.COVER),
  558. )
  559. assert asset is not None
  560. assert asset.filename == "pic.png"
  561. assert asset.asset_url is not None
  562. assert asset.asset_url.url == "https://cdn/pic.png"
  563. # ensure the request encoded thumbnail options with the wire enum
  564. body = route.calls.last.request.content
  565. assert b'"IMAGE_FIT_MODE_COVER"' in body