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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- 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
|
+
});
|