talon-agent 1.0.0 → 1.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Tests for src/frontend/teams/graph.ts
3
+ *
4
+ * Covers: loadTokens, saveTokens, refreshAccessToken, GraphClient
5
+ * (ensureValidToken, graphGet, getMe, listChats, getChatMessages,
6
+ * getStoredChatId/ChatTopic/UserId, saveChatConfig), and initGraphClient.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+
10
+ // Use vi.hoisted so mock factories can reference these before import hoisting
11
+ const {
12
+ writeAtomicSyncMock,
13
+ existsSyncMock,
14
+ readFileSyncMock,
15
+ mkdirSyncMock,
16
+ proxyFetchMock,
17
+ } = vi.hoisted(() => ({
18
+ writeAtomicSyncMock: vi.fn(),
19
+ existsSyncMock: vi.fn(() => false),
20
+ readFileSyncMock: vi.fn(),
21
+ mkdirSyncMock: vi.fn(),
22
+ proxyFetchMock: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../util/log.js", () => ({
26
+ log: vi.fn(),
27
+ logError: vi.fn(),
28
+ logWarn: vi.fn(),
29
+ logDebug: vi.fn(),
30
+ }));
31
+ vi.mock("../util/paths.js", () => ({
32
+ dirs: { data: "/fake/.talon/data" },
33
+ files: {},
34
+ }));
35
+ vi.mock("write-file-atomic", () => ({
36
+ default: { sync: writeAtomicSyncMock },
37
+ }));
38
+ vi.mock("node:fs", () => ({
39
+ existsSync: existsSyncMock,
40
+ readFileSync: readFileSyncMock,
41
+ mkdirSync: mkdirSyncMock,
42
+ }));
43
+ vi.mock("../frontend/teams/proxy-fetch.js", () => ({
44
+ proxyFetch: (...args: unknown[]) => proxyFetchMock(...args),
45
+ }));
46
+ vi.mock("cheerio", () => {
47
+ const dollarFn = Object.assign(vi.fn(), { text: () => "stripped text" });
48
+ return {
49
+ default: {},
50
+ load: vi.fn(() => dollarFn),
51
+ };
52
+ });
53
+ vi.mock("marked", () => ({
54
+ marked: { lexer: vi.fn(() => []) },
55
+ }));
56
+
57
+ import {
58
+ GraphClient,
59
+ initGraphClient,
60
+ deviceCodeAuth,
61
+ } from "../frontend/teams/graph.js";
62
+
63
+ // ── Helper: make a mock JSON response ────────────────────────────────────────
64
+
65
+ function mockResponse(data: unknown, ok = true, status = 200) {
66
+ return {
67
+ ok,
68
+ status,
69
+ json: vi.fn(async () => data),
70
+ text: vi.fn(async () => JSON.stringify(data)),
71
+ };
72
+ }
73
+
74
+ function makeTokens(overrides: Record<string, unknown> = {}) {
75
+ return {
76
+ accessToken: "test-access-token",
77
+ refreshToken: "test-refresh-token",
78
+ expiresAt: Date.now() + 3_600_000, // 1 hour from now
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ // ── GraphClient ──────────────────────────────────────────────────────────────
84
+
85
+ describe("GraphClient", () => {
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ existsSyncMock.mockReturnValue(true);
89
+ mkdirSyncMock.mockReturnValue(undefined);
90
+ });
91
+
92
+ describe("ensureValidToken", () => {
93
+ it("returns existing token when not expired", async () => {
94
+ const tokens = makeTokens(); // expires 1h from now
95
+ const client = new GraphClient(
96
+ tokens as ConstructorParameters<typeof GraphClient>[0],
97
+ );
98
+
99
+ proxyFetchMock.mockResolvedValue(
100
+ mockResponse({ id: "user1", displayName: "Alice" }),
101
+ );
102
+ const result = await client.getMe();
103
+ expect(result.id).toBe("user1");
104
+ // proxyFetch called with Bearer access-token
105
+ expect(proxyFetchMock).toHaveBeenCalledWith(
106
+ expect.stringContaining("/me"),
107
+ expect.objectContaining({
108
+ headers: expect.objectContaining({
109
+ Authorization: "Bearer test-access-token",
110
+ }),
111
+ }),
112
+ );
113
+ });
114
+
115
+ it("refreshes token when expired", async () => {
116
+ const tokens = makeTokens({ expiresAt: Date.now() - 1000 }); // already expired
117
+ const client = new GraphClient(
118
+ tokens as ConstructorParameters<typeof GraphClient>[0],
119
+ );
120
+
121
+ // First call: refresh token endpoint
122
+ // Second call: the actual graphGet
123
+ proxyFetchMock
124
+ .mockResolvedValueOnce(
125
+ mockResponse({
126
+ access_token: "new-access-token",
127
+ refresh_token: "new-refresh-token",
128
+ expires_in: 3600,
129
+ }),
130
+ )
131
+ .mockResolvedValueOnce(
132
+ mockResponse({ id: "user1", displayName: "Alice" }),
133
+ );
134
+
135
+ const result = await client.getMe();
136
+ expect(result.id).toBe("user1");
137
+ // The Bearer token in the second call should be the NEW token
138
+ expect(proxyFetchMock.mock.calls[1]![1]!.headers.Authorization).toBe(
139
+ "Bearer new-access-token",
140
+ );
141
+ // Tokens should be saved
142
+ expect(writeAtomicSyncMock).toHaveBeenCalled();
143
+ });
144
+
145
+ it("throws when token refresh fails", async () => {
146
+ const tokens = makeTokens({ expiresAt: Date.now() - 1000 });
147
+ const client = new GraphClient(
148
+ tokens as ConstructorParameters<typeof GraphClient>[0],
149
+ );
150
+
151
+ proxyFetchMock.mockResolvedValueOnce(
152
+ mockResponse({
153
+ error: "invalid_grant",
154
+ error_description: "Token has been revoked",
155
+ }),
156
+ );
157
+
158
+ await expect(client.getMe()).rejects.toThrow("Token refresh failed");
159
+ });
160
+
161
+ it("uses error code when error_description is absent from refresh response", async () => {
162
+ const tokens = makeTokens({ expiresAt: Date.now() - 1000 });
163
+ const client = new GraphClient(
164
+ tokens as ConstructorParameters<typeof GraphClient>[0],
165
+ );
166
+
167
+ proxyFetchMock.mockResolvedValueOnce(
168
+ mockResponse({
169
+ // error_description intentionally omitted → triggers (data.error_description || data.error)
170
+ error: "expired_token",
171
+ }),
172
+ );
173
+
174
+ await expect(client.getMe()).rejects.toThrow("Token refresh failed");
175
+ });
176
+
177
+ it("uses existing refreshToken when response omits refresh_token", async () => {
178
+ const originalRefreshToken = "original-refresh-token";
179
+ const tokens = makeTokens({
180
+ expiresAt: Date.now() - 1000,
181
+ refreshToken: originalRefreshToken,
182
+ });
183
+ const client = new GraphClient(
184
+ tokens as ConstructorParameters<typeof GraphClient>[0],
185
+ );
186
+
187
+ // First call: token refresh succeeds without providing new refresh_token
188
+ proxyFetchMock
189
+ .mockResolvedValueOnce(
190
+ mockResponse({
191
+ access_token: "new-access-token-2",
192
+ // refresh_token intentionally omitted → triggers (data.refresh_token || refreshToken)
193
+ expires_in: 3600,
194
+ }),
195
+ )
196
+ // Second call: the actual API request
197
+ .mockResolvedValueOnce(
198
+ mockResponse({ id: "user2", displayName: "User Two" }),
199
+ );
200
+
201
+ const result = await client.getMe();
202
+ expect(result.id).toBe("user2");
203
+ // The saved tokens should use the original refresh token as fallback
204
+ const savedData = JSON.parse(
205
+ writeAtomicSyncMock.mock.calls[
206
+ writeAtomicSyncMock.mock.calls.length - 1
207
+ ][1] as string,
208
+ );
209
+ expect(savedData.refreshToken).toBe(originalRefreshToken);
210
+ });
211
+ });
212
+
213
+ describe("graphGet", () => {
214
+ it("throws on non-ok HTTP response", async () => {
215
+ const client = new GraphClient(
216
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
217
+ );
218
+ proxyFetchMock.mockResolvedValue(
219
+ mockResponse({ error: "Unauthorized" }, false, 401),
220
+ );
221
+ await expect(client.getMe()).rejects.toThrow("Graph API");
222
+ await expect(client.getMe()).rejects.toThrow("401");
223
+ });
224
+
225
+ it("covers () => '' catch callback when resp.text() throws on non-ok response", async () => {
226
+ const client = new GraphClient(
227
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
228
+ );
229
+ proxyFetchMock.mockResolvedValue({
230
+ ok: false,
231
+ status: 503,
232
+ text: vi.fn(async () => {
233
+ throw new Error("body read failed");
234
+ }),
235
+ json: vi.fn(async () => ({})),
236
+ });
237
+ // resp.text() throws → catch(() => "") fires → body = ""
238
+ await expect(client.getMe()).rejects.toThrow("Graph API /me");
239
+ });
240
+ });
241
+
242
+ describe("getMe", () => {
243
+ it("returns user id and displayName", async () => {
244
+ const client = new GraphClient(
245
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
246
+ );
247
+ proxyFetchMock.mockResolvedValue(
248
+ mockResponse({ id: "abc123", displayName: "Bob Smith" }),
249
+ );
250
+ const me = await client.getMe();
251
+ expect(me.id).toBe("abc123");
252
+ expect(me.displayName).toBe("Bob Smith");
253
+ });
254
+ });
255
+
256
+ describe("listChats", () => {
257
+ it("returns chats array with id, topic, chatType", async () => {
258
+ const client = new GraphClient(
259
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
260
+ );
261
+ proxyFetchMock.mockResolvedValue(
262
+ mockResponse({
263
+ value: [
264
+ { id: "chat1", topic: "Team discussion", chatType: "group" },
265
+ { id: "chat2", topic: null, chatType: "oneOnOne" },
266
+ ],
267
+ }),
268
+ );
269
+ const chats = await client.listChats();
270
+ expect(chats).toHaveLength(2);
271
+ expect(chats[0]!.id).toBe("chat1");
272
+ expect(chats[0]!.topic).toBe("Team discussion");
273
+ expect(chats[1]!.topic).toBeNull();
274
+ });
275
+ });
276
+
277
+ describe("getChatMessages", () => {
278
+ it("filters only 'message' type messages", async () => {
279
+ const client = new GraphClient(
280
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
281
+ );
282
+ proxyFetchMock.mockResolvedValue(
283
+ mockResponse({
284
+ value: [
285
+ {
286
+ id: "msg1",
287
+ messageType: "message",
288
+ from: { user: { id: "u1", displayName: "Alice" } },
289
+ body: { contentType: "text", content: "Hello there" },
290
+ createdDateTime: "2026-01-01T10:00:00Z",
291
+ lastEditedDateTime: null,
292
+ },
293
+ {
294
+ id: "msg2",
295
+ messageType: "systemEventMessage", // should be filtered out
296
+ from: null,
297
+ body: { contentType: "text", content: "User joined" },
298
+ createdDateTime: "2026-01-01T09:00:00Z",
299
+ lastEditedDateTime: null,
300
+ },
301
+ ],
302
+ }),
303
+ );
304
+ const messages = await client.getChatMessages("chat1");
305
+ expect(messages).toHaveLength(1);
306
+ expect(messages[0]!.id).toBe("msg1");
307
+ expect(messages[0]!.senderName).toBe("Alice");
308
+ expect(messages[0]!.text).toBe("Hello there");
309
+ expect(messages[0]!.edited).toBe(false);
310
+ });
311
+
312
+ it("marks message as edited when lastEditedDateTime is set", async () => {
313
+ const client = new GraphClient(
314
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
315
+ );
316
+ proxyFetchMock.mockResolvedValue(
317
+ mockResponse({
318
+ value: [
319
+ {
320
+ id: "msg3",
321
+ messageType: "message",
322
+ from: { user: { id: "u2", displayName: "Bob" } },
323
+ body: { contentType: "text", content: "edited msg" },
324
+ createdDateTime: "2026-01-01T11:00:00Z",
325
+ lastEditedDateTime: "2026-01-01T11:05:00Z",
326
+ },
327
+ ],
328
+ }),
329
+ );
330
+ const messages = await client.getChatMessages("chat1");
331
+ expect(messages[0]!.edited).toBe(true);
332
+ });
333
+
334
+ it("handles null from/user gracefully", async () => {
335
+ const client = new GraphClient(
336
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
337
+ );
338
+ proxyFetchMock.mockResolvedValue(
339
+ mockResponse({
340
+ value: [
341
+ {
342
+ id: "msg4",
343
+ messageType: "message",
344
+ from: null,
345
+ body: { contentType: "text", content: "anon msg" },
346
+ createdDateTime: "2026-01-01T12:00:00Z",
347
+ lastEditedDateTime: null,
348
+ },
349
+ ],
350
+ }),
351
+ );
352
+ const messages = await client.getChatMessages("chat1");
353
+ expect(messages[0]!.senderName).toBe("Unknown");
354
+ expect(messages[0]!.senderId).toBe("");
355
+ });
356
+ });
357
+
358
+ describe("stored config helpers", () => {
359
+ it("returns undefined for unset chatId/chatTopic/userId", () => {
360
+ const client = new GraphClient(
361
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
362
+ );
363
+ expect(client.getStoredChatId()).toBeUndefined();
364
+ expect(client.getStoredChatTopic()).toBeUndefined();
365
+ expect(client.getStoredUserId()).toBeUndefined();
366
+ });
367
+
368
+ it("saveChatConfig stores and returns config", () => {
369
+ const client = new GraphClient(
370
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
371
+ );
372
+ client.saveChatConfig("chat-abc", "My Team", "user-xyz");
373
+ expect(client.getStoredChatId()).toBe("chat-abc");
374
+ expect(client.getStoredChatTopic()).toBe("My Team");
375
+ expect(client.getStoredUserId()).toBe("user-xyz");
376
+ expect(writeAtomicSyncMock).toHaveBeenCalled();
377
+ });
378
+ });
379
+ });
380
+
381
+ // ── initGraphClient ──────────────────────────────────────────────────────────
382
+
383
+ describe("initGraphClient", () => {
384
+ beforeEach(() => {
385
+ vi.clearAllMocks();
386
+ });
387
+
388
+ it("loads stored tokens, refreshes, and returns GraphClient", async () => {
389
+ existsSyncMock.mockReturnValue(true);
390
+ readFileSyncMock.mockReturnValue(
391
+ JSON.stringify({
392
+ accessToken: "old-token",
393
+ refreshToken: "stored-refresh",
394
+ expiresAt: Date.now() - 1000,
395
+ }),
396
+ );
397
+
398
+ proxyFetchMock.mockResolvedValue(
399
+ mockResponse({
400
+ access_token: "fresh-token",
401
+ refresh_token: "new-refresh",
402
+ expires_in: 3600,
403
+ }),
404
+ );
405
+
406
+ const client = await initGraphClient();
407
+ expect(client).toBeInstanceOf(GraphClient);
408
+ expect(writeAtomicSyncMock).toHaveBeenCalled();
409
+ });
410
+
411
+ it("falls through to deviceCodeAuth when no stored tokens", async () => {
412
+ vi.useFakeTimers();
413
+ existsSyncMock.mockReturnValue(false);
414
+ proxyFetchMock
415
+ .mockResolvedValueOnce(
416
+ mockResponse({
417
+ device_code: "dc-code",
418
+ user_code: "ABC-123",
419
+ verification_uri: "https://microsoft.com/devicelogin",
420
+ expires_in: 300,
421
+ interval: 1, // 1-second poll
422
+ message: "Sign in at https://microsoft.com/devicelogin",
423
+ }),
424
+ )
425
+ .mockResolvedValueOnce(
426
+ mockResponse({
427
+ access_token: "device-token",
428
+ refresh_token: "device-refresh",
429
+ expires_in: 3600,
430
+ }),
431
+ );
432
+
433
+ const promise = initGraphClient();
434
+ await vi.advanceTimersByTimeAsync(1100);
435
+ const client = await promise;
436
+ expect(client).toBeInstanceOf(GraphClient);
437
+ vi.useRealTimers();
438
+ });
439
+
440
+ it("falls back to deviceCodeAuth when stored refresh token fails", async () => {
441
+ vi.useFakeTimers();
442
+ existsSyncMock.mockReturnValue(true);
443
+ readFileSyncMock.mockReturnValue(
444
+ JSON.stringify({
445
+ accessToken: "old",
446
+ refreshToken: "bad-refresh",
447
+ expiresAt: Date.now() - 1000,
448
+ }),
449
+ );
450
+
451
+ proxyFetchMock
452
+ .mockResolvedValueOnce(
453
+ mockResponse({
454
+ error: "invalid_grant",
455
+ error_description: "token expired",
456
+ }),
457
+ )
458
+ .mockResolvedValueOnce(
459
+ mockResponse({
460
+ device_code: "dc2",
461
+ user_code: "XYZ-789",
462
+ verification_uri: "https://microsoft.com/devicelogin",
463
+ expires_in: 300,
464
+ interval: 1,
465
+ message: "Sign in",
466
+ }),
467
+ )
468
+ .mockResolvedValueOnce(
469
+ mockResponse({
470
+ access_token: "new-device-token",
471
+ refresh_token: "new-device-refresh",
472
+ expires_in: 3600,
473
+ }),
474
+ );
475
+
476
+ const promise = initGraphClient();
477
+ await vi.advanceTimersByTimeAsync(1100);
478
+ const client = await promise;
479
+ expect(client).toBeInstanceOf(GraphClient);
480
+ vi.useRealTimers();
481
+ });
482
+ });
483
+
484
+ // ── loadTokens edge cases ──────────────────────────────────────────────────
485
+
486
+ describe("loadTokens (via initGraphClient)", () => {
487
+ beforeEach(() => {
488
+ vi.clearAllMocks();
489
+ });
490
+
491
+ it("handles corrupt token file gracefully (falls back to deviceCodeAuth)", async () => {
492
+ vi.useFakeTimers();
493
+ existsSyncMock.mockReturnValue(true);
494
+ readFileSyncMock.mockReturnValue("{ invalid json {{{{");
495
+
496
+ proxyFetchMock
497
+ .mockResolvedValueOnce(
498
+ mockResponse({
499
+ device_code: "dc3",
500
+ user_code: "MNO-456",
501
+ verification_uri: "https://microsoft.com/devicelogin",
502
+ expires_in: 300,
503
+ interval: 1,
504
+ message: "Sign in",
505
+ }),
506
+ )
507
+ .mockResolvedValueOnce(
508
+ mockResponse({
509
+ access_token: "from-device",
510
+ refresh_token: "from-device-refresh",
511
+ expires_in: 3600,
512
+ }),
513
+ );
514
+
515
+ const promise = initGraphClient();
516
+ await vi.advanceTimersByTimeAsync(1100);
517
+ const client = await promise;
518
+ expect(client).toBeInstanceOf(GraphClient);
519
+ vi.useRealTimers();
520
+ });
521
+ });
522
+
523
+ // ── deviceCodeAuth polling paths ─────────────────────────────────────────────
524
+
525
+ describe("deviceCodeAuth polling edge cases", () => {
526
+ beforeEach(() => {
527
+ vi.clearAllMocks();
528
+ existsSyncMock.mockReturnValue(false);
529
+ mkdirSyncMock.mockReturnValue(undefined);
530
+ });
531
+
532
+ it("handles authorization_pending then succeeds", async () => {
533
+ vi.useFakeTimers();
534
+ proxyFetchMock
535
+ .mockResolvedValueOnce(
536
+ mockResponse({
537
+ device_code: "dc-pending",
538
+ user_code: "AAA-000",
539
+ verification_uri: "https://microsoft.com/devicelogin",
540
+ expires_in: 300,
541
+ interval: 1,
542
+ message: "Sign in",
543
+ }),
544
+ )
545
+ .mockResolvedValueOnce(mockResponse({ error: "authorization_pending" }))
546
+ .mockResolvedValueOnce(
547
+ mockResponse({
548
+ access_token: "final-token",
549
+ refresh_token: "final-refresh",
550
+ expires_in: 3600,
551
+ }),
552
+ );
553
+
554
+ const promise = deviceCodeAuth();
555
+ await vi.advanceTimersByTimeAsync(1100);
556
+ await vi.advanceTimersByTimeAsync(1100);
557
+ const tokens = await promise;
558
+ expect(tokens.accessToken).toBe("final-token");
559
+ vi.useRealTimers();
560
+ });
561
+
562
+ it("handles slow_down response (waits extra 5s before continuing)", async () => {
563
+ vi.useFakeTimers();
564
+ proxyFetchMock
565
+ .mockResolvedValueOnce(
566
+ mockResponse({
567
+ device_code: "dc-slow",
568
+ user_code: "BBB-111",
569
+ verification_uri: "https://microsoft.com/devicelogin",
570
+ expires_in: 300,
571
+ interval: 1,
572
+ message: "Sign in",
573
+ }),
574
+ )
575
+ .mockResolvedValueOnce(mockResponse({ error: "slow_down" }))
576
+ .mockResolvedValueOnce(
577
+ mockResponse({
578
+ access_token: "slow-down-token",
579
+ refresh_token: "slow-down-refresh",
580
+ expires_in: 3600,
581
+ }),
582
+ );
583
+
584
+ const promise = deviceCodeAuth();
585
+ await vi.advanceTimersByTimeAsync(1100);
586
+ await vi.advanceTimersByTimeAsync(5100);
587
+ await vi.advanceTimersByTimeAsync(1100);
588
+ const tokens = await promise;
589
+ expect(tokens.accessToken).toBe("slow-down-token");
590
+ vi.useRealTimers();
591
+ });
592
+
593
+ it("throws on permanent auth error (access_denied)", async () => {
594
+ vi.useFakeTimers();
595
+ proxyFetchMock
596
+ .mockResolvedValueOnce(
597
+ mockResponse({
598
+ device_code: "dc-err",
599
+ user_code: "CCC-222",
600
+ verification_uri: "https://microsoft.com/devicelogin",
601
+ expires_in: 300,
602
+ interval: 1,
603
+ message: "Sign in",
604
+ }),
605
+ )
606
+ .mockResolvedValueOnce(
607
+ mockResponse({
608
+ error: "access_denied",
609
+ error_description: "The user declined to authorize",
610
+ }),
611
+ );
612
+
613
+ const promise = deviceCodeAuth();
614
+ // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
615
+ const check = expect(promise).rejects.toThrow("Auth failed");
616
+ await vi.advanceTimersByTimeAsync(1100);
617
+ await check;
618
+ vi.useRealTimers();
619
+ });
620
+ });
621
+
622
+ // ── deviceCodeAuth — branch coverage ─────────────────────────────────────────
623
+
624
+ describe("deviceCodeAuth — branch coverage", () => {
625
+ beforeEach(() => {
626
+ vi.clearAllMocks();
627
+ existsSyncMock.mockReturnValue(false);
628
+ mkdirSyncMock.mockReturnValue(undefined);
629
+ });
630
+
631
+ it("uses default interval (5s) when response omits interval field", async () => {
632
+ vi.useFakeTimers();
633
+ proxyFetchMock
634
+ .mockResolvedValueOnce(
635
+ mockResponse({
636
+ device_code: "dc-noint",
637
+ user_code: "ZZZ-999",
638
+ verification_uri: "https://microsoft.com/devicelogin",
639
+ expires_in: 300,
640
+ // interval intentionally omitted → triggers (dcResp.interval || 5)
641
+ message: "Sign in",
642
+ }),
643
+ )
644
+ .mockResolvedValueOnce(
645
+ mockResponse({
646
+ access_token: "default-interval-token",
647
+ refresh_token: "ri",
648
+ expires_in: 3600,
649
+ }),
650
+ );
651
+
652
+ const promise = deviceCodeAuth();
653
+ // Default interval is 5s
654
+ await vi.advanceTimersByTimeAsync(5100);
655
+ const tokens = await promise;
656
+ expect(tokens.accessToken).toBe("default-interval-token");
657
+ vi.useRealTimers();
658
+ });
659
+
660
+ it("uses empty string when refresh_token is absent", async () => {
661
+ vi.useFakeTimers();
662
+ proxyFetchMock
663
+ .mockResolvedValueOnce(
664
+ mockResponse({
665
+ device_code: "dc-norefresh",
666
+ user_code: "YYY-888",
667
+ verification_uri: "https://microsoft.com/devicelogin",
668
+ expires_in: 300,
669
+ interval: 1,
670
+ message: "Sign in",
671
+ }),
672
+ )
673
+ .mockResolvedValueOnce(
674
+ mockResponse({
675
+ access_token: "no-refresh-token",
676
+ // refresh_token intentionally omitted → triggers (tokenResp.refresh_token || "")
677
+ expires_in: 3600,
678
+ }),
679
+ );
680
+
681
+ const promise = deviceCodeAuth();
682
+ await vi.advanceTimersByTimeAsync(1100);
683
+ const tokens = await promise;
684
+ expect(tokens.accessToken).toBe("no-refresh-token");
685
+ expect(tokens.refreshToken).toBe("");
686
+ vi.useRealTimers();
687
+ });
688
+
689
+ it("uses error code when error_description is absent", async () => {
690
+ vi.useFakeTimers();
691
+ proxyFetchMock
692
+ .mockResolvedValueOnce(
693
+ mockResponse({
694
+ device_code: "dc-noerrdesc",
695
+ user_code: "XXX-777",
696
+ verification_uri: "https://microsoft.com/devicelogin",
697
+ expires_in: 300,
698
+ interval: 1,
699
+ message: "Sign in",
700
+ }),
701
+ )
702
+ .mockResolvedValueOnce(
703
+ mockResponse({
704
+ // error_description omitted → triggers (tokenResp.error_description || tokenResp.error)
705
+ error: "invalid_grant",
706
+ }),
707
+ );
708
+
709
+ const promise = deviceCodeAuth();
710
+ const check = expect(promise).rejects.toThrow("Auth failed: invalid_grant");
711
+ await vi.advanceTimersByTimeAsync(1100);
712
+ await check;
713
+ vi.useRealTimers();
714
+ });
715
+ });
716
+
717
+ // ── getChatMessages HTML content type ─────────────────────────────────────────
718
+
719
+ describe("getChatMessages — HTML content", () => {
720
+ beforeEach(() => {
721
+ vi.clearAllMocks();
722
+ existsSyncMock.mockReturnValue(true);
723
+ });
724
+
725
+ it("strips HTML when contentType is html", async () => {
726
+ const client = new GraphClient(
727
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
728
+ );
729
+ proxyFetchMock.mockResolvedValue(
730
+ mockResponse({
731
+ value: [
732
+ {
733
+ id: "html-msg",
734
+ messageType: "message",
735
+ from: { user: { id: "u5", displayName: "Carol" } },
736
+ body: {
737
+ contentType: "html",
738
+ content: "<p>Hello <b>Teams</b>!</p>",
739
+ },
740
+ createdDateTime: "2026-01-01T13:00:00Z",
741
+ lastEditedDateTime: null,
742
+ },
743
+ ],
744
+ }),
745
+ );
746
+
747
+ const messages = await client.getChatMessages("chat1");
748
+ // stripHtml should have been invoked (mocked to return "stripped text")
749
+ expect(messages[0]!.text).toBe("stripped text");
750
+ });
751
+
752
+ it("uses empty string when body content is null/empty", async () => {
753
+ const client = new GraphClient(
754
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
755
+ );
756
+ proxyFetchMock.mockResolvedValue(
757
+ mockResponse({
758
+ value: [
759
+ {
760
+ id: "empty-body-msg",
761
+ messageType: "message",
762
+ from: { user: { id: "u6", displayName: "Dave" } },
763
+ body: { contentType: "text", content: "" }, // empty content → triggers || ""
764
+ createdDateTime: "2026-01-01T14:00:00Z",
765
+ lastEditedDateTime: null,
766
+ },
767
+ ],
768
+ }),
769
+ );
770
+
771
+ const messages = await client.getChatMessages("chat1");
772
+ expect(messages[0]!.text).toBe("");
773
+ });
774
+
775
+ it("handles null body gracefully", async () => {
776
+ const client = new GraphClient(
777
+ makeTokens() as ConstructorParameters<typeof GraphClient>[0],
778
+ );
779
+ proxyFetchMock.mockResolvedValue(
780
+ mockResponse({
781
+ value: [
782
+ {
783
+ id: "null-body-msg",
784
+ messageType: "message",
785
+ from: { user: { id: "u7", displayName: "Eve" } },
786
+ body: null, // null body → triggers body?.content || ""
787
+ createdDateTime: "2026-01-01T15:00:00Z",
788
+ lastEditedDateTime: null,
789
+ },
790
+ ],
791
+ }),
792
+ );
793
+
794
+ const messages = await client.getChatMessages("chat1");
795
+ expect(messages[0]!.text).toBe("");
796
+ });
797
+ });
798
+
799
+ describe("deviceCodeAuth — timeout path", () => {
800
+ beforeEach(() => {
801
+ vi.clearAllMocks();
802
+ existsSyncMock.mockReturnValue(false);
803
+ });
804
+
805
+ it("throws when device code auth deadline expires", async () => {
806
+ vi.useFakeTimers();
807
+ proxyFetchMock
808
+ // device code request
809
+ .mockResolvedValueOnce(
810
+ mockResponse({
811
+ device_code: "dc-to",
812
+ user_code: "DDD-333",
813
+ verification_uri: "https://microsoft.com/devicelogin",
814
+ expires_in: 5, // 5-second deadline
815
+ interval: 1,
816
+ message: "Sign in",
817
+ }),
818
+ )
819
+ // Always return authorization_pending — loop runs until deadline
820
+ .mockResolvedValue(mockResponse({ error: "authorization_pending" }));
821
+
822
+ const promise = deviceCodeAuth();
823
+ // Attach handler first to avoid unhandled rejection
824
+ const check = expect(promise).rejects.toThrow("Device code auth timed out");
825
+ // Advance past the 5-second deadline
826
+ await vi.advanceTimersByTimeAsync(6100);
827
+ await check;
828
+ vi.useRealTimers();
829
+ });
830
+ });