test_client.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. """Tests for ChattoClient using respx to mock HTTP."""
  2. import httpx
  3. import pytest
  4. import respx
  5. from chattolib.client import ChattoClient
  6. from chattolib.exceptions import ChattoAuthError, ChattoGraphQLError
  7. from chattolib.types import PresenceStatusInput
  8. @pytest.fixture
  9. def mock_api():
  10. with respx.mock(base_url="https://chat.chatto.run") as api:
  11. yield api
  12. @pytest.fixture
  13. def client():
  14. return ChattoClient(token="test-token")
  15. def _gql_response(data: dict) -> httpx.Response:
  16. return httpx.Response(200, json={"data": data})
  17. async def test_me(mock_api, client):
  18. mock_api.post("/api/graphql").mock(
  19. return_value=_gql_response(
  20. {
  21. "viewer": {
  22. "user": {
  23. "id": "u1",
  24. "login": "alice",
  25. "displayName": "Alice",
  26. "createdAt": "2025-01-01T00:00:00",
  27. "avatarUrl": None,
  28. "presenceStatus": "ONLINE",
  29. "settings": None,
  30. }
  31. }
  32. }
  33. )
  34. )
  35. async with client:
  36. user = await client.me()
  37. assert user.id == "u1"
  38. assert user.login == "alice"
  39. assert user.display_name == "Alice"
  40. async def test_rooms(mock_api, client):
  41. mock_api.post("/api/graphql").mock(
  42. return_value=_gql_response(
  43. {
  44. "server": {
  45. "rooms": [
  46. {
  47. "id": "r1",
  48. "type": "CHANNEL",
  49. "name": "general",
  50. "description": None,
  51. "archived": False,
  52. "groupId": "g1",
  53. "hasUnread": False,
  54. }
  55. ]
  56. }
  57. }
  58. )
  59. )
  60. async with client:
  61. rooms = await client.rooms()
  62. assert len(rooms) == 1
  63. assert rooms[0].name == "general"
  64. assert rooms[0].type.value == "CHANNEL"
  65. async def test_graphql_error(mock_api, client):
  66. mock_api.post("/api/graphql").mock(
  67. return_value=httpx.Response(
  68. 200,
  69. json={"errors": [{"message": "Not authorized"}]},
  70. )
  71. )
  72. async with client:
  73. with pytest.raises(ChattoGraphQLError, match="Not authorized"):
  74. await client.me()
  75. async def test_auth_error(mock_api, client):
  76. mock_api.post("/api/graphql").mock(return_value=httpx.Response(401))
  77. async with client:
  78. with pytest.raises(ChattoAuthError):
  79. await client.me()
  80. async def test_post_message(mock_api, client):
  81. mock_api.post("/api/graphql").mock(
  82. return_value=_gql_response(
  83. {"postMessage": {"id": "e1", "createdAt": "2025-01-01T00:00:00"}}
  84. )
  85. )
  86. async with client:
  87. result = await client.post_message("r1", "Hello!")
  88. assert result["id"] == "e1"
  89. async def test_room_events(mock_api, client):
  90. mock_api.post("/api/graphql").mock(
  91. return_value=_gql_response(
  92. {
  93. "room": {
  94. "events": {
  95. "events": [
  96. {
  97. "id": "e1",
  98. "createdAt": "2025-01-01T00:00:00",
  99. "actorId": "u1",
  100. "actor": {
  101. "id": "u1",
  102. "login": "alice",
  103. "displayName": "Alice",
  104. "avatarUrl": None,
  105. "presenceStatus": "ONLINE",
  106. },
  107. "event": {
  108. "roomId": "r1",
  109. "body": "Hello",
  110. "updatedAt": None,
  111. "attachments": [],
  112. "reactions": [],
  113. "inReplyTo": None,
  114. "threadRootEventId": None,
  115. "replyCount": 0,
  116. "lastReplyAt": None,
  117. "echoOfEventId": None,
  118. "echoFromThreadRootEventId": None,
  119. "viewerIsFollowingThread": None,
  120. "linkPreview": None,
  121. },
  122. }
  123. ],
  124. "hasOlder": False,
  125. "hasNewer": False,
  126. "startCursor": None,
  127. "endCursor": None,
  128. }
  129. }
  130. }
  131. )
  132. )
  133. async with client:
  134. page = await client.room_events("r1", limit=10)
  135. assert len(page.events) == 1
  136. assert page.events[0].body == "Hello"
  137. assert page.events[0].actor.login == "alice"
  138. assert page.events[0].thread_root_event_id is None
  139. async def test_login():
  140. with respx.mock(base_url="https://chat.chatto.run") as api:
  141. api.post("/auth/login").mock(
  142. return_value=httpx.Response(
  143. 200,
  144. json={
  145. "success": True,
  146. "token": "cht_abc123",
  147. "user": {"id": "u1", "login": "alice"},
  148. },
  149. headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
  150. )
  151. )
  152. client = await ChattoClient.login("alice", "password123")
  153. assert client._token == "cht_abc123"
  154. assert client._session_cookie == "xyz"
  155. await client.close()
  156. async def test_login_invalid():
  157. with respx.mock(base_url="https://chat.chatto.run") as api:
  158. api.post("/auth/login").mock(
  159. return_value=httpx.Response(401, json={"error": "Invalid credentials"})
  160. )
  161. with pytest.raises(ChattoAuthError, match="Invalid credentials"):
  162. await ChattoClient.login("bad", "creds")
  163. async def test_update_message(mock_api, client):
  164. mock_api.post("/api/graphql").mock(return_value=_gql_response({"updateMessage": True}))
  165. async with client:
  166. result = await client.update_message("r1", "e1", "edited body")
  167. assert result is True
  168. async def test_delete_message(mock_api, client):
  169. mock_api.post("/api/graphql").mock(return_value=_gql_response({"deleteMessage": True}))
  170. async with client:
  171. result = await client.delete_message("r1", "e1")
  172. assert result is True
  173. async def test_add_reaction(mock_api, client):
  174. mock_api.post("/api/graphql").mock(return_value=_gql_response({"addReaction": True}))
  175. async with client:
  176. result = await client.add_reaction("r1", "e1", "thumbsup")
  177. assert result is True
  178. async def test_remove_reaction(mock_api, client):
  179. mock_api.post("/api/graphql").mock(return_value=_gql_response({"removeReaction": True}))
  180. async with client:
  181. result = await client.remove_reaction("r1", "e1", "thumbsup")
  182. assert result is True
  183. async def test_create_room(mock_api, client):
  184. mock_api.post("/api/graphql").mock(
  185. return_value=_gql_response(
  186. {
  187. "createRoom": {
  188. "id": "r1",
  189. "type": "CHANNEL",
  190. "name": "general",
  191. "description": None,
  192. "groupId": "g1",
  193. }
  194. }
  195. )
  196. )
  197. async with client:
  198. room = await client.create_room("general", "g1")
  199. assert room.id == "r1"
  200. assert room.name == "general"
  201. assert room.group_id == "g1"
  202. async def test_update_profile(mock_api, client):
  203. mock_api.post("/api/graphql").mock(
  204. return_value=_gql_response(
  205. {
  206. "updateProfile": {
  207. "id": "u1",
  208. "login": "newname",
  209. "displayName": "New Name",
  210. "avatarUrl": None,
  211. "presenceStatus": "ONLINE",
  212. }
  213. }
  214. )
  215. )
  216. async with client:
  217. user = await client.update_profile("u1", login="newname", display_name="New Name")
  218. assert user.login == "newname"
  219. assert user.display_name == "New Name"
  220. async def test_update_presence(mock_api, client):
  221. mock_api.post("/api/graphql").mock(return_value=_gql_response({"updateMyPresence": True}))
  222. async with client:
  223. result = await client.update_presence(PresenceStatusInput.ONLINE)
  224. assert result is True
  225. async def test_upload_avatar(mock_api, client, tmp_path):
  226. mock_api.post("/api/graphql").mock(
  227. return_value=_gql_response(
  228. {"uploadAvatar": {"id": "u1", "avatarUrl": "https://example.com/avatar.jpg"}}
  229. )
  230. )
  231. avatar = tmp_path / "avatar.jpg"
  232. avatar.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
  233. async with client:
  234. result = await client.upload_avatar(str(avatar), "u1")
  235. assert result["avatarUrl"] == "https://example.com/avatar.jpg"
  236. async def test_user(mock_api, client):
  237. mock_api.post("/api/graphql").mock(
  238. return_value=_gql_response(
  239. {
  240. "user": {
  241. "id": "u1",
  242. "login": "alice",
  243. "displayName": "Alice",
  244. "createdAt": "2025-01-01T00:00:00",
  245. "avatarUrl": None,
  246. "presenceStatus": "ONLINE",
  247. }
  248. }
  249. )
  250. )
  251. async with client:
  252. user = await client.user("u1")
  253. assert user.id == "u1"
  254. assert user.login == "alice"
  255. async def test_user_by_login(mock_api, client):
  256. mock_api.post("/api/graphql").mock(
  257. return_value=_gql_response(
  258. {
  259. "userByLogin": {
  260. "id": "u1",
  261. "login": "alice",
  262. "displayName": "Alice",
  263. "createdAt": None,
  264. "avatarUrl": None,
  265. "presenceStatus": "OFFLINE",
  266. }
  267. }
  268. )
  269. )
  270. async with client:
  271. user = await client.user_by_login("alice")
  272. assert user.login == "alice"
  273. async def test_start_dm(mock_api, client):
  274. mock_api.post("/api/graphql").mock(
  275. return_value=_gql_response({"startDM": {"id": "r1", "name": "DM"}})
  276. )
  277. async with client:
  278. result = await client.start_dm(["u1", "u2"])
  279. assert result["id"] == "r1"
  280. async def test_dismiss_notification(mock_api, client):
  281. mock_api.post("/api/graphql").mock(return_value=_gql_response({"dismissNotification": True}))
  282. async with client:
  283. result = await client.dismiss_notification("n1")
  284. assert result is True
  285. async def test_dismiss_all_notifications(mock_api, client):
  286. mock_api.post("/api/graphql").mock(
  287. return_value=_gql_response({"dismissAllNotifications": True})
  288. )
  289. async with client:
  290. result = await client.dismiss_all_notifications()
  291. assert result is True
  292. async def test_followed_threads(mock_api, client):
  293. mock_api.post("/api/graphql").mock(
  294. return_value=_gql_response(
  295. {
  296. "viewer": {
  297. "followedThreads": {
  298. "threads": [
  299. {
  300. "roomId": "r1",
  301. "threadRootEventId": "e1",
  302. "replyCount": 3,
  303. "lastReplyAt": "2025-01-01T00:00:00",
  304. "hasUnread": True,
  305. }
  306. ],
  307. "totalCount": 1,
  308. "hasMore": False,
  309. },
  310. "hasUnreadFollowedThreads": True,
  311. }
  312. }
  313. )
  314. )
  315. async with client:
  316. page = await client.followed_threads()
  317. assert page.total_count == 1
  318. assert page.threads[0].room_id == "r1"
  319. assert page.threads[0].has_unread is True
  320. async def test_notifications(mock_api, client):
  321. mock_api.post("/api/graphql").mock(
  322. return_value=_gql_response(
  323. {
  324. "viewer": {
  325. "notifications": {
  326. "items": [
  327. {
  328. "id": "n1",
  329. "createdAt": "2025-01-01T00:00:00",
  330. "summary": "Mention",
  331. "actor": None,
  332. "room": {"id": "r1", "name": "general"},
  333. "eventId": "e1",
  334. "threadRootEventId": None,
  335. }
  336. ],
  337. "totalCount": 1,
  338. "hasMore": False,
  339. },
  340. "hasNotifications": True,
  341. }
  342. }
  343. )
  344. )
  345. async with client:
  346. page = await client.notifications()
  347. assert page.total_count == 1
  348. assert page.items[0]["id"] == "n1"
  349. async def test_archive_unarchive_room(mock_api, client):
  350. mock_api.post("/api/graphql").mock(
  351. return_value=_gql_response(
  352. {"archiveRoom": {"id": "r1", "name": "general", "archived": True}}
  353. )
  354. )
  355. async with client:
  356. room = await client.archive_room("r1")
  357. assert room.id == "r1"
  358. assert room.archived is True
  359. async def test_create_room_group(mock_api, client):
  360. mock_api.post("/api/graphql").mock(
  361. return_value=_gql_response(
  362. {"createRoomGroup": {"id": "g1", "name": "Team", "description": ""}}
  363. )
  364. )
  365. async with client:
  366. group = await client.create_room_group("Team")
  367. assert group.id == "g1"
  368. assert group.name == "Team"
  369. async def test_ban_room_member(mock_api, client):
  370. mock_api.post("/api/graphql").mock(return_value=_gql_response({"banRoomMember": True}))
  371. async with client:
  372. result = await client.ban_room_member("r1", "u2", "spam")
  373. assert result is True
  374. async def test_update_presence_offline_rejected(client):
  375. from chattolib.types import PresenceStatus
  376. async with client:
  377. with pytest.raises(ValueError):
  378. await client.update_presence(PresenceStatus.OFFLINE)