agent-messenger 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.claude-plugin/marketplace.json +14 -1
  2. package/.claude-plugin/plugin.json +4 -2
  3. package/.env.template +35 -17
  4. package/README.md +37 -33
  5. package/bun.lock +6 -6
  6. package/dist/package.json +11 -3
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +3 -0
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/platforms/channeltalk/commands/auth.d.ts.map +1 -1
  11. package/dist/src/platforms/channeltalk/commands/auth.js +35 -28
  12. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  13. package/dist/src/platforms/channeltalk/ensure-auth.js +6 -6
  14. package/dist/src/platforms/channeltalk/ensure-auth.js.map +1 -1
  15. package/dist/src/platforms/channeltalk/token-extractor.d.ts +23 -1
  16. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  17. package/dist/src/platforms/channeltalk/token-extractor.js +299 -29
  18. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  20. package/dist/src/platforms/discord/commands/auth.js +57 -49
  21. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  22. package/dist/src/platforms/discord/ensure-auth.js +3 -3
  23. package/dist/src/platforms/discord/ensure-auth.js.map +1 -1
  24. package/dist/src/platforms/discord/token-extractor.d.ts +6 -1
  25. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  26. package/dist/src/platforms/discord/token-extractor.js +167 -14
  27. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  28. package/dist/src/platforms/instagram/client.d.ts +2 -0
  29. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  30. package/dist/src/platforms/instagram/client.js +2 -2
  31. package/dist/src/platforms/instagram/client.js.map +1 -1
  32. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/instagram/commands/auth.js +107 -14
  34. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/instagram/ensure-auth.d.ts.map +1 -1
  36. package/dist/src/platforms/instagram/ensure-auth.js +57 -11
  37. package/dist/src/platforms/instagram/ensure-auth.js.map +1 -1
  38. package/dist/src/platforms/instagram/index.d.ts +1 -0
  39. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  40. package/dist/src/platforms/instagram/index.js +1 -0
  41. package/dist/src/platforms/instagram/index.js.map +1 -1
  42. package/dist/src/platforms/instagram/token-extractor.d.ts +44 -0
  43. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -0
  44. package/dist/src/platforms/instagram/token-extractor.js +407 -0
  45. package/dist/src/platforms/instagram/token-extractor.js.map +1 -0
  46. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  47. package/dist/src/platforms/kakaotalk/client.js +2 -1
  48. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  49. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  50. package/dist/src/platforms/kakaotalk/commands/auth.js +14 -13
  51. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  52. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  53. package/dist/src/platforms/kakaotalk/protocol/connection.js +2 -1
  54. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  55. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  56. package/dist/src/platforms/line/commands/auth.js +6 -5
  57. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  58. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  59. package/dist/src/platforms/slack/commands/auth.js +11 -10
  60. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  61. package/dist/src/platforms/slack/token-extractor.d.ts +9 -0
  62. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  63. package/dist/src/platforms/slack/token-extractor.js +300 -23
  64. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  65. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  66. package/dist/src/platforms/teams/commands/auth.js +9 -8
  67. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  68. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  69. package/dist/src/platforms/teams/ensure-auth.js +2 -1
  70. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  71. package/dist/src/platforms/teams/token-extractor.d.ts +5 -0
  72. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  73. package/dist/src/platforms/teams/token-extractor.js +161 -29
  74. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  75. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  76. package/dist/src/platforms/telegram/client.js +25 -7
  77. package/dist/src/platforms/telegram/client.js.map +1 -1
  78. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  79. package/dist/src/platforms/telegram/commands/auth.js +6 -5
  80. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  81. package/dist/src/platforms/webex/app-config.d.ts +7 -0
  82. package/dist/src/platforms/webex/app-config.d.ts.map +1 -0
  83. package/dist/src/platforms/webex/app-config.js +20 -0
  84. package/dist/src/platforms/webex/app-config.js.map +1 -0
  85. package/dist/src/platforms/webex/cli.d.ts +5 -0
  86. package/dist/src/platforms/webex/cli.d.ts.map +1 -0
  87. package/dist/src/platforms/webex/cli.js +32 -0
  88. package/dist/src/platforms/webex/cli.js.map +1 -0
  89. package/dist/src/platforms/webex/client.d.ts +55 -0
  90. package/dist/src/platforms/webex/client.d.ts.map +1 -0
  91. package/dist/src/platforms/webex/client.js +299 -0
  92. package/dist/src/platforms/webex/client.js.map +1 -0
  93. package/dist/src/platforms/webex/commands/auth.d.ts +19 -0
  94. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -0
  95. package/dist/src/platforms/webex/commands/auth.js +166 -0
  96. package/dist/src/platforms/webex/commands/auth.js.map +1 -0
  97. package/dist/src/platforms/webex/commands/index.d.ts +6 -0
  98. package/dist/src/platforms/webex/commands/index.d.ts.map +1 -0
  99. package/dist/src/platforms/webex/commands/index.js +6 -0
  100. package/dist/src/platforms/webex/commands/index.js.map +1 -0
  101. package/dist/src/platforms/webex/commands/member.d.ts +7 -0
  102. package/dist/src/platforms/webex/commands/member.d.ts.map +1 -0
  103. package/dist/src/platforms/webex/commands/member.js +34 -0
  104. package/dist/src/platforms/webex/commands/member.js.map +1 -0
  105. package/dist/src/platforms/webex/commands/message.d.ts +26 -0
  106. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -0
  107. package/dist/src/platforms/webex/commands/message.js +153 -0
  108. package/dist/src/platforms/webex/commands/message.js.map +1 -0
  109. package/dist/src/platforms/webex/commands/snapshot.d.ts +9 -0
  110. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -0
  111. package/dist/src/platforms/webex/commands/snapshot.js +72 -0
  112. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -0
  113. package/dist/src/platforms/webex/commands/space.d.ts +11 -0
  114. package/dist/src/platforms/webex/commands/space.d.ts.map +1 -0
  115. package/dist/src/platforms/webex/commands/space.js +59 -0
  116. package/dist/src/platforms/webex/commands/space.js.map +1 -0
  117. package/dist/src/platforms/webex/credential-manager.d.ts +23 -0
  118. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -0
  119. package/dist/src/platforms/webex/credential-manager.js +148 -0
  120. package/dist/src/platforms/webex/credential-manager.js.map +1 -0
  121. package/dist/src/platforms/webex/ensure-auth.d.ts +2 -0
  122. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -0
  123. package/dist/src/platforms/webex/ensure-auth.js +36 -0
  124. package/dist/src/platforms/webex/ensure-auth.js.map +1 -0
  125. package/dist/src/platforms/webex/index.d.ts +8 -0
  126. package/dist/src/platforms/webex/index.d.ts.map +1 -0
  127. package/dist/src/platforms/webex/index.js +6 -0
  128. package/dist/src/platforms/webex/index.js.map +1 -0
  129. package/dist/src/platforms/webex/token-extractor.d.ts +28 -0
  130. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -0
  131. package/dist/src/platforms/webex/token-extractor.js +344 -0
  132. package/dist/src/platforms/webex/token-extractor.js.map +1 -0
  133. package/dist/src/platforms/webex/types.d.ts +127 -0
  134. package/dist/src/platforms/webex/types.d.ts.map +1 -0
  135. package/dist/src/platforms/webex/types.js +64 -0
  136. package/dist/src/platforms/webex/types.js.map +1 -0
  137. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  138. package/dist/src/platforms/whatsapp/client.js +6 -2
  139. package/dist/src/platforms/whatsapp/client.js.map +1 -1
  140. package/dist/src/shared/utils/derived-key-cache.d.ts +1 -1
  141. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  142. package/dist/src/shared/utils/error-handler.d.ts +1 -1
  143. package/dist/src/shared/utils/error-handler.d.ts.map +1 -1
  144. package/dist/src/shared/utils/error-handler.js +3 -2
  145. package/dist/src/shared/utils/error-handler.js.map +1 -1
  146. package/dist/src/shared/utils/stderr.d.ts +5 -0
  147. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  148. package/dist/src/shared/utils/stderr.js +18 -0
  149. package/dist/src/shared/utils/stderr.js.map +1 -0
  150. package/dist/src/tui/adapters/webex-adapter.d.ts +14 -0
  151. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -0
  152. package/dist/src/tui/adapters/webex-adapter.js +79 -0
  153. package/dist/src/tui/adapters/webex-adapter.js.map +1 -0
  154. package/dist/src/tui/app.d.ts.map +1 -1
  155. package/dist/src/tui/app.js +2 -0
  156. package/dist/src/tui/app.js.map +1 -1
  157. package/docs/content/docs/cli/channeltalk.mdx +7 -7
  158. package/docs/content/docs/cli/discord.mdx +3 -3
  159. package/docs/content/docs/cli/instagram.mdx +28 -6
  160. package/docs/content/docs/cli/meta.json +1 -0
  161. package/docs/content/docs/cli/slack.mdx +2 -2
  162. package/docs/content/docs/cli/teams.mdx +6 -4
  163. package/docs/content/docs/cli/webex.mdx +310 -0
  164. package/docs/content/docs/sdk/meta.json +1 -1
  165. package/docs/content/docs/sdk/webex.mdx +260 -0
  166. package/docs/content/docs/tui.mdx +4 -3
  167. package/docs/src/app/page.tsx +2 -2
  168. package/e2e/README.md +132 -8
  169. package/e2e/channeltalk.e2e.test.ts +2 -7
  170. package/e2e/channeltalkbot.e2e.test.ts +2 -6
  171. package/e2e/config.ts +172 -10
  172. package/e2e/helpers.ts +7 -0
  173. package/e2e/instagram.e2e.test.ts +97 -0
  174. package/e2e/kakaotalk.e2e.test.ts +74 -0
  175. package/e2e/line.e2e.test.ts +92 -0
  176. package/e2e/teams.e2e.test.ts +46 -1
  177. package/e2e/telegram.e2e.test.ts +84 -0
  178. package/e2e/webex.e2e.test.ts +190 -0
  179. package/e2e/whatsapp.e2e.test.ts +90 -0
  180. package/e2e/whatsappbot.e2e.test.ts +78 -0
  181. package/package.json +11 -3
  182. package/skills/agent-channeltalk/SKILL.md +9 -9
  183. package/skills/agent-channeltalk/references/authentication.md +21 -18
  184. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  185. package/skills/agent-discord/SKILL.md +5 -5
  186. package/skills/agent-discord/references/authentication.md +8 -8
  187. package/skills/agent-discordbot/SKILL.md +1 -1
  188. package/skills/agent-instagram/SKILL.md +51 -9
  189. package/skills/agent-instagram/references/authentication.md +35 -3
  190. package/skills/agent-kakaotalk/SKILL.md +1 -1
  191. package/skills/agent-line/SKILL.md +1 -1
  192. package/skills/agent-slack/SKILL.md +5 -5
  193. package/skills/agent-slack/references/authentication.md +8 -8
  194. package/skills/agent-slackbot/SKILL.md +1 -1
  195. package/skills/agent-teams/SKILL.md +6 -6
  196. package/skills/agent-teams/references/authentication.md +8 -8
  197. package/skills/agent-telegram/SKILL.md +1 -1
  198. package/skills/agent-webex/SKILL.md +406 -0
  199. package/skills/agent-webex/references/authentication.md +371 -0
  200. package/skills/agent-webex/references/common-patterns.md +726 -0
  201. package/skills/agent-webex/templates/monitor-space.sh +165 -0
  202. package/skills/agent-webex/templates/post-message.sh +170 -0
  203. package/skills/agent-whatsapp/SKILL.md +1 -1
  204. package/skills/agent-whatsappbot/SKILL.md +1 -1
  205. package/src/cli.ts +4 -0
  206. package/src/platforms/channeltalk/commands/auth.test.ts +5 -5
  207. package/src/platforms/channeltalk/commands/auth.ts +38 -32
  208. package/src/platforms/channeltalk/ensure-auth.test.ts +6 -6
  209. package/src/platforms/channeltalk/ensure-auth.ts +6 -6
  210. package/src/platforms/channeltalk/token-extractor.test.ts +182 -15
  211. package/src/platforms/channeltalk/token-extractor.ts +344 -30
  212. package/src/platforms/discord/commands/auth.test.ts +3 -3
  213. package/src/platforms/discord/commands/auth.ts +58 -54
  214. package/src/platforms/discord/ensure-auth.test.ts +3 -3
  215. package/src/platforms/discord/ensure-auth.ts +3 -3
  216. package/src/platforms/discord/token-extractor.test.ts +199 -27
  217. package/src/platforms/discord/token-extractor.ts +190 -17
  218. package/src/platforms/instagram/client.ts +2 -2
  219. package/src/platforms/instagram/commands/auth.ts +133 -14
  220. package/src/platforms/instagram/ensure-auth.ts +63 -12
  221. package/src/platforms/instagram/index.ts +1 -0
  222. package/src/platforms/instagram/token-extractor.test.ts +424 -0
  223. package/src/platforms/instagram/token-extractor.ts +478 -0
  224. package/src/platforms/kakaotalk/client.ts +3 -1
  225. package/src/platforms/kakaotalk/commands/auth.ts +14 -13
  226. package/src/platforms/kakaotalk/protocol/connection.ts +3 -1
  227. package/src/platforms/line/commands/auth.ts +7 -6
  228. package/src/platforms/slack/cli.test.ts +6 -5
  229. package/src/platforms/slack/commands/auth.test.ts +11 -7
  230. package/src/platforms/slack/commands/auth.ts +11 -10
  231. package/src/platforms/slack/token-extractor.test.ts +98 -1
  232. package/src/platforms/slack/token-extractor.ts +338 -26
  233. package/src/platforms/teams/commands/auth.ts +9 -8
  234. package/src/platforms/teams/ensure-auth.ts +3 -1
  235. package/src/platforms/teams/token-extractor.test.ts +136 -17
  236. package/src/platforms/teams/token-extractor.ts +182 -31
  237. package/src/platforms/telegram/client.test.ts +134 -0
  238. package/src/platforms/telegram/client.ts +27 -6
  239. package/src/platforms/telegram/commands/auth.ts +6 -5
  240. package/src/platforms/webex/app-config.test.ts +98 -0
  241. package/src/platforms/webex/app-config.ts +31 -0
  242. package/src/platforms/webex/cli.test.ts +58 -0
  243. package/src/platforms/webex/cli.ts +39 -0
  244. package/src/platforms/webex/client.test.ts +743 -0
  245. package/src/platforms/webex/client.ts +405 -0
  246. package/src/platforms/webex/commands/auth.test.ts +222 -0
  247. package/src/platforms/webex/commands/auth.ts +243 -0
  248. package/src/platforms/webex/commands/index.ts +5 -0
  249. package/src/platforms/webex/commands/member.test.ts +112 -0
  250. package/src/platforms/webex/commands/member.ts +45 -0
  251. package/src/platforms/webex/commands/message.test.ts +235 -0
  252. package/src/platforms/webex/commands/message.ts +204 -0
  253. package/src/platforms/webex/commands/snapshot.test.ts +105 -0
  254. package/src/platforms/webex/commands/snapshot.ts +91 -0
  255. package/src/platforms/webex/commands/space.test.ts +216 -0
  256. package/src/platforms/webex/commands/space.ts +74 -0
  257. package/src/platforms/webex/credential-manager.test.ts +314 -0
  258. package/src/platforms/webex/credential-manager.ts +197 -0
  259. package/src/platforms/webex/ensure-auth.test.ts +89 -0
  260. package/src/platforms/webex/ensure-auth.ts +38 -0
  261. package/src/platforms/webex/index.test.ts +25 -0
  262. package/src/platforms/webex/index.ts +19 -0
  263. package/src/platforms/webex/token-extractor.test.ts +327 -0
  264. package/src/platforms/webex/token-extractor.ts +393 -0
  265. package/src/platforms/webex/types.test.ts +307 -0
  266. package/src/platforms/webex/types.ts +129 -0
  267. package/src/platforms/whatsapp/client.ts +11 -7
  268. package/src/shared/utils/derived-key-cache.ts +1 -1
  269. package/src/shared/utils/error-handler.ts +4 -2
  270. package/src/shared/utils/stderr.ts +22 -0
  271. package/src/tui/adapters/webex-adapter.ts +103 -0
  272. package/src/tui/app.ts +2 -0
@@ -0,0 +1,743 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+
3
+ import { WebexClient } from './client'
4
+ import { WebexError } from './types'
5
+
6
+ describe('WebexClient', () => {
7
+ const originalFetch = globalThis.fetch
8
+ let fetchCalls: Array<{ url: string; options?: RequestInit }> = []
9
+ let fetchResponses: Response[] = []
10
+ let fetchIndex = 0
11
+
12
+ beforeEach(() => {
13
+ fetchCalls = []
14
+ fetchResponses = []
15
+ fetchIndex = 0
16
+ ;(globalThis as { fetch: unknown }).fetch = async (
17
+ url: string | URL | Request,
18
+ options?: RequestInit,
19
+ ): Promise<Response> => {
20
+ fetchCalls.push({ url: url.toString(), options })
21
+ const response = fetchResponses[fetchIndex]
22
+ fetchIndex++
23
+ if (!response) {
24
+ throw new Error('No mock response configured')
25
+ }
26
+ return response
27
+ }
28
+ })
29
+
30
+ afterEach(() => {
31
+ globalThis.fetch = originalFetch
32
+ })
33
+
34
+ const mockResponse = (body: unknown, status = 200, headers: Record<string, string> = {}) => {
35
+ const defaultHeaders: Record<string, string> = {
36
+ 'Content-Type': 'application/json',
37
+ 'X-RateLimit-Remaining': '10',
38
+ 'X-RateLimit-Reset': String(Date.now() / 1000 + 60),
39
+ ...headers,
40
+ }
41
+ fetchResponses.push(
42
+ new Response(body === null ? null : JSON.stringify(body), {
43
+ status,
44
+ headers: defaultHeaders,
45
+ }),
46
+ )
47
+ }
48
+
49
+ describe('login', () => {
50
+ test('accepts valid token', async () => {
51
+ const client = await new WebexClient().login({ token: 'test-token' })
52
+ expect(client).toBeInstanceOf(WebexClient)
53
+ })
54
+
55
+ test('throws on empty token', async () => {
56
+ await expect(new WebexClient().login({ token: '' })).rejects.toThrow(WebexError)
57
+ await expect(new WebexClient().login({ token: '' })).rejects.toThrow('Token is required')
58
+ })
59
+ })
60
+
61
+ describe('testAuth', () => {
62
+ test('calls GET /people/me and returns person', async () => {
63
+ mockResponse({
64
+ id: 'user-123',
65
+ displayName: 'Test User',
66
+ emails: ['test@example.com'],
67
+ })
68
+
69
+ const client = await new WebexClient().login({ token: 'test-token' })
70
+ const person = await client.testAuth()
71
+
72
+ expect(person.id).toBe('user-123')
73
+ expect(person.displayName).toBe('Test User')
74
+ expect(fetchCalls.length).toBe(1)
75
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/me')
76
+ expect(fetchCalls[0].options?.headers).toMatchObject({
77
+ Authorization: 'Bearer test-token',
78
+ })
79
+ })
80
+
81
+ test('throws WebexError on API error', async () => {
82
+ mockResponse({ message: 'Unauthorized' }, 401)
83
+
84
+ const client = await new WebexClient().login({ token: 'bad-token' })
85
+ await expect(client.testAuth()).rejects.toThrow(WebexError)
86
+ })
87
+ })
88
+
89
+ describe('listSpaces', () => {
90
+ test('returns unwrapped items array', async () => {
91
+ mockResponse({
92
+ items: [
93
+ { id: 'room1', title: 'Room One', type: 'group' },
94
+ { id: 'room2', title: 'Room Two', type: 'direct' },
95
+ ],
96
+ })
97
+
98
+ const client = await new WebexClient().login({ token: 'test-token' })
99
+ const spaces = await client.listSpaces()
100
+
101
+ expect(spaces).toHaveLength(2)
102
+ expect(spaces[0].id).toBe('room1')
103
+ expect(spaces[1].title).toBe('Room Two')
104
+ })
105
+
106
+ test('includes default max=50 query param', async () => {
107
+ mockResponse({ items: [] })
108
+
109
+ const client = await new WebexClient().login({ token: 'test-token' })
110
+ await client.listSpaces()
111
+
112
+ expect(fetchCalls[0].url).toContain('max=50')
113
+ })
114
+
115
+ test('passes type and max query params', async () => {
116
+ mockResponse({ items: [] })
117
+
118
+ const client = await new WebexClient().login({ token: 'test-token' })
119
+ await client.listSpaces({ type: 'direct', max: 10 })
120
+
121
+ expect(fetchCalls[0].url).toContain('type=direct')
122
+ expect(fetchCalls[0].url).toContain('max=10')
123
+ expect(fetchCalls[0].url).toContain('/rooms')
124
+ })
125
+ })
126
+
127
+ describe('getSpace', () => {
128
+ test('calls GET /rooms/{spaceId}', async () => {
129
+ mockResponse({ id: 'room1', title: 'Test Room', type: 'group' })
130
+
131
+ const client = await new WebexClient().login({ token: 'test-token' })
132
+ const space = await client.getSpace('room1')
133
+
134
+ expect(space.id).toBe('room1')
135
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/rooms/room1')
136
+ })
137
+ })
138
+
139
+ describe('sendMessage', () => {
140
+ test('posts text message to room', async () => {
141
+ mockResponse({ id: 'msg1', roomId: 'room1', text: 'Hello world' })
142
+
143
+ const client = await new WebexClient().login({ token: 'test-token' })
144
+ const message = await client.sendMessage('room1', 'Hello world')
145
+
146
+ expect(message.id).toBe('msg1')
147
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages')
148
+ expect(fetchCalls[0].options?.method).toBe('POST')
149
+ expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ roomId: 'room1', text: 'Hello world' }))
150
+ })
151
+
152
+ test('sends markdown message when option set', async () => {
153
+ mockResponse({ id: 'msg1', roomId: 'room1', markdown: '**bold**' })
154
+
155
+ const client = await new WebexClient().login({ token: 'test-token' })
156
+ await client.sendMessage('room1', '**bold**', { markdown: true })
157
+
158
+ expect(fetchCalls[0].options?.body).toBe(
159
+ JSON.stringify({ roomId: 'room1', markdown: '**bold**' }),
160
+ )
161
+ })
162
+ })
163
+
164
+ describe('sendDirectMessage', () => {
165
+ test('posts message with toPersonEmail', async () => {
166
+ mockResponse({ id: 'msg1', toPersonEmail: 'user@example.com', text: 'Hello' })
167
+
168
+ const client = await new WebexClient().login({ token: 'test-token' })
169
+ await client.sendDirectMessage('user@example.com', 'Hello')
170
+
171
+ expect(fetchCalls[0].options?.body).toBe(
172
+ JSON.stringify({ toPersonEmail: 'user@example.com', text: 'Hello' }),
173
+ )
174
+ })
175
+
176
+ test('sends markdown direct message when option set', async () => {
177
+ mockResponse({ id: 'msg1', toPersonEmail: 'user@example.com' })
178
+
179
+ const client = await new WebexClient().login({ token: 'test-token' })
180
+ await client.sendDirectMessage('user@example.com', '**bold**', { markdown: true })
181
+
182
+ expect(fetchCalls[0].options?.body).toBe(
183
+ JSON.stringify({ toPersonEmail: 'user@example.com', markdown: '**bold**' }),
184
+ )
185
+ })
186
+ })
187
+
188
+ describe('listMessages', () => {
189
+ test('includes roomId query param and unwraps items', async () => {
190
+ mockResponse({
191
+ items: [
192
+ { id: 'msg1', roomId: 'room1', text: 'Message 1' },
193
+ { id: 'msg2', roomId: 'room1', text: 'Message 2' },
194
+ ],
195
+ })
196
+
197
+ const client = await new WebexClient().login({ token: 'test-token' })
198
+ const messages = await client.listMessages('room1')
199
+
200
+ expect(messages).toHaveLength(2)
201
+ expect(messages[0].id).toBe('msg1')
202
+ expect(fetchCalls[0].url).toContain('roomId=room1')
203
+ expect(fetchCalls[0].url).toContain('max=50')
204
+ })
205
+
206
+ test('passes custom max', async () => {
207
+ mockResponse({ items: [] })
208
+
209
+ const client = await new WebexClient().login({ token: 'test-token' })
210
+ await client.listMessages('room1', { max: 10 })
211
+
212
+ expect(fetchCalls[0].url).toContain('max=10')
213
+ })
214
+ })
215
+
216
+ describe('getMessage', () => {
217
+ test('calls GET /messages/{messageId}', async () => {
218
+ mockResponse({ id: 'msg1', roomId: 'room1', text: 'Hello' })
219
+
220
+ const client = await new WebexClient().login({ token: 'test-token' })
221
+ const message = await client.getMessage('msg1')
222
+
223
+ expect(message.id).toBe('msg1')
224
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
225
+ })
226
+ })
227
+
228
+ describe('deleteMessage', () => {
229
+ test('calls DELETE /messages/{messageId} and handles 204', async () => {
230
+ mockResponse(null, 204)
231
+
232
+ const client = await new WebexClient().login({ token: 'test-token' })
233
+ await client.deleteMessage('msg1')
234
+
235
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
236
+ expect(fetchCalls[0].options?.method).toBe('DELETE')
237
+ })
238
+ })
239
+
240
+ describe('editMessage', () => {
241
+ test('calls PUT /messages/{messageId} with roomId and text', async () => {
242
+ mockResponse({ id: 'msg1', roomId: 'room1', text: 'Edited text' })
243
+
244
+ const client = await new WebexClient().login({ token: 'test-token' })
245
+ await client.editMessage('msg1', 'room1', 'Edited text')
246
+
247
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
248
+ expect(fetchCalls[0].options?.method).toBe('PUT')
249
+ expect(fetchCalls[0].options?.body).toBe(
250
+ JSON.stringify({ roomId: 'room1', text: 'Edited text' }),
251
+ )
252
+ })
253
+
254
+ test('sends markdown when option set', async () => {
255
+ mockResponse({ id: 'msg1', roomId: 'room1', markdown: '**edited**' })
256
+
257
+ const client = await new WebexClient().login({ token: 'test-token' })
258
+ await client.editMessage('msg1', 'room1', '**edited**', { markdown: true })
259
+
260
+ expect(fetchCalls[0].options?.body).toBe(
261
+ JSON.stringify({ roomId: 'room1', markdown: '**edited**' }),
262
+ )
263
+ })
264
+ })
265
+
266
+ describe('listPeople', () => {
267
+ test('returns unwrapped items', async () => {
268
+ mockResponse({
269
+ items: [
270
+ { id: 'u1', displayName: 'User One', emails: ['user1@example.com'] },
271
+ { id: 'u2', displayName: 'User Two', emails: ['user2@example.com'] },
272
+ ],
273
+ })
274
+
275
+ const client = await new WebexClient().login({ token: 'test-token' })
276
+ const people = await client.listPeople()
277
+
278
+ expect(people).toHaveLength(2)
279
+ expect(people[0].displayName).toBe('User One')
280
+ })
281
+
282
+ test('passes email, displayName, max query params', async () => {
283
+ mockResponse({ items: [] })
284
+
285
+ const client = await new WebexClient().login({ token: 'test-token' })
286
+ await client.listPeople({ email: 'user@example.com', displayName: 'Test', max: 5 })
287
+
288
+ expect(fetchCalls[0].url).toContain('email=user%40example.com')
289
+ expect(fetchCalls[0].url).toContain('displayName=Test')
290
+ expect(fetchCalls[0].url).toContain('max=5')
291
+ })
292
+ })
293
+
294
+ describe('listMemberships', () => {
295
+ test('includes roomId and returns unwrapped items', async () => {
296
+ mockResponse({
297
+ items: [
298
+ { id: 'm1', roomId: 'room1', personEmail: 'user1@example.com', isModerator: false },
299
+ { id: 'm2', roomId: 'room1', personEmail: 'user2@example.com', isModerator: true },
300
+ ],
301
+ })
302
+
303
+ const client = await new WebexClient().login({ token: 'test-token' })
304
+ const memberships = await client.listMemberships('room1')
305
+
306
+ expect(memberships).toHaveLength(2)
307
+ expect(memberships[0].id).toBe('m1')
308
+ expect(fetchCalls[0].url).toContain('roomId=room1')
309
+ })
310
+
311
+ test('passes max query param', async () => {
312
+ mockResponse({ items: [] })
313
+
314
+ const client = await new WebexClient().login({ token: 'test-token' })
315
+ await client.listMemberships('room1', { max: 20 })
316
+
317
+ expect(fetchCalls[0].url).toContain('max=20')
318
+ })
319
+ })
320
+
321
+ describe('rate limiting', () => {
322
+ test('retries on 429 with Retry-After header', async () => {
323
+ mockResponse({ message: 'Rate limited' }, 429, { 'Retry-After': '0.1' })
324
+ mockResponse({
325
+ id: 'user-123',
326
+ displayName: 'Test User',
327
+ emails: ['test@example.com'],
328
+ })
329
+
330
+ const client = await new WebexClient().login({ token: 'test-token' })
331
+ const person = await client.testAuth()
332
+
333
+ expect(person.id).toBe('user-123')
334
+ expect(fetchCalls.length).toBe(2)
335
+ })
336
+
337
+ test('throws after max retries exceeded on 429', async () => {
338
+ for (let i = 0; i <= MAX_RETRIES; i++) {
339
+ mockResponse({ message: 'Rate limited' }, 429, { 'Retry-After': '0.01' })
340
+ }
341
+
342
+ const client = await new WebexClient().login({ token: 'test-token' })
343
+ await expect(client.testAuth()).rejects.toThrow(WebexError)
344
+ expect(fetchCalls.length).toBeLessThanOrEqual(4)
345
+ })
346
+ })
347
+
348
+ describe('server errors', () => {
349
+ test('retries on 500 with exponential backoff', async () => {
350
+ mockResponse({ message: 'Internal Server Error' }, 500)
351
+ mockResponse({
352
+ id: 'user-123',
353
+ displayName: 'Test User',
354
+ emails: ['test@example.com'],
355
+ })
356
+
357
+ const client = await new WebexClient().login({ token: 'test-token' })
358
+ const person = await client.testAuth()
359
+
360
+ expect(person.id).toBe('user-123')
361
+ expect(fetchCalls.length).toBe(2)
362
+ })
363
+
364
+ test('does not retry on 4xx errors except 429', async () => {
365
+ mockResponse({ message: 'Not Found' }, 404)
366
+
367
+ const client = await new WebexClient().login({ token: 'test-token' })
368
+ await expect(client.testAuth()).rejects.toThrow(WebexError)
369
+ expect(fetchCalls.length).toBe(1)
370
+ })
371
+
372
+ test('backoff increases with multiple retries', async () => {
373
+ mockResponse({ message: 'Error' }, 500)
374
+ mockResponse({ message: 'Error' }, 500)
375
+ mockResponse({
376
+ id: 'user-123',
377
+ displayName: 'Test User',
378
+ emails: ['test@example.com'],
379
+ })
380
+
381
+ const client = await new WebexClient().login({ token: 'test-token' })
382
+ const startTime = Date.now()
383
+ await client.testAuth()
384
+ const elapsed = Date.now() - startTime
385
+
386
+ expect(elapsed).toBeGreaterThanOrEqual(150)
387
+ expect(fetchCalls.length).toBe(3)
388
+ })
389
+ })
390
+
391
+ describe('internal conversation API', () => {
392
+ const TEST_DEVICE_URL = 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id'
393
+ const CONV_BASE = 'https://conv-r.wbx2.com/conversation/api/v1'
394
+ const TEST_ROOM_ID = Buffer.from('ciscospark://urn:TEAM:us-west-2_r/ROOM/abc123-def456').toString('base64')
395
+ const TEST_CONV_UUID = 'abc123-def456'
396
+
397
+ const mockActivity = (text: string, overrides?: Partial<Record<string, unknown>>) => ({
398
+ id: 'activity-123',
399
+ verb: 'post',
400
+ actor: { displayName: 'Test User', emailAddress: 'test@example.com', entryUUID: 'user-uuid' },
401
+ object: { objectType: 'comment', content: text, displayName: text },
402
+ target: { id: TEST_CONV_UUID },
403
+ published: '2026-01-01T00:00:00.000Z',
404
+ ...overrides,
405
+ })
406
+
407
+ const mockConversation = (activities: ReturnType<typeof mockActivity>[]) => ({
408
+ id: TEST_CONV_UUID,
409
+ activities: { items: activities },
410
+ })
411
+
412
+ const createExtractedClient = async () => {
413
+ const client = await new WebexClient().login({ token: 'extracted-token' })
414
+ ;(client as any).deviceUrl = TEST_DEVICE_URL
415
+ ;(client as any).tokenType = 'extracted'
416
+ return client
417
+ }
418
+
419
+ describe('sendMessage', () => {
420
+ test('posts activity to /activities with POST method', async () => {
421
+ mockResponse(mockActivity('Hello world'))
422
+
423
+ const client = await createExtractedClient()
424
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
425
+
426
+ expect(fetchCalls[0].url).toBe(`${CONV_BASE}/activities`)
427
+ expect(fetchCalls[0].options?.method).toBe('POST')
428
+ })
429
+
430
+ test('body has verb, object type, displayName, and content', async () => {
431
+ mockResponse(mockActivity('Hello world'))
432
+
433
+ const client = await createExtractedClient()
434
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
435
+
436
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
437
+ expect(body.verb).toBe('post')
438
+ expect(body.object.objectType).toBe('comment')
439
+ expect(body.object.displayName).toBe('Hello world')
440
+ expect(body.object.content).toBe('Hello world')
441
+ })
442
+
443
+ test('body has target with decoded conv UUID and conversation type', async () => {
444
+ mockResponse(mockActivity('Hello world'))
445
+
446
+ const client = await createExtractedClient()
447
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
448
+
449
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
450
+ expect(body.target.id).toBe(TEST_CONV_UUID)
451
+ expect(body.target.objectType).toBe('conversation')
452
+ })
453
+
454
+ test('body has clientTempId starting with tmp-', async () => {
455
+ mockResponse(mockActivity('Hello world'))
456
+
457
+ const client = await createExtractedClient()
458
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
459
+
460
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
461
+ expect(body.clientTempId).toStartWith('tmp-')
462
+ })
463
+
464
+ test('includes cisco-device-url header', async () => {
465
+ mockResponse(mockActivity('Hello world'))
466
+
467
+ const client = await createExtractedClient()
468
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
469
+
470
+ expect(fetchCalls[0].options?.headers).toMatchObject({
471
+ 'cisco-device-url': TEST_DEVICE_URL,
472
+ })
473
+ })
474
+
475
+ test('returns WebexMessage mapped from activity response', async () => {
476
+ mockResponse(mockActivity('Hello world'))
477
+
478
+ const client = await createExtractedClient()
479
+ const message = await client.sendMessage(TEST_ROOM_ID, 'Hello world')
480
+
481
+ expect(message.id).toBe('activity-123')
482
+ expect(message.text).toBe('Hello world')
483
+ expect(message.personEmail).toBe('test@example.com')
484
+ expect(message.created).toBe('2026-01-01T00:00:00.000Z')
485
+ })
486
+ })
487
+
488
+ describe('listMessages', () => {
489
+ test('calls GET on conversations endpoint with activitiesLimit and participantsLimit', async () => {
490
+ mockResponse(mockConversation([mockActivity('Hello')]))
491
+
492
+ const client = await createExtractedClient()
493
+ await client.listMessages(TEST_ROOM_ID)
494
+
495
+ expect(fetchCalls[0].url).toBe(
496
+ `${CONV_BASE}/conversations/${TEST_CONV_UUID}?activitiesLimit=50&participantsLimit=0`,
497
+ )
498
+ })
499
+
500
+ test('filters activities to only those with verb post', async () => {
501
+ mockResponse(
502
+ mockConversation([
503
+ mockActivity('Hello'),
504
+ { ...mockActivity('Deleted'), verb: 'delete' },
505
+ mockActivity('World'),
506
+ ]),
507
+ )
508
+
509
+ const client = await createExtractedClient()
510
+ const messages = await client.listMessages(TEST_ROOM_ID)
511
+
512
+ expect(messages).toHaveLength(2)
513
+ expect(messages[0].text).toBe('Hello')
514
+ expect(messages[1].text).toBe('World')
515
+ })
516
+
517
+ test('maps each activity to WebexMessage format', async () => {
518
+ mockResponse(mockConversation([mockActivity('Hello')]))
519
+
520
+ const client = await createExtractedClient()
521
+ const messages = await client.listMessages(TEST_ROOM_ID)
522
+
523
+ expect(messages[0].id).toBe('activity-123')
524
+ expect(messages[0].text).toBe('Hello')
525
+ expect(messages[0].personEmail).toBe('test@example.com')
526
+ expect(messages[0].created).toBe('2026-01-01T00:00:00.000Z')
527
+ })
528
+
529
+ test('passes custom max to activitiesLimit', async () => {
530
+ mockResponse(mockConversation([]))
531
+
532
+ const client = await createExtractedClient()
533
+ await client.listMessages(TEST_ROOM_ID, { max: 25 })
534
+
535
+ expect(fetchCalls[0].url).toContain('activitiesLimit=25')
536
+ })
537
+
538
+ test('includes cisco-device-url header', async () => {
539
+ mockResponse(mockConversation([]))
540
+
541
+ const client = await createExtractedClient()
542
+ await client.listMessages(TEST_ROOM_ID)
543
+
544
+ expect(fetchCalls[0].options?.headers).toMatchObject({
545
+ 'cisco-device-url': TEST_DEVICE_URL,
546
+ })
547
+ })
548
+ })
549
+
550
+ describe('getMessage', () => {
551
+ test('calls GET on activities endpoint', async () => {
552
+ mockResponse(mockActivity('Hello'))
553
+
554
+ const client = await createExtractedClient()
555
+ await client.getMessage('activity-123')
556
+
557
+ expect(fetchCalls[0].url).toBe(`${CONV_BASE}/activities/activity-123`)
558
+ })
559
+
560
+ test('maps activity to WebexMessage format', async () => {
561
+ mockResponse(mockActivity('Hello'))
562
+
563
+ const client = await createExtractedClient()
564
+ const message = await client.getMessage('activity-123')
565
+
566
+ expect(message.id).toBe('activity-123')
567
+ expect(message.text).toBe('Hello')
568
+ expect(message.personEmail).toBe('test@example.com')
569
+ })
570
+ })
571
+
572
+ describe('deleteMessage', () => {
573
+ test('first GETs the activity then POSTs a delete activity', async () => {
574
+ mockResponse(mockActivity('Hello'))
575
+ mockResponse({})
576
+
577
+ const client = await createExtractedClient()
578
+ await client.deleteMessage('activity-123')
579
+
580
+ expect(fetchCalls[0].url).toBe(`${CONV_BASE}/activities/activity-123`)
581
+ expect(fetchCalls[1].url).toBe(`${CONV_BASE}/activities`)
582
+ expect(fetchCalls[1].options?.method).toBe('POST')
583
+ })
584
+
585
+ test('delete activity body has correct verb, object, and target', async () => {
586
+ mockResponse(mockActivity('Hello'))
587
+ mockResponse({})
588
+
589
+ const client = await createExtractedClient()
590
+ await client.deleteMessage('activity-123')
591
+
592
+ const body = JSON.parse(fetchCalls[1].options?.body as string)
593
+ expect(body.verb).toBe('delete')
594
+ expect(body.object.id).toBe('activity-123')
595
+ expect(body.object.objectType).toBe('activity')
596
+ expect(body.target.id).toBe(TEST_CONV_UUID)
597
+ })
598
+
599
+ test('throws WebexError when activity has no target', async () => {
600
+ mockResponse({ ...mockActivity('Hello'), target: undefined })
601
+
602
+ const client = await createExtractedClient()
603
+ await expect(client.deleteMessage('activity-123')).rejects.toThrow(WebexError)
604
+ })
605
+ })
606
+
607
+ describe('editMessage', () => {
608
+ test('posts activity with verb post and parent edit reference', async () => {
609
+ mockResponse(mockActivity('Edited text'))
610
+
611
+ const client = await createExtractedClient()
612
+ await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
613
+
614
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
615
+ expect(body.verb).toBe('post')
616
+ expect(body.parent).toEqual({ id: 'activity-123', type: 'edit' })
617
+ })
618
+
619
+ test('body has object with comment type and new text', async () => {
620
+ mockResponse(mockActivity('Edited text'))
621
+
622
+ const client = await createExtractedClient()
623
+ await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
624
+
625
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
626
+ expect(body.object.objectType).toBe('comment')
627
+ expect(body.object.displayName).toBe('Edited text')
628
+ expect(body.object.content).toBe('Edited text')
629
+ })
630
+
631
+ test('target has decoded conv UUID', async () => {
632
+ mockResponse(mockActivity('Edited text'))
633
+
634
+ const client = await createExtractedClient()
635
+ await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
636
+
637
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
638
+ expect(body.target.id).toBe(TEST_CONV_UUID)
639
+ })
640
+ })
641
+
642
+ describe('sendDirectMessage', () => {
643
+ test('calls public rooms and memberships API to find room, then sends via internal API', async () => {
644
+ mockResponse({ items: [{ id: TEST_ROOM_ID, title: 'DM', type: 'direct' }] })
645
+ mockResponse({
646
+ items: [{ id: 'm1', roomId: TEST_ROOM_ID, personEmail: 'target@example.com', isModerator: false }],
647
+ })
648
+ mockResponse(mockActivity('Hello'))
649
+
650
+ const client = await createExtractedClient()
651
+ const message = await client.sendDirectMessage('target@example.com', 'Hello')
652
+
653
+ expect(fetchCalls[0].url).toContain('/rooms?type=direct&max=100')
654
+ expect(fetchCalls[1].url).toContain('/memberships?roomId=')
655
+ expect(fetchCalls[2].url).toBe(`${CONV_BASE}/activities`)
656
+ expect(message.id).toBe('activity-123')
657
+ })
658
+
659
+ test('throws WebexError when no existing direct conversation found', async () => {
660
+ mockResponse({ items: [{ id: 'room-x', title: 'DM', type: 'direct' }] })
661
+ mockResponse({
662
+ items: [{ id: 'm1', roomId: 'room-x', personEmail: 'other@example.com', isModerator: false }],
663
+ })
664
+
665
+ const client = await createExtractedClient()
666
+ await expect(client.sendDirectMessage('target@example.com', 'Hello')).rejects.toThrow(WebexError)
667
+ })
668
+ })
669
+
670
+ describe('error handling', () => {
671
+ test('throws WebexError when internal API returns non-OK response', async () => {
672
+ fetchResponses.push(
673
+ new Response(JSON.stringify({ message: 'Activity not found' }), {
674
+ status: 404,
675
+ headers: { 'Content-Type': 'application/json' },
676
+ }),
677
+ )
678
+
679
+ const client = await createExtractedClient()
680
+ await expect(client.getMessage('bad-activity')).rejects.toThrow(WebexError)
681
+ })
682
+
683
+ test('error message extracted from internal API response body', async () => {
684
+ fetchResponses.push(
685
+ new Response(JSON.stringify({ message: 'Activity not found' }), {
686
+ status: 404,
687
+ headers: { 'Content-Type': 'application/json' },
688
+ }),
689
+ )
690
+
691
+ const client = await createExtractedClient()
692
+ let error: WebexError | null = null
693
+ try {
694
+ await client.getMessage('bad-activity')
695
+ } catch (err) {
696
+ error = err as WebexError
697
+ }
698
+
699
+ expect(error).toBeInstanceOf(WebexError)
700
+ expect(error?.message).toBe('Activity not found')
701
+ })
702
+ })
703
+ })
704
+
705
+ describe('error handling', () => {
706
+ test('throws WebexError with parsed message from response body', async () => {
707
+ mockResponse({ message: 'The requested resource could not be found.', trackingId: 'abc' }, 404)
708
+
709
+ const client = await new WebexClient().login({ token: 'test-token' })
710
+ let error: WebexError | null = null
711
+ try {
712
+ await client.testAuth()
713
+ } catch (err) {
714
+ error = err as WebexError
715
+ }
716
+
717
+ expect(error).toBeInstanceOf(WebexError)
718
+ expect(error?.message).toBe('The requested resource could not be found.')
719
+ })
720
+
721
+ test('falls back to HTTP status message when no body', async () => {
722
+ fetchResponses.push(
723
+ new Response(null, {
724
+ status: 403,
725
+ headers: { 'Content-Type': 'application/json' },
726
+ }),
727
+ )
728
+
729
+ const client = await new WebexClient().login({ token: 'test-token' })
730
+ let error: WebexError | null = null
731
+ try {
732
+ await client.testAuth()
733
+ } catch (err) {
734
+ error = err as WebexError
735
+ }
736
+
737
+ expect(error).toBeInstanceOf(WebexError)
738
+ expect(error?.message).toBe('HTTP 403')
739
+ })
740
+ })
741
+ })
742
+
743
+ const MAX_RETRIES = 3