@channel.io/app-sdk-server 0.2.0 → 0.3.1

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 (55) hide show
  1. package/dist/__tests__/native-client.test.d.ts +2 -0
  2. package/dist/__tests__/native-client.test.d.ts.map +1 -0
  3. package/dist/__tests__/native-client.test.js +256 -0
  4. package/dist/__tests__/native-client.test.js.map +1 -0
  5. package/dist/__tests__/proxy-api.test.d.ts +2 -0
  6. package/dist/__tests__/proxy-api.test.d.ts.map +1 -0
  7. package/dist/__tests__/proxy-api.test.js +273 -0
  8. package/dist/__tests__/proxy-api.test.js.map +1 -0
  9. package/dist/__tests__/signature.test.d.ts +2 -0
  10. package/dist/__tests__/signature.test.d.ts.map +1 -0
  11. package/dist/__tests__/signature.test.js +172 -0
  12. package/dist/__tests__/signature.test.js.map +1 -0
  13. package/dist/__tests__/token-manager.test.d.ts +2 -0
  14. package/dist/__tests__/token-manager.test.d.ts.map +1 -0
  15. package/dist/__tests__/token-manager.test.js +624 -0
  16. package/dist/__tests__/token-manager.test.js.map +1 -0
  17. package/dist/decorators/extension-names.d.ts +1 -1
  18. package/dist/decorators/extension-names.d.ts.map +1 -1
  19. package/dist/decorators/extension-names.js +1 -0
  20. package/dist/decorators/extension-names.js.map +1 -1
  21. package/dist/guards/signature.middleware.d.ts +25 -16
  22. package/dist/guards/signature.middleware.d.ts.map +1 -1
  23. package/dist/guards/signature.middleware.js +35 -19
  24. package/dist/guards/signature.middleware.js.map +1 -1
  25. package/dist/native/client.d.ts +55 -1
  26. package/dist/native/client.d.ts.map +1 -1
  27. package/dist/native/client.js +89 -0
  28. package/dist/native/client.js.map +1 -1
  29. package/dist/native/index.d.ts +2 -0
  30. package/dist/native/index.d.ts.map +1 -1
  31. package/dist/native/index.js +2 -0
  32. package/dist/native/index.js.map +1 -1
  33. package/dist/native/proxy-api.d.ts +110 -0
  34. package/dist/native/proxy-api.d.ts.map +1 -0
  35. package/dist/native/proxy-api.js +159 -0
  36. package/dist/native/proxy-api.js.map +1 -0
  37. package/dist/native/proxy-api.types.d.ts +232 -0
  38. package/dist/native/proxy-api.types.d.ts.map +1 -0
  39. package/dist/native/proxy-api.types.js +10 -0
  40. package/dist/native/proxy-api.types.js.map +1 -0
  41. package/dist/native/types.d.ts +108 -0
  42. package/dist/native/types.d.ts.map +1 -1
  43. package/dist/nestjs/channel-app.service.d.ts +21 -2
  44. package/dist/nestjs/channel-app.service.d.ts.map +1 -1
  45. package/dist/nestjs/channel-app.service.js +73 -36
  46. package/dist/nestjs/channel-app.service.js.map +1 -1
  47. package/dist/nestjs/channel-app.service.test.d.ts +2 -0
  48. package/dist/nestjs/channel-app.service.test.d.ts.map +1 -0
  49. package/dist/nestjs/channel-app.service.test.js +240 -0
  50. package/dist/nestjs/channel-app.service.test.js.map +1 -0
  51. package/dist/token/manager.d.ts +33 -1
  52. package/dist/token/manager.d.ts.map +1 -1
  53. package/dist/token/manager.js +75 -13
  54. package/dist/token/manager.js.map +1 -1
  55. package/package.json +2 -2
@@ -0,0 +1,624 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { TokenManager, TokenManagerError } from "../token/manager.js";
3
+ import { InMemoryTokenCache } from "../token/cache.js";
4
+ // ============================================
5
+ // Helpers
6
+ // ============================================
7
+ function makeTokenResponse(overrides = {}) {
8
+ return {
9
+ accessToken: overrides.accessToken ?? "access-token-123",
10
+ refreshToken: overrides.refreshToken ?? "refresh-token-456",
11
+ expiresIn: overrides.expiresIn ?? 3600,
12
+ expiresAt: overrides.expiresAt ?? Date.now() + 3600 * 1000,
13
+ refreshTokenExpiresAt: overrides.refreshTokenExpiresAt ?? Date.now() + 604800 * 1000,
14
+ tokenType: overrides.tokenType ?? "Bearer",
15
+ scopes: overrides.scopes ?? [],
16
+ };
17
+ }
18
+ function makeApiResponse(token) {
19
+ return {
20
+ accessToken: token.accessToken,
21
+ refreshToken: token.refreshToken,
22
+ expiresIn: token.expiresIn,
23
+ expiresAt: token.expiresAt,
24
+ refreshTokenExpiresAt: token.refreshTokenExpiresAt,
25
+ tokenType: token.tokenType,
26
+ scopes: token.scopes,
27
+ };
28
+ }
29
+ function createMockFetch(responses) {
30
+ if (responses.length === 0) {
31
+ throw new Error("createMockFetch requires at least one response");
32
+ }
33
+ let callIndex = 0;
34
+ return vi.fn(async () => {
35
+ const resp = (responses[callIndex] ?? responses[responses.length - 1]);
36
+ callIndex++;
37
+ return {
38
+ ok: resp.ok,
39
+ status: resp.status,
40
+ statusText: resp.ok ? "OK" : "Error",
41
+ json: async () => resp.body,
42
+ text: async () => JSON.stringify(resp.body),
43
+ };
44
+ });
45
+ }
46
+ // ============================================
47
+ // InMemoryTokenCache Tests
48
+ // ============================================
49
+ describe("InMemoryTokenCache", () => {
50
+ let cache;
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ cache = new InMemoryTokenCache(60000);
54
+ });
55
+ afterEach(() => {
56
+ cache.destroy();
57
+ vi.useRealTimers();
58
+ });
59
+ it("should return null for missing key", async () => {
60
+ const result = await cache.get("nonexistent");
61
+ expect(result).toBeNull();
62
+ });
63
+ it("should store and retrieve a cached token", async () => {
64
+ const token = makeTokenResponse();
65
+ const cached = { token, cachedAt: Date.now(), key: "test-key" };
66
+ await cache.set("test-key", cached);
67
+ const result = await cache.get("test-key");
68
+ expect(result).not.toBeNull();
69
+ expect(result.token.accessToken).toBe("access-token-123");
70
+ });
71
+ it("should respect TTL and expire entries", async () => {
72
+ const token = makeTokenResponse();
73
+ const cached = { token, cachedAt: Date.now(), key: "ttl-key" };
74
+ await cache.set("ttl-key", cached, 5000); // 5 second TTL
75
+ expect(await cache.get("ttl-key")).not.toBeNull();
76
+ vi.advanceTimersByTime(6000); // Advance past TTL
77
+ expect(await cache.get("ttl-key")).toBeNull();
78
+ });
79
+ it("should delete a specific key", async () => {
80
+ const token = makeTokenResponse();
81
+ const cached = { token, cachedAt: Date.now(), key: "del-key" };
82
+ await cache.set("del-key", cached);
83
+ expect(await cache.get("del-key")).not.toBeNull();
84
+ await cache.delete("del-key");
85
+ expect(await cache.get("del-key")).toBeNull();
86
+ });
87
+ it("should clear all entries", async () => {
88
+ const token = makeTokenResponse();
89
+ await cache.set("key1", { token, cachedAt: Date.now(), key: "key1" });
90
+ await cache.set("key2", { token, cachedAt: Date.now(), key: "key2" });
91
+ expect(cache.getStats().size).toBe(2);
92
+ await cache.clear();
93
+ expect(cache.getStats().size).toBe(0);
94
+ });
95
+ it("should periodically cleanup expired entries", async () => {
96
+ const token = makeTokenResponse();
97
+ const cached = { token, cachedAt: Date.now(), key: "cleanup-key" };
98
+ await cache.set("cleanup-key", cached, 5000);
99
+ expect(cache.getStats().size).toBe(1);
100
+ vi.advanceTimersByTime(65000); // Past TTL + cleanup interval
101
+ expect(cache.getStats().size).toBe(0);
102
+ });
103
+ it("should report stats correctly", async () => {
104
+ const token = makeTokenResponse();
105
+ await cache.set("a", { token, cachedAt: Date.now(), key: "a" });
106
+ await cache.set("b", { token, cachedAt: Date.now(), key: "b" });
107
+ const stats = cache.getStats();
108
+ expect(stats.size).toBe(2);
109
+ expect(stats.keys).toContain("a");
110
+ expect(stats.keys).toContain("b");
111
+ });
112
+ });
113
+ // ============================================
114
+ // TokenManager Tests
115
+ // ============================================
116
+ describe("TokenManager", () => {
117
+ let originalFetch;
118
+ beforeEach(() => {
119
+ vi.useFakeTimers();
120
+ originalFetch = globalThis.fetch;
121
+ });
122
+ afterEach(() => {
123
+ globalThis.fetch = originalFetch;
124
+ vi.useRealTimers();
125
+ });
126
+ function createManager(overrides = {}) {
127
+ return new TokenManager({
128
+ appId: "test-app",
129
+ appSecret: "test-secret",
130
+ debug: true,
131
+ ...overrides,
132
+ });
133
+ }
134
+ // ----------------------------------------
135
+ // Token Issuance
136
+ // ----------------------------------------
137
+ describe("token issuance", () => {
138
+ it("should issue a channel token on cache miss", async () => {
139
+ const token = makeTokenResponse();
140
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
141
+ const manager = createManager();
142
+ const result = await manager.getChannelToken({ channelId: "ch-1" });
143
+ expect(result.accessToken).toBe("access-token-123");
144
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
145
+ manager.destroy();
146
+ });
147
+ it("should issue a manager token", async () => {
148
+ const token = makeTokenResponse({ accessToken: "mgr-token" });
149
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
150
+ const manager = createManager();
151
+ const result = await manager.getManagerToken({ channelId: "ch-1", managerId: "mgr-1" });
152
+ expect(result.accessToken).toBe("mgr-token");
153
+ manager.destroy();
154
+ });
155
+ it("should issue a user token", async () => {
156
+ const token = makeTokenResponse({ accessToken: "user-token" });
157
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
158
+ const manager = createManager();
159
+ const result = await manager.getUserToken({ channelId: "ch-1", userId: "usr-1" });
160
+ expect(result.accessToken).toBe("user-token");
161
+ manager.destroy();
162
+ });
163
+ it("should issue an app token (no channelId)", async () => {
164
+ const token = makeTokenResponse({ accessToken: "app-token" });
165
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
166
+ const manager = createManager();
167
+ const result = await manager.getAppToken();
168
+ expect(result.accessToken).toBe("app-token");
169
+ // Verify the API was called with the correct URL (no channelId in path)
170
+ const fetchCall = globalThis.fetch.mock.calls[0];
171
+ const url = fetchCall[0];
172
+ expect(url).toBe("https://api.channel.io/app/v1/token");
173
+ manager.destroy();
174
+ });
175
+ it("should throw TokenManagerError on API failure", async () => {
176
+ globalThis.fetch = createMockFetch([
177
+ { ok: false, status: 401, body: { error: "Unauthorized" } },
178
+ ]);
179
+ const manager = createManager();
180
+ await expect(manager.getChannelToken({ channelId: "ch-1" })).rejects.toThrow(TokenManagerError);
181
+ manager.destroy();
182
+ });
183
+ });
184
+ // ----------------------------------------
185
+ // Cache Hit
186
+ // ----------------------------------------
187
+ describe("cache hit", () => {
188
+ it("should return cached token on second call without API request", async () => {
189
+ const token = makeTokenResponse();
190
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
191
+ const manager = createManager();
192
+ // First call - cache miss
193
+ await manager.getChannelToken({ channelId: "ch-1" });
194
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
195
+ // Second call - cache hit
196
+ const result = await manager.getChannelToken({ channelId: "ch-1" });
197
+ expect(result.accessToken).toBe("access-token-123");
198
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1); // No additional API call
199
+ manager.destroy();
200
+ });
201
+ it("should use separate cache entries for different channelIds", async () => {
202
+ const token1 = makeTokenResponse({ accessToken: "token-ch1" });
203
+ const token2 = makeTokenResponse({ accessToken: "token-ch2" });
204
+ globalThis.fetch = createMockFetch([
205
+ { ok: true, status: 200, body: makeApiResponse(token1) },
206
+ { ok: true, status: 200, body: makeApiResponse(token2) },
207
+ ]);
208
+ const manager = createManager();
209
+ const r1 = await manager.getChannelToken({ channelId: "ch-1" });
210
+ const r2 = await manager.getChannelToken({ channelId: "ch-2" });
211
+ expect(r1.accessToken).toBe("token-ch1");
212
+ expect(r2.accessToken).toBe("token-ch2");
213
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
214
+ manager.destroy();
215
+ });
216
+ it("should cache app token separately from channel tokens", async () => {
217
+ const appToken = makeTokenResponse({ accessToken: "app-token" });
218
+ const channelToken = makeTokenResponse({ accessToken: "ch-token" });
219
+ globalThis.fetch = createMockFetch([
220
+ { ok: true, status: 200, body: makeApiResponse(appToken) },
221
+ { ok: true, status: 200, body: makeApiResponse(channelToken) },
222
+ ]);
223
+ const manager = createManager();
224
+ const r1 = await manager.getAppToken();
225
+ const r2 = await manager.getChannelToken({ channelId: "ch-1" });
226
+ expect(r1.accessToken).toBe("app-token");
227
+ expect(r2.accessToken).toBe("ch-token");
228
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
229
+ // Second call to getAppToken should be cached
230
+ const r3 = await manager.getAppToken();
231
+ expect(r3.accessToken).toBe("app-token");
232
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
233
+ manager.destroy();
234
+ });
235
+ it("should use separate cache entries for different token types", async () => {
236
+ const channelToken = makeTokenResponse({ accessToken: "ch-token" });
237
+ const managerToken = makeTokenResponse({ accessToken: "mgr-token" });
238
+ globalThis.fetch = createMockFetch([
239
+ { ok: true, status: 200, body: makeApiResponse(channelToken) },
240
+ { ok: true, status: 200, body: makeApiResponse(managerToken) },
241
+ ]);
242
+ const manager = createManager();
243
+ const r1 = await manager.getChannelToken({ channelId: "ch-1" });
244
+ const r2 = await manager.getManagerToken({ channelId: "ch-1", managerId: "mgr-1" });
245
+ expect(r1.accessToken).toBe("ch-token");
246
+ expect(r2.accessToken).toBe("mgr-token");
247
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
248
+ manager.destroy();
249
+ });
250
+ });
251
+ // ----------------------------------------
252
+ // TTL Expiry & Auto-Refresh
253
+ // ----------------------------------------
254
+ describe("TTL expiry and auto-refresh", () => {
255
+ it("should auto-refresh token within refresh buffer period", async () => {
256
+ const now = Date.now();
257
+ // Token that expires in 4 minutes (within 5-minute buffer)
258
+ const expiringToken = makeTokenResponse({
259
+ accessToken: "old-token",
260
+ expiresAt: now + 4 * 60 * 1000,
261
+ });
262
+ const refreshedToken = makeTokenResponse({
263
+ accessToken: "refreshed-token",
264
+ expiresAt: now + 3600 * 1000,
265
+ });
266
+ let callCount = 0;
267
+ globalThis.fetch = vi.fn(async (url) => {
268
+ callCount++;
269
+ const urlStr = typeof url === "string" ? url : url.toString();
270
+ if (callCount === 1) {
271
+ // Initial issue
272
+ return {
273
+ ok: true,
274
+ status: 200,
275
+ statusText: "OK",
276
+ json: async () => makeApiResponse(expiringToken),
277
+ text: async () => JSON.stringify(makeApiResponse(expiringToken)),
278
+ };
279
+ }
280
+ if (urlStr.includes("/token/refresh")) {
281
+ // Refresh call
282
+ return {
283
+ ok: true,
284
+ status: 200,
285
+ statusText: "OK",
286
+ json: async () => makeApiResponse(refreshedToken),
287
+ text: async () => JSON.stringify(makeApiResponse(refreshedToken)),
288
+ };
289
+ }
290
+ // Fallback re-issue
291
+ return {
292
+ ok: true,
293
+ status: 200,
294
+ statusText: "OK",
295
+ json: async () => makeApiResponse(refreshedToken),
296
+ text: async () => JSON.stringify(makeApiResponse(refreshedToken)),
297
+ };
298
+ });
299
+ const manager = createManager({ refreshBufferMs: 5 * 60 * 1000 });
300
+ // First call issues token
301
+ const first = await manager.getChannelToken({ channelId: "ch-1" });
302
+ expect(first.accessToken).toBe("old-token");
303
+ // Second call should trigger refresh because token expires within buffer
304
+ const second = await manager.getChannelToken({ channelId: "ch-1" });
305
+ expect(second.accessToken).toBe("refreshed-token");
306
+ manager.destroy();
307
+ });
308
+ it("should re-issue token when refresh fails", async () => {
309
+ const now = Date.now();
310
+ // Token that expires within buffer
311
+ const expiringToken = makeTokenResponse({
312
+ accessToken: "old-token",
313
+ expiresAt: now + 2 * 60 * 1000,
314
+ });
315
+ const newToken = makeTokenResponse({
316
+ accessToken: "new-token",
317
+ expiresAt: now + 3600 * 1000,
318
+ });
319
+ let callCount = 0;
320
+ globalThis.fetch = vi.fn(async (url) => {
321
+ callCount++;
322
+ const urlStr = typeof url === "string" ? url : url.toString();
323
+ if (callCount === 1) {
324
+ // Initial issue
325
+ return {
326
+ ok: true,
327
+ status: 200,
328
+ statusText: "OK",
329
+ json: async () => makeApiResponse(expiringToken),
330
+ text: async () => JSON.stringify(makeApiResponse(expiringToken)),
331
+ };
332
+ }
333
+ if (urlStr.includes("/token/refresh")) {
334
+ // Refresh fails
335
+ return {
336
+ ok: false,
337
+ status: 401,
338
+ statusText: "Unauthorized",
339
+ json: async () => ({}),
340
+ text: async () => "Unauthorized",
341
+ };
342
+ }
343
+ // Re-issue succeeds
344
+ return {
345
+ ok: true,
346
+ status: 200,
347
+ statusText: "OK",
348
+ json: async () => makeApiResponse(newToken),
349
+ text: async () => JSON.stringify(makeApiResponse(newToken)),
350
+ };
351
+ });
352
+ const manager = createManager({ refreshBufferMs: 5 * 60 * 1000 });
353
+ await manager.getChannelToken({ channelId: "ch-1" });
354
+ const result = await manager.getChannelToken({ channelId: "ch-1" });
355
+ expect(result.accessToken).toBe("new-token");
356
+ manager.destroy();
357
+ });
358
+ });
359
+ // ----------------------------------------
360
+ // Thundering Herd Prevention
361
+ // ----------------------------------------
362
+ describe("thundering herd prevention", () => {
363
+ it("should deduplicate concurrent requests for the same token", async () => {
364
+ let resolvePromise;
365
+ const pendingPromise = new Promise((resolve) => {
366
+ resolvePromise = resolve;
367
+ });
368
+ const token = makeTokenResponse({ accessToken: "deduped-token" });
369
+ let callCount = 0;
370
+ globalThis.fetch = vi.fn(() => {
371
+ callCount++;
372
+ if (callCount === 1) {
373
+ return pendingPromise;
374
+ }
375
+ // Should never reach here for the same key
376
+ return Promise.resolve({
377
+ ok: true,
378
+ status: 200,
379
+ statusText: "OK",
380
+ json: async () => makeApiResponse(makeTokenResponse({ accessToken: "second-token" })),
381
+ text: async () => "",
382
+ });
383
+ });
384
+ const manager = createManager();
385
+ // Fire 3 concurrent requests for the same channel
386
+ const p1 = manager.getChannelToken({ channelId: "ch-1" });
387
+ const p2 = manager.getChannelToken({ channelId: "ch-1" });
388
+ const p3 = manager.getChannelToken({ channelId: "ch-1" });
389
+ // Resolve the single pending fetch
390
+ resolvePromise({
391
+ ok: true,
392
+ status: 200,
393
+ statusText: "OK",
394
+ json: async () => makeApiResponse(token),
395
+ text: async () => JSON.stringify(makeApiResponse(token)),
396
+ });
397
+ const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
398
+ // All should return the same token
399
+ expect(r1.accessToken).toBe("deduped-token");
400
+ expect(r2.accessToken).toBe("deduped-token");
401
+ expect(r3.accessToken).toBe("deduped-token");
402
+ // Only one fetch call should have been made
403
+ expect(callCount).toBe(1);
404
+ manager.destroy();
405
+ });
406
+ it("should not deduplicate requests for different keys", async () => {
407
+ const token1 = makeTokenResponse({ accessToken: "token-1" });
408
+ const token2 = makeTokenResponse({ accessToken: "token-2" });
409
+ globalThis.fetch = createMockFetch([
410
+ { ok: true, status: 200, body: makeApiResponse(token1) },
411
+ { ok: true, status: 200, body: makeApiResponse(token2) },
412
+ ]);
413
+ const manager = createManager();
414
+ const [r1, r2] = await Promise.all([
415
+ manager.getChannelToken({ channelId: "ch-1" }),
416
+ manager.getChannelToken({ channelId: "ch-2" }),
417
+ ]);
418
+ expect(r1.accessToken).toBe("token-1");
419
+ expect(r2.accessToken).toBe("token-2");
420
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
421
+ manager.destroy();
422
+ });
423
+ it("should allow new requests after in-flight promise resolves", async () => {
424
+ const token1 = makeTokenResponse({ accessToken: "first" });
425
+ const token2 = makeTokenResponse({ accessToken: "second" });
426
+ let callCount = 0;
427
+ globalThis.fetch = vi.fn(async () => {
428
+ callCount++;
429
+ const token = callCount === 1 ? token1 : token2;
430
+ return {
431
+ ok: true,
432
+ status: 200,
433
+ statusText: "OK",
434
+ json: async () => makeApiResponse(token),
435
+ text: async () => JSON.stringify(makeApiResponse(token)),
436
+ };
437
+ });
438
+ const manager = createManager();
439
+ // First request
440
+ const r1 = await manager.getChannelToken({ channelId: "ch-1" });
441
+ expect(r1.accessToken).toBe("first");
442
+ // Invalidate cache to force new request
443
+ await manager.invalidateToken("channel", "ch-1");
444
+ // Second request should make a new API call
445
+ const r2 = await manager.getChannelToken({ channelId: "ch-1" });
446
+ expect(r2.accessToken).toBe("second");
447
+ expect(callCount).toBe(2);
448
+ manager.destroy();
449
+ });
450
+ it("should clean up in-flight promise on error", async () => {
451
+ let callCount = 0;
452
+ const token = makeTokenResponse();
453
+ globalThis.fetch = vi.fn(async () => {
454
+ callCount++;
455
+ if (callCount === 1) {
456
+ return {
457
+ ok: false,
458
+ status: 500,
459
+ statusText: "Internal Server Error",
460
+ json: async () => ({}),
461
+ text: async () => "Server Error",
462
+ };
463
+ }
464
+ return {
465
+ ok: true,
466
+ status: 200,
467
+ statusText: "OK",
468
+ json: async () => makeApiResponse(token),
469
+ text: async () => JSON.stringify(makeApiResponse(token)),
470
+ };
471
+ });
472
+ const manager = createManager();
473
+ // First request fails
474
+ await expect(manager.getChannelToken({ channelId: "ch-1" })).rejects.toThrow(TokenManagerError);
475
+ // Second request should work (in-flight was cleaned up)
476
+ const result = await manager.getChannelToken({ channelId: "ch-1" });
477
+ expect(result.accessToken).toBe("access-token-123");
478
+ expect(callCount).toBe(2);
479
+ manager.destroy();
480
+ });
481
+ });
482
+ // ----------------------------------------
483
+ // Cache Invalidation
484
+ // ----------------------------------------
485
+ describe("cache invalidation", () => {
486
+ it("should invalidate a specific token", async () => {
487
+ const token = makeTokenResponse();
488
+ globalThis.fetch = createMockFetch([
489
+ { ok: true, status: 200, body: makeApiResponse(token) },
490
+ { ok: true, status: 200, body: makeApiResponse(token) },
491
+ ]);
492
+ const manager = createManager();
493
+ await manager.getChannelToken({ channelId: "ch-1" });
494
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
495
+ await manager.invalidateToken("channel", "ch-1");
496
+ // Should issue new token after invalidation
497
+ await manager.getChannelToken({ channelId: "ch-1" });
498
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
499
+ manager.destroy();
500
+ });
501
+ it("should invalidate an app token", async () => {
502
+ const token1 = makeTokenResponse({ accessToken: "app-token-1" });
503
+ const token2 = makeTokenResponse({ accessToken: "app-token-2" });
504
+ globalThis.fetch = createMockFetch([
505
+ { ok: true, status: 200, body: makeApiResponse(token1) },
506
+ { ok: true, status: 200, body: makeApiResponse(token2) },
507
+ ]);
508
+ const manager = createManager();
509
+ const r1 = await manager.getAppToken();
510
+ expect(r1.accessToken).toBe("app-token-1");
511
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
512
+ await manager.invalidateToken("app");
513
+ // Should issue new token after invalidation
514
+ const r2 = await manager.getAppToken();
515
+ expect(r2.accessToken).toBe("app-token-2");
516
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
517
+ manager.destroy();
518
+ });
519
+ it("should clear all cached tokens", async () => {
520
+ const token = makeTokenResponse();
521
+ globalThis.fetch = createMockFetch([
522
+ { ok: true, status: 200, body: makeApiResponse(token) },
523
+ { ok: true, status: 200, body: makeApiResponse(token) },
524
+ { ok: true, status: 200, body: makeApiResponse(token) },
525
+ { ok: true, status: 200, body: makeApiResponse(token) },
526
+ ]);
527
+ const manager = createManager();
528
+ await manager.getChannelToken({ channelId: "ch-1" });
529
+ await manager.getChannelToken({ channelId: "ch-2" });
530
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
531
+ await manager.clearCache();
532
+ // Both should require new API calls
533
+ await manager.getChannelToken({ channelId: "ch-1" });
534
+ await manager.getChannelToken({ channelId: "ch-2" });
535
+ expect(globalThis.fetch).toHaveBeenCalledTimes(4);
536
+ manager.destroy();
537
+ });
538
+ });
539
+ // ----------------------------------------
540
+ // Manual Refresh
541
+ // ----------------------------------------
542
+ describe("manual refresh", () => {
543
+ it("should refresh a token manually", async () => {
544
+ const refreshed = makeTokenResponse({ accessToken: "manually-refreshed" });
545
+ globalThis.fetch = createMockFetch([
546
+ { ok: true, status: 200, body: makeApiResponse(refreshed) },
547
+ ]);
548
+ const manager = createManager();
549
+ const result = await manager.refreshToken("old-refresh-token");
550
+ expect(result.accessToken).toBe("manually-refreshed");
551
+ manager.destroy();
552
+ });
553
+ });
554
+ // ----------------------------------------
555
+ // API Response Normalization
556
+ // ----------------------------------------
557
+ describe("API response normalization", () => {
558
+ it("should handle snake_case API responses", async () => {
559
+ globalThis.fetch = createMockFetch([
560
+ {
561
+ ok: true,
562
+ status: 200,
563
+ body: {
564
+ access_token: "snake-access",
565
+ refresh_token: "snake-refresh",
566
+ expires_in: 7200,
567
+ token_type: "Bearer",
568
+ scope: "read write",
569
+ },
570
+ },
571
+ ]);
572
+ const manager = createManager();
573
+ const result = await manager.getChannelToken({ channelId: "ch-1" });
574
+ expect(result.accessToken).toBe("snake-access");
575
+ expect(result.refreshToken).toBe("snake-refresh");
576
+ expect(result.expiresIn).toBe(7200);
577
+ expect(result.tokenType).toBe("Bearer");
578
+ expect(result.scopes).toEqual(["read", "write"]);
579
+ manager.destroy();
580
+ });
581
+ });
582
+ // ----------------------------------------
583
+ // Custom Cache Storage
584
+ // ----------------------------------------
585
+ describe("custom cache storage", () => {
586
+ it("should use custom cache storage when provided", async () => {
587
+ const storage = {
588
+ get: vi.fn().mockResolvedValue(null),
589
+ set: vi.fn().mockResolvedValue(undefined),
590
+ delete: vi.fn().mockResolvedValue(undefined),
591
+ clear: vi.fn().mockResolvedValue(undefined),
592
+ };
593
+ const token = makeTokenResponse();
594
+ globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
595
+ const manager = createManager({ cacheStorage: storage });
596
+ await manager.getChannelToken({ channelId: "ch-1" });
597
+ expect(storage.get).toHaveBeenCalled();
598
+ expect(storage.set).toHaveBeenCalled();
599
+ manager.destroy();
600
+ });
601
+ });
602
+ // ----------------------------------------
603
+ // Destroy
604
+ // ----------------------------------------
605
+ describe("destroy", () => {
606
+ it("should cleanup resources on destroy", () => {
607
+ const manager = createManager();
608
+ // Should not throw
609
+ expect(() => manager.destroy()).not.toThrow();
610
+ });
611
+ it("should not throw when destroy is called with custom cache", () => {
612
+ const storage = {
613
+ get: vi.fn().mockResolvedValue(null),
614
+ set: vi.fn().mockResolvedValue(undefined),
615
+ delete: vi.fn().mockResolvedValue(undefined),
616
+ clear: vi.fn().mockResolvedValue(undefined),
617
+ };
618
+ const manager = createManager({ cacheStorage: storage });
619
+ // Should not throw even with custom storage (no destroy method)
620
+ expect(() => manager.destroy()).not.toThrow();
621
+ });
622
+ });
623
+ });
624
+ //# sourceMappingURL=token-manager.test.js.map