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
|
@@ -3,7 +3,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
3
3
|
// ── Module mocks (must come before import) ──────────────────────────────────
|
|
4
4
|
|
|
5
5
|
vi.mock("../util/log.js", () => ({
|
|
6
|
-
log: vi.fn(),
|
|
6
|
+
log: vi.fn(),
|
|
7
|
+
logError: vi.fn(),
|
|
8
|
+
logWarn: vi.fn(),
|
|
9
|
+
logDebug: vi.fn(),
|
|
7
10
|
}));
|
|
8
11
|
|
|
9
12
|
vi.mock("write-file-atomic", () => ({
|
|
@@ -31,7 +34,12 @@ const mockGetCronJob = vi.fn();
|
|
|
31
34
|
const mockGetCronJobsForChat = vi.fn((): any[] => []);
|
|
32
35
|
const mockUpdateCronJob = vi.fn();
|
|
33
36
|
const mockDeleteCronJob = vi.fn();
|
|
34
|
-
const mockValidateCronExpression = vi.fn(
|
|
37
|
+
const mockValidateCronExpression = vi.fn(
|
|
38
|
+
(): { valid: boolean; next?: string; error?: string } => ({
|
|
39
|
+
valid: true,
|
|
40
|
+
next: "2026-04-01T09:00:00.000Z",
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
35
43
|
const mockGenerateCronId = vi.fn(() => "test-id-123");
|
|
36
44
|
|
|
37
45
|
vi.mock("../storage/cron-store.js", () => ({
|
|
@@ -82,14 +90,27 @@ function mockResponse(opts: {
|
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/** Create an ArrayBuffer with valid image magic bytes. */
|
|
85
|
-
function imageBuffer(
|
|
93
|
+
function imageBuffer(
|
|
94
|
+
type: "png" | "jpg" | "gif" | "webp",
|
|
95
|
+
size = 1024,
|
|
96
|
+
): ArrayBuffer {
|
|
86
97
|
const buf = new ArrayBuffer(Math.max(size, 16));
|
|
87
98
|
const view = new Uint8Array(buf);
|
|
88
99
|
switch (type) {
|
|
89
|
-
case "png":
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
case "
|
|
100
|
+
case "png":
|
|
101
|
+
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
102
|
+
break;
|
|
103
|
+
case "jpg":
|
|
104
|
+
view.set([0xff, 0xd8, 0xff, 0xe0]);
|
|
105
|
+
break;
|
|
106
|
+
case "gif":
|
|
107
|
+
view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
|
108
|
+
break;
|
|
109
|
+
case "webp":
|
|
110
|
+
view.set([
|
|
111
|
+
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
|
112
|
+
]);
|
|
113
|
+
break;
|
|
93
114
|
}
|
|
94
115
|
return buf;
|
|
95
116
|
}
|
|
@@ -120,7 +141,9 @@ describe("gateway shared actions", () => {
|
|
|
120
141
|
|
|
121
142
|
describe("unknown actions", () => {
|
|
122
143
|
it("returns null for unknown actions", async () => {
|
|
123
|
-
expect(
|
|
144
|
+
expect(
|
|
145
|
+
await handleSharedAction({ action: "unknown_thing" }, 123),
|
|
146
|
+
).toBeNull();
|
|
124
147
|
});
|
|
125
148
|
|
|
126
149
|
it("returns null for empty action", async () => {
|
|
@@ -162,18 +185,27 @@ describe("gateway shared actions", () => {
|
|
|
162
185
|
|
|
163
186
|
describe("search_history", () => {
|
|
164
187
|
it("returns search results with default limit", async () => {
|
|
165
|
-
const result = await handleSharedAction(
|
|
188
|
+
const result = await handleSharedAction(
|
|
189
|
+
{ action: "search_history", query: "hello" },
|
|
190
|
+
42,
|
|
191
|
+
);
|
|
166
192
|
expect(result).toEqual({ ok: true, text: "search results here" });
|
|
167
193
|
expect(mockSearchHistory).toHaveBeenCalledWith("42", "hello", 20);
|
|
168
194
|
});
|
|
169
195
|
|
|
170
196
|
it("passes custom limit", async () => {
|
|
171
|
-
await handleSharedAction(
|
|
197
|
+
await handleSharedAction(
|
|
198
|
+
{ action: "search_history", query: "test", limit: 5 },
|
|
199
|
+
42,
|
|
200
|
+
);
|
|
172
201
|
expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 5);
|
|
173
202
|
});
|
|
174
203
|
|
|
175
204
|
it("clamps limit to 100", async () => {
|
|
176
|
-
await handleSharedAction(
|
|
205
|
+
await handleSharedAction(
|
|
206
|
+
{ action: "search_history", query: "test", limit: 999 },
|
|
207
|
+
42,
|
|
208
|
+
);
|
|
177
209
|
expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 100);
|
|
178
210
|
});
|
|
179
211
|
|
|
@@ -185,18 +217,27 @@ describe("gateway shared actions", () => {
|
|
|
185
217
|
|
|
186
218
|
describe("get_user_messages", () => {
|
|
187
219
|
it("returns user messages with default limit", async () => {
|
|
188
|
-
const result = await handleSharedAction(
|
|
220
|
+
const result = await handleSharedAction(
|
|
221
|
+
{ action: "get_user_messages", user_name: "alice" },
|
|
222
|
+
42,
|
|
223
|
+
);
|
|
189
224
|
expect(result).toEqual({ ok: true, text: "user messages here" });
|
|
190
225
|
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "alice", 20);
|
|
191
226
|
});
|
|
192
227
|
|
|
193
228
|
it("passes custom limit", async () => {
|
|
194
|
-
await handleSharedAction(
|
|
229
|
+
await handleSharedAction(
|
|
230
|
+
{ action: "get_user_messages", user_name: "bob", limit: 10 },
|
|
231
|
+
42,
|
|
232
|
+
);
|
|
195
233
|
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 10);
|
|
196
234
|
});
|
|
197
235
|
|
|
198
236
|
it("clamps limit to 50", async () => {
|
|
199
|
-
await handleSharedAction(
|
|
237
|
+
await handleSharedAction(
|
|
238
|
+
{ action: "get_user_messages", user_name: "bob", limit: 200 },
|
|
239
|
+
42,
|
|
240
|
+
);
|
|
200
241
|
expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 50);
|
|
201
242
|
});
|
|
202
243
|
|
|
@@ -208,7 +249,10 @@ describe("gateway shared actions", () => {
|
|
|
208
249
|
|
|
209
250
|
describe("list_known_users", () => {
|
|
210
251
|
it("returns known users", async () => {
|
|
211
|
-
const result = await handleSharedAction(
|
|
252
|
+
const result = await handleSharedAction(
|
|
253
|
+
{ action: "list_known_users" },
|
|
254
|
+
42,
|
|
255
|
+
);
|
|
212
256
|
expect(result).toEqual({ ok: true, text: "alice, bob" });
|
|
213
257
|
expect(mockGetKnownUsers).toHaveBeenCalledWith("42");
|
|
214
258
|
});
|
|
@@ -243,27 +287,43 @@ describe("gateway shared actions", () => {
|
|
|
243
287
|
});
|
|
244
288
|
|
|
245
289
|
it("returns error for empty query string", async () => {
|
|
246
|
-
const result = await handleSharedAction(
|
|
290
|
+
const result = await handleSharedAction(
|
|
291
|
+
{ action: "web_search", query: "" },
|
|
292
|
+
123,
|
|
293
|
+
);
|
|
247
294
|
expect(result).toEqual({ ok: false, error: "Missing query" });
|
|
248
295
|
});
|
|
249
296
|
|
|
250
297
|
it("uses Brave API when key is configured", async () => {
|
|
251
298
|
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
252
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
299
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
300
|
+
mockResponse({
|
|
301
|
+
ok: true,
|
|
302
|
+
contentType: "application/json",
|
|
303
|
+
json: {
|
|
304
|
+
web: {
|
|
305
|
+
results: [
|
|
306
|
+
{
|
|
307
|
+
title: "Result 1",
|
|
308
|
+
url: "https://example.com/1",
|
|
309
|
+
description: "Description 1",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
title: "Result 2",
|
|
313
|
+
url: "https://example.com/2",
|
|
314
|
+
description: "Description 2",
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
261
318
|
},
|
|
262
|
-
},
|
|
263
|
-
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
264
321
|
vi.stubGlobal("fetch", mockFetch);
|
|
265
322
|
|
|
266
|
-
const result = await handleSharedAction(
|
|
323
|
+
const result = await handleSharedAction(
|
|
324
|
+
{ action: "web_search", query: "test query" },
|
|
325
|
+
123,
|
|
326
|
+
);
|
|
267
327
|
|
|
268
328
|
expect(result?.ok).toBe(true);
|
|
269
329
|
expect(result?.text).toContain("via Brave");
|
|
@@ -283,45 +343,73 @@ describe("gateway shared actions", () => {
|
|
|
283
343
|
|
|
284
344
|
it("respects custom limit for Brave API", async () => {
|
|
285
345
|
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
286
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
346
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
347
|
+
mockResponse({
|
|
348
|
+
ok: true,
|
|
349
|
+
json: {
|
|
350
|
+
web: {
|
|
351
|
+
results: [{ title: "R", url: "https://r.com", description: "d" }],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
290
356
|
vi.stubGlobal("fetch", mockFetch);
|
|
291
357
|
|
|
292
|
-
await handleSharedAction(
|
|
358
|
+
await handleSharedAction(
|
|
359
|
+
{ action: "web_search", query: "test", limit: 8 },
|
|
360
|
+
123,
|
|
361
|
+
);
|
|
293
362
|
const [url] = mockFetch.mock.calls[0];
|
|
294
363
|
expect(url).toContain("count=8");
|
|
295
364
|
});
|
|
296
365
|
|
|
297
366
|
it("clamps search limit to 10", async () => {
|
|
298
367
|
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
299
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
368
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
369
|
+
mockResponse({
|
|
370
|
+
ok: true,
|
|
371
|
+
json: {
|
|
372
|
+
web: {
|
|
373
|
+
results: [{ title: "R", url: "https://r.com", description: "d" }],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
303
378
|
vi.stubGlobal("fetch", mockFetch);
|
|
304
379
|
|
|
305
|
-
await handleSharedAction(
|
|
380
|
+
await handleSharedAction(
|
|
381
|
+
{ action: "web_search", query: "test", limit: 50 },
|
|
382
|
+
123,
|
|
383
|
+
);
|
|
306
384
|
const [url] = mockFetch.mock.calls[0];
|
|
307
385
|
expect(url).toContain("count=10");
|
|
308
386
|
});
|
|
309
387
|
|
|
310
388
|
it("falls back to SearXNG when Brave returns non-ok", async () => {
|
|
311
389
|
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
312
|
-
const mockFetch = vi
|
|
313
|
-
.
|
|
314
|
-
.mockResolvedValueOnce(mockResponse({
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
390
|
+
const mockFetch = vi
|
|
391
|
+
.fn()
|
|
392
|
+
.mockResolvedValueOnce(mockResponse({ ok: false, status: 429 })) // Brave fails
|
|
393
|
+
.mockResolvedValueOnce(
|
|
394
|
+
mockResponse({
|
|
395
|
+
ok: true,
|
|
396
|
+
json: {
|
|
397
|
+
results: [
|
|
398
|
+
{
|
|
399
|
+
title: "SearX Result",
|
|
400
|
+
url: "https://searx.example.com",
|
|
401
|
+
content: "SearX snippet",
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
322
407
|
vi.stubGlobal("fetch", mockFetch);
|
|
323
408
|
|
|
324
|
-
const result = await handleSharedAction(
|
|
409
|
+
const result = await handleSharedAction(
|
|
410
|
+
{ action: "web_search", query: "fallback test" },
|
|
411
|
+
123,
|
|
412
|
+
);
|
|
325
413
|
|
|
326
414
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
327
415
|
expect(result?.ok).toBe(true);
|
|
@@ -331,19 +419,29 @@ describe("gateway shared actions", () => {
|
|
|
331
419
|
|
|
332
420
|
it("falls back to SearXNG when Brave throws an error", async () => {
|
|
333
421
|
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
334
|
-
const mockFetch = vi
|
|
335
|
-
.
|
|
336
|
-
.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
422
|
+
const mockFetch = vi
|
|
423
|
+
.fn()
|
|
424
|
+
.mockRejectedValueOnce(new Error("network error")) // Brave throws
|
|
425
|
+
.mockResolvedValueOnce(
|
|
426
|
+
mockResponse({
|
|
427
|
+
ok: true,
|
|
428
|
+
json: {
|
|
429
|
+
results: [
|
|
430
|
+
{
|
|
431
|
+
title: "Fallback",
|
|
432
|
+
url: "https://fb.com",
|
|
433
|
+
content: "snippet",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
344
439
|
vi.stubGlobal("fetch", mockFetch);
|
|
345
440
|
|
|
346
|
-
const result = await handleSharedAction(
|
|
441
|
+
const result = await handleSharedAction(
|
|
442
|
+
{ action: "web_search", query: "test" },
|
|
443
|
+
123,
|
|
444
|
+
);
|
|
347
445
|
|
|
348
446
|
expect(result?.ok).toBe(true);
|
|
349
447
|
expect(result?.text).toContain("via SearXNG");
|
|
@@ -351,17 +449,26 @@ describe("gateway shared actions", () => {
|
|
|
351
449
|
|
|
352
450
|
it("uses SearXNG directly when no Brave key", async () => {
|
|
353
451
|
// No TALON_BRAVE_API_KEY set
|
|
354
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
452
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
453
|
+
mockResponse({
|
|
454
|
+
ok: true,
|
|
455
|
+
json: {
|
|
456
|
+
results: [
|
|
457
|
+
{
|
|
458
|
+
title: "Direct SearX",
|
|
459
|
+
url: "https://searx.com/r",
|
|
460
|
+
content: "content here",
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
362
466
|
vi.stubGlobal("fetch", mockFetch);
|
|
363
467
|
|
|
364
|
-
const result = await handleSharedAction(
|
|
468
|
+
const result = await handleSharedAction(
|
|
469
|
+
{ action: "web_search", query: "direct" },
|
|
470
|
+
123,
|
|
471
|
+
);
|
|
365
472
|
|
|
366
473
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
367
474
|
const [url] = mockFetch.mock.calls[0];
|
|
@@ -373,10 +480,14 @@ describe("gateway shared actions", () => {
|
|
|
373
480
|
|
|
374
481
|
it("uses custom SearXNG URL from env", async () => {
|
|
375
482
|
process.env.TALON_SEARXNG_URL = "http://my-searx:9090";
|
|
376
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
483
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
484
|
+
mockResponse({
|
|
485
|
+
ok: true,
|
|
486
|
+
json: {
|
|
487
|
+
results: [{ title: "T", url: "https://t.com", content: "c" }],
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
380
491
|
vi.stubGlobal("fetch", mockFetch);
|
|
381
492
|
|
|
382
493
|
await handleSharedAction({ action: "web_search", query: "custom" }, 123);
|
|
@@ -387,12 +498,16 @@ describe("gateway shared actions", () => {
|
|
|
387
498
|
|
|
388
499
|
it("returns 'no results' when both providers fail", async () => {
|
|
389
500
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
390
|
-
const mockFetch = vi
|
|
501
|
+
const mockFetch = vi
|
|
502
|
+
.fn()
|
|
391
503
|
.mockRejectedValueOnce(new Error("brave fail"))
|
|
392
504
|
.mockRejectedValueOnce(new Error("searx fail"));
|
|
393
505
|
vi.stubGlobal("fetch", mockFetch);
|
|
394
506
|
|
|
395
|
-
const result = await handleSharedAction(
|
|
507
|
+
const result = await handleSharedAction(
|
|
508
|
+
{ action: "web_search", query: "nothing" },
|
|
509
|
+
123,
|
|
510
|
+
);
|
|
396
511
|
|
|
397
512
|
expect(result?.ok).toBe(true);
|
|
398
513
|
expect(result?.text).toBe('No results for "nothing".');
|
|
@@ -400,12 +515,16 @@ describe("gateway shared actions", () => {
|
|
|
400
515
|
|
|
401
516
|
it("returns 'no results' when both return non-ok", async () => {
|
|
402
517
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
403
|
-
const mockFetch = vi
|
|
518
|
+
const mockFetch = vi
|
|
519
|
+
.fn()
|
|
404
520
|
.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
|
|
405
521
|
.mockResolvedValueOnce(mockResponse({ ok: false, status: 503 }));
|
|
406
522
|
vi.stubGlobal("fetch", mockFetch);
|
|
407
523
|
|
|
408
|
-
const result = await handleSharedAction(
|
|
524
|
+
const result = await handleSharedAction(
|
|
525
|
+
{ action: "web_search", query: "failing" },
|
|
526
|
+
123,
|
|
527
|
+
);
|
|
409
528
|
|
|
410
529
|
expect(result?.ok).toBe(true);
|
|
411
530
|
expect(result?.text).toBe('No results for "failing".');
|
|
@@ -413,12 +532,20 @@ describe("gateway shared actions", () => {
|
|
|
413
532
|
|
|
414
533
|
it("returns 'no results' when Brave returns empty results array", async () => {
|
|
415
534
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
416
|
-
const mockFetch = vi
|
|
417
|
-
.
|
|
418
|
-
.mockResolvedValueOnce(
|
|
535
|
+
const mockFetch = vi
|
|
536
|
+
.fn()
|
|
537
|
+
.mockResolvedValueOnce(
|
|
538
|
+
mockResponse({ ok: true, json: { web: { results: [] } } }),
|
|
539
|
+
)
|
|
540
|
+
.mockResolvedValueOnce(
|
|
541
|
+
mockResponse({ ok: true, json: { results: [] } }),
|
|
542
|
+
);
|
|
419
543
|
vi.stubGlobal("fetch", mockFetch);
|
|
420
544
|
|
|
421
|
-
const result = await handleSharedAction(
|
|
545
|
+
const result = await handleSharedAction(
|
|
546
|
+
{ action: "web_search", query: "empty" },
|
|
547
|
+
123,
|
|
548
|
+
);
|
|
422
549
|
|
|
423
550
|
expect(result?.ok).toBe(true);
|
|
424
551
|
expect(result?.text).toBe('No results for "empty".');
|
|
@@ -426,28 +553,49 @@ describe("gateway shared actions", () => {
|
|
|
426
553
|
|
|
427
554
|
it("handles Brave response with missing web field", async () => {
|
|
428
555
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
429
|
-
const mockFetch = vi
|
|
430
|
-
.
|
|
431
|
-
.mockResolvedValueOnce(mockResponse({
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
556
|
+
const mockFetch = vi
|
|
557
|
+
.fn()
|
|
558
|
+
.mockResolvedValueOnce(mockResponse({ ok: true, json: {} })) // no web field
|
|
559
|
+
.mockResolvedValueOnce(
|
|
560
|
+
mockResponse({
|
|
561
|
+
ok: true,
|
|
562
|
+
json: {
|
|
563
|
+
results: [
|
|
564
|
+
{ title: "FallbackR", url: "https://f.com", content: "fb" },
|
|
565
|
+
],
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
435
569
|
vi.stubGlobal("fetch", mockFetch);
|
|
436
570
|
|
|
437
|
-
const result = await handleSharedAction(
|
|
571
|
+
const result = await handleSharedAction(
|
|
572
|
+
{ action: "web_search", query: "test" },
|
|
573
|
+
123,
|
|
574
|
+
);
|
|
438
575
|
expect(result?.text).toContain("via SearXNG");
|
|
439
576
|
});
|
|
440
577
|
|
|
441
578
|
it("truncates long snippets to 200 chars", async () => {
|
|
442
579
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
443
580
|
const longDesc = "A".repeat(500);
|
|
444
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
581
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
582
|
+
mockResponse({
|
|
583
|
+
ok: true,
|
|
584
|
+
json: {
|
|
585
|
+
web: {
|
|
586
|
+
results: [
|
|
587
|
+
{ title: "Long", url: "https://l.com", description: longDesc },
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
448
593
|
vi.stubGlobal("fetch", mockFetch);
|
|
449
594
|
|
|
450
|
-
const result = await handleSharedAction(
|
|
595
|
+
const result = await handleSharedAction(
|
|
596
|
+
{ action: "web_search", query: "long" },
|
|
597
|
+
123,
|
|
598
|
+
);
|
|
451
599
|
// The snippet should be sliced to 200 chars
|
|
452
600
|
expect(result?.text).not.toContain("A".repeat(201));
|
|
453
601
|
expect(result?.text).toContain("A".repeat(200));
|
|
@@ -455,29 +603,43 @@ describe("gateway shared actions", () => {
|
|
|
455
603
|
|
|
456
604
|
it("handles missing description in Brave results", async () => {
|
|
457
605
|
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
458
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
606
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
607
|
+
mockResponse({
|
|
608
|
+
ok: true,
|
|
609
|
+
json: {
|
|
610
|
+
web: { results: [{ title: "NoDesc", url: "https://nd.com" }] },
|
|
611
|
+
},
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
462
614
|
vi.stubGlobal("fetch", mockFetch);
|
|
463
615
|
|
|
464
|
-
const result = await handleSharedAction(
|
|
616
|
+
const result = await handleSharedAction(
|
|
617
|
+
{ action: "web_search", query: "nodesc" },
|
|
618
|
+
123,
|
|
619
|
+
);
|
|
465
620
|
expect(result?.ok).toBe(true);
|
|
466
621
|
expect(result?.text).toContain("NoDesc");
|
|
467
622
|
});
|
|
468
623
|
|
|
469
624
|
it("slices SearXNG results to limit", async () => {
|
|
470
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
625
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
626
|
+
mockResponse({
|
|
627
|
+
ok: true,
|
|
628
|
+
json: {
|
|
629
|
+
results: Array.from({ length: 20 }, (_, i) => ({
|
|
630
|
+
title: `R${i}`,
|
|
631
|
+
url: `https://r${i}.com`,
|
|
632
|
+
content: `c${i}`,
|
|
633
|
+
})),
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
478
637
|
vi.stubGlobal("fetch", mockFetch);
|
|
479
638
|
|
|
480
|
-
const result = await handleSharedAction(
|
|
639
|
+
const result = await handleSharedAction(
|
|
640
|
+
{ action: "web_search", query: "many", limit: 3 },
|
|
641
|
+
123,
|
|
642
|
+
);
|
|
481
643
|
// Should only contain 3 results (numbered 1-3)
|
|
482
644
|
expect(result?.text).toContain("1. R0");
|
|
483
645
|
expect(result?.text).toContain("3. R2");
|
|
@@ -496,23 +658,35 @@ describe("gateway shared actions", () => {
|
|
|
496
658
|
});
|
|
497
659
|
|
|
498
660
|
it("rejects non-http protocols", async () => {
|
|
499
|
-
const result = await handleSharedAction(
|
|
661
|
+
const result = await handleSharedAction(
|
|
662
|
+
{ action: "fetch_url", url: "ftp://example.com" },
|
|
663
|
+
123,
|
|
664
|
+
);
|
|
500
665
|
expect(result?.ok).toBe(false);
|
|
501
666
|
expect(result?.error).toContain("http or https");
|
|
502
667
|
});
|
|
503
668
|
|
|
504
669
|
it("rejects malformed URLs", async () => {
|
|
505
|
-
const result = await handleSharedAction(
|
|
670
|
+
const result = await handleSharedAction(
|
|
671
|
+
{ action: "fetch_url", url: "not a url at all" },
|
|
672
|
+
123,
|
|
673
|
+
);
|
|
506
674
|
expect(result).toEqual({ ok: false, error: "Invalid URL" });
|
|
507
675
|
});
|
|
508
676
|
|
|
509
677
|
it("rejects javascript: protocol", async () => {
|
|
510
|
-
const result = await handleSharedAction(
|
|
678
|
+
const result = await handleSharedAction(
|
|
679
|
+
{ action: "fetch_url", url: "javascript:alert(1)" },
|
|
680
|
+
123,
|
|
681
|
+
);
|
|
511
682
|
expect(result?.ok).toBe(false);
|
|
512
683
|
});
|
|
513
684
|
|
|
514
685
|
it("rejects data: protocol", async () => {
|
|
515
|
-
const result = await handleSharedAction(
|
|
686
|
+
const result = await handleSharedAction(
|
|
687
|
+
{ action: "fetch_url", url: "data:text/html,<h1>hi</h1>" },
|
|
688
|
+
123,
|
|
689
|
+
);
|
|
516
690
|
expect(result?.ok).toBe(false);
|
|
517
691
|
});
|
|
518
692
|
|
|
@@ -523,14 +697,19 @@ describe("gateway shared actions", () => {
|
|
|
523
697
|
<h1>Hello World</h1>
|
|
524
698
|
<p>This is a & test with <tags> and entities.</p>
|
|
525
699
|
</body></html>`;
|
|
526
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
700
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
701
|
+
mockResponse({
|
|
702
|
+
ok: true,
|
|
703
|
+
contentType: "text/html; charset=utf-8",
|
|
704
|
+
body: htmlBody,
|
|
705
|
+
}),
|
|
706
|
+
);
|
|
531
707
|
vi.stubGlobal("fetch", mockFetch);
|
|
532
708
|
|
|
533
|
-
const result = await handleSharedAction(
|
|
709
|
+
const result = await handleSharedAction(
|
|
710
|
+
{ action: "fetch_url", url: "https://example.com" },
|
|
711
|
+
123,
|
|
712
|
+
);
|
|
534
713
|
|
|
535
714
|
expect(result?.ok).toBe(true);
|
|
536
715
|
// Script and style content should be stripped
|
|
@@ -543,14 +722,19 @@ describe("gateway shared actions", () => {
|
|
|
543
722
|
|
|
544
723
|
it("returns JSON content as text", async () => {
|
|
545
724
|
const jsonBody = '{"key": "value", "count": 42}';
|
|
546
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
725
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
726
|
+
mockResponse({
|
|
727
|
+
ok: true,
|
|
728
|
+
contentType: "application/json",
|
|
729
|
+
body: jsonBody,
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
551
732
|
vi.stubGlobal("fetch", mockFetch);
|
|
552
733
|
|
|
553
|
-
const result = await handleSharedAction(
|
|
734
|
+
const result = await handleSharedAction(
|
|
735
|
+
{ action: "fetch_url", url: "https://api.example.com/data" },
|
|
736
|
+
123,
|
|
737
|
+
);
|
|
554
738
|
|
|
555
739
|
expect(result?.ok).toBe(true);
|
|
556
740
|
expect(result?.text).toContain('"key": "value"');
|
|
@@ -558,72 +742,104 @@ describe("gateway shared actions", () => {
|
|
|
558
742
|
|
|
559
743
|
it("truncates large text content to 8000 chars", async () => {
|
|
560
744
|
const longText = "A".repeat(10000);
|
|
561
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
745
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
746
|
+
mockResponse({
|
|
747
|
+
ok: true,
|
|
748
|
+
contentType: "text/plain",
|
|
749
|
+
body: longText,
|
|
750
|
+
}),
|
|
751
|
+
);
|
|
566
752
|
vi.stubGlobal("fetch", mockFetch);
|
|
567
753
|
|
|
568
|
-
const result = await handleSharedAction(
|
|
754
|
+
const result = await handleSharedAction(
|
|
755
|
+
{ action: "fetch_url", url: "https://example.com/big" },
|
|
756
|
+
123,
|
|
757
|
+
);
|
|
569
758
|
|
|
570
759
|
expect(result?.ok).toBe(true);
|
|
571
760
|
expect(result?.text!.length).toBe(8000);
|
|
572
761
|
});
|
|
573
762
|
|
|
574
763
|
it("returns message for pages with no readable content", async () => {
|
|
575
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
764
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
765
|
+
mockResponse({
|
|
766
|
+
ok: true,
|
|
767
|
+
contentType: "text/html",
|
|
768
|
+
body: "<html><body> </body></html>",
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
580
771
|
vi.stubGlobal("fetch", mockFetch);
|
|
581
772
|
|
|
582
|
-
const result = await handleSharedAction(
|
|
773
|
+
const result = await handleSharedAction(
|
|
774
|
+
{ action: "fetch_url", url: "https://empty.com" },
|
|
775
|
+
123,
|
|
776
|
+
);
|
|
583
777
|
|
|
584
778
|
expect(result?.ok).toBe(true);
|
|
585
779
|
expect(result?.text).toBe("(Page has no readable content)");
|
|
586
780
|
});
|
|
587
781
|
|
|
588
782
|
it("returns HTTP error for non-ok response", async () => {
|
|
589
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
783
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
784
|
+
mockResponse({
|
|
785
|
+
ok: false,
|
|
786
|
+
status: 404,
|
|
787
|
+
}),
|
|
788
|
+
);
|
|
593
789
|
vi.stubGlobal("fetch", mockFetch);
|
|
594
790
|
|
|
595
|
-
const result = await handleSharedAction(
|
|
791
|
+
const result = await handleSharedAction(
|
|
792
|
+
{ action: "fetch_url", url: "https://example.com/missing" },
|
|
793
|
+
123,
|
|
794
|
+
);
|
|
596
795
|
|
|
597
796
|
expect(result).toEqual({ ok: false, error: "HTTP 404" });
|
|
598
797
|
});
|
|
599
798
|
|
|
600
799
|
it("returns HTTP 500 error", async () => {
|
|
601
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
800
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
801
|
+
mockResponse({
|
|
802
|
+
ok: false,
|
|
803
|
+
status: 500,
|
|
804
|
+
}),
|
|
805
|
+
);
|
|
605
806
|
vi.stubGlobal("fetch", mockFetch);
|
|
606
807
|
|
|
607
|
-
const result = await handleSharedAction(
|
|
808
|
+
const result = await handleSharedAction(
|
|
809
|
+
{ action: "fetch_url", url: "https://example.com/error" },
|
|
810
|
+
123,
|
|
811
|
+
);
|
|
608
812
|
|
|
609
813
|
expect(result).toEqual({ ok: false, error: "HTTP 500" });
|
|
610
814
|
});
|
|
611
815
|
|
|
612
816
|
it("handles network errors", async () => {
|
|
613
|
-
const mockFetch = vi
|
|
817
|
+
const mockFetch = vi
|
|
818
|
+
.fn()
|
|
819
|
+
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
614
820
|
vi.stubGlobal("fetch", mockFetch);
|
|
615
821
|
|
|
616
|
-
const result = await handleSharedAction(
|
|
822
|
+
const result = await handleSharedAction(
|
|
823
|
+
{ action: "fetch_url", url: "https://down.example.com" },
|
|
824
|
+
123,
|
|
825
|
+
);
|
|
617
826
|
|
|
618
827
|
expect(result?.ok).toBe(false);
|
|
619
828
|
expect(result?.error).toContain("Fetch failed: ECONNREFUSED");
|
|
620
829
|
});
|
|
621
830
|
|
|
622
831
|
it("handles timeout errors", async () => {
|
|
623
|
-
const mockFetch = vi
|
|
832
|
+
const mockFetch = vi
|
|
833
|
+
.fn()
|
|
834
|
+
.mockRejectedValueOnce(
|
|
835
|
+
new Error("The operation was aborted due to timeout"),
|
|
836
|
+
);
|
|
624
837
|
vi.stubGlobal("fetch", mockFetch);
|
|
625
838
|
|
|
626
|
-
const result = await handleSharedAction(
|
|
839
|
+
const result = await handleSharedAction(
|
|
840
|
+
{ action: "fetch_url", url: "https://slow.example.com" },
|
|
841
|
+
123,
|
|
842
|
+
);
|
|
627
843
|
|
|
628
844
|
expect(result?.ok).toBe(false);
|
|
629
845
|
expect(result?.error).toContain("Fetch failed");
|
|
@@ -634,7 +850,10 @@ describe("gateway shared actions", () => {
|
|
|
634
850
|
const mockFetch = vi.fn().mockRejectedValueOnce("string error");
|
|
635
851
|
vi.stubGlobal("fetch", mockFetch);
|
|
636
852
|
|
|
637
|
-
const result = await handleSharedAction(
|
|
853
|
+
const result = await handleSharedAction(
|
|
854
|
+
{ action: "fetch_url", url: "https://weird.example.com" },
|
|
855
|
+
123,
|
|
856
|
+
);
|
|
638
857
|
|
|
639
858
|
expect(result?.ok).toBe(false);
|
|
640
859
|
expect(result?.error).toBe("Fetch failed: string error");
|
|
@@ -644,15 +863,20 @@ describe("gateway shared actions", () => {
|
|
|
644
863
|
|
|
645
864
|
it("downloads binary PNG file", async () => {
|
|
646
865
|
const buffer = imageBuffer("png");
|
|
647
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
866
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
867
|
+
mockResponse({
|
|
868
|
+
ok: true,
|
|
869
|
+
contentType: "image/png",
|
|
870
|
+
arrayBuffer: buffer,
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
652
873
|
vi.stubGlobal("fetch", mockFetch);
|
|
653
874
|
mockExistsSync.mockReturnValue(true);
|
|
654
875
|
|
|
655
|
-
const result = await handleSharedAction(
|
|
876
|
+
const result = await handleSharedAction(
|
|
877
|
+
{ action: "fetch_url", url: "https://example.com/img.png" },
|
|
878
|
+
123,
|
|
879
|
+
);
|
|
656
880
|
|
|
657
881
|
expect(result?.ok).toBe(true);
|
|
658
882
|
expect(result?.text).toContain("Downloaded image");
|
|
@@ -663,15 +887,20 @@ describe("gateway shared actions", () => {
|
|
|
663
887
|
|
|
664
888
|
it("downloads JPEG file", async () => {
|
|
665
889
|
const buffer = imageBuffer("jpg", 2048);
|
|
666
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
890
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
891
|
+
mockResponse({
|
|
892
|
+
ok: true,
|
|
893
|
+
contentType: "image/jpeg",
|
|
894
|
+
arrayBuffer: buffer,
|
|
895
|
+
}),
|
|
896
|
+
);
|
|
671
897
|
vi.stubGlobal("fetch", mockFetch);
|
|
672
898
|
mockExistsSync.mockReturnValue(true);
|
|
673
899
|
|
|
674
|
-
const result = await handleSharedAction(
|
|
900
|
+
const result = await handleSharedAction(
|
|
901
|
+
{ action: "fetch_url", url: "https://example.com/photo.jpg" },
|
|
902
|
+
123,
|
|
903
|
+
);
|
|
675
904
|
|
|
676
905
|
expect(result?.ok).toBe(true);
|
|
677
906
|
expect(result?.text).toContain(".jpg");
|
|
@@ -680,14 +909,19 @@ describe("gateway shared actions", () => {
|
|
|
680
909
|
|
|
681
910
|
it("downloads GIF file", async () => {
|
|
682
911
|
const buffer = imageBuffer("gif", 512);
|
|
683
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
912
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
913
|
+
mockResponse({
|
|
914
|
+
ok: true,
|
|
915
|
+
contentType: "image/gif",
|
|
916
|
+
arrayBuffer: buffer,
|
|
917
|
+
}),
|
|
918
|
+
);
|
|
688
919
|
vi.stubGlobal("fetch", mockFetch);
|
|
689
920
|
|
|
690
|
-
const result = await handleSharedAction(
|
|
921
|
+
const result = await handleSharedAction(
|
|
922
|
+
{ action: "fetch_url", url: "https://example.com/anim.gif" },
|
|
923
|
+
123,
|
|
924
|
+
);
|
|
691
925
|
|
|
692
926
|
expect(result?.ok).toBe(true);
|
|
693
927
|
expect(result?.text).toContain(".gif");
|
|
@@ -695,14 +929,19 @@ describe("gateway shared actions", () => {
|
|
|
695
929
|
|
|
696
930
|
it("downloads WebP file", async () => {
|
|
697
931
|
const buffer = imageBuffer("webp", 512);
|
|
698
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
932
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
933
|
+
mockResponse({
|
|
934
|
+
ok: true,
|
|
935
|
+
contentType: "image/webp",
|
|
936
|
+
arrayBuffer: buffer,
|
|
937
|
+
}),
|
|
938
|
+
);
|
|
703
939
|
vi.stubGlobal("fetch", mockFetch);
|
|
704
940
|
|
|
705
|
-
const result = await handleSharedAction(
|
|
941
|
+
const result = await handleSharedAction(
|
|
942
|
+
{ action: "fetch_url", url: "https://example.com/pic.webp" },
|
|
943
|
+
123,
|
|
944
|
+
);
|
|
706
945
|
|
|
707
946
|
expect(result?.ok).toBe(true);
|
|
708
947
|
expect(result?.text).toContain(".webp");
|
|
@@ -710,14 +949,19 @@ describe("gateway shared actions", () => {
|
|
|
710
949
|
|
|
711
950
|
it("downloads PDF file", async () => {
|
|
712
951
|
const buffer = new ArrayBuffer(4096);
|
|
713
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
952
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
953
|
+
mockResponse({
|
|
954
|
+
ok: true,
|
|
955
|
+
contentType: "application/pdf",
|
|
956
|
+
arrayBuffer: buffer,
|
|
957
|
+
}),
|
|
958
|
+
);
|
|
718
959
|
vi.stubGlobal("fetch", mockFetch);
|
|
719
960
|
|
|
720
|
-
const result = await handleSharedAction(
|
|
961
|
+
const result = await handleSharedAction(
|
|
962
|
+
{ action: "fetch_url", url: "https://example.com/doc.pdf" },
|
|
963
|
+
123,
|
|
964
|
+
);
|
|
721
965
|
|
|
722
966
|
expect(result?.ok).toBe(true);
|
|
723
967
|
expect(result?.text).toContain(".pdf");
|
|
@@ -726,14 +970,19 @@ describe("gateway shared actions", () => {
|
|
|
726
970
|
|
|
727
971
|
it("downloads ZIP file", async () => {
|
|
728
972
|
const buffer = new ArrayBuffer(8192);
|
|
729
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
973
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
974
|
+
mockResponse({
|
|
975
|
+
ok: true,
|
|
976
|
+
contentType: "application/zip",
|
|
977
|
+
arrayBuffer: buffer,
|
|
978
|
+
}),
|
|
979
|
+
);
|
|
734
980
|
vi.stubGlobal("fetch", mockFetch);
|
|
735
981
|
|
|
736
|
-
const result = await handleSharedAction(
|
|
982
|
+
const result = await handleSharedAction(
|
|
983
|
+
{ action: "fetch_url", url: "https://example.com/archive.zip" },
|
|
984
|
+
123,
|
|
985
|
+
);
|
|
737
986
|
|
|
738
987
|
expect(result?.ok).toBe(true);
|
|
739
988
|
expect(result?.text).toContain(".zip");
|
|
@@ -741,14 +990,19 @@ describe("gateway shared actions", () => {
|
|
|
741
990
|
|
|
742
991
|
it("uses .bin extension for unknown binary types", async () => {
|
|
743
992
|
const buffer = new ArrayBuffer(256);
|
|
744
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
993
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
994
|
+
mockResponse({
|
|
995
|
+
ok: true,
|
|
996
|
+
contentType: "application/octet-stream",
|
|
997
|
+
arrayBuffer: buffer,
|
|
998
|
+
}),
|
|
999
|
+
);
|
|
749
1000
|
vi.stubGlobal("fetch", mockFetch);
|
|
750
1001
|
|
|
751
|
-
const result = await handleSharedAction(
|
|
1002
|
+
const result = await handleSharedAction(
|
|
1003
|
+
{ action: "fetch_url", url: "https://example.com/data" },
|
|
1004
|
+
123,
|
|
1005
|
+
);
|
|
752
1006
|
|
|
753
1007
|
expect(result?.ok).toBe(true);
|
|
754
1008
|
expect(result?.text).toContain(".bin");
|
|
@@ -756,44 +1010,62 @@ describe("gateway shared actions", () => {
|
|
|
756
1010
|
|
|
757
1011
|
it("creates uploads directory when it does not exist", async () => {
|
|
758
1012
|
const buffer = imageBuffer("png", 256);
|
|
759
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1013
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1014
|
+
mockResponse({
|
|
1015
|
+
ok: true,
|
|
1016
|
+
contentType: "image/png",
|
|
1017
|
+
arrayBuffer: buffer,
|
|
1018
|
+
}),
|
|
1019
|
+
);
|
|
764
1020
|
vi.stubGlobal("fetch", mockFetch);
|
|
765
1021
|
mockExistsSync.mockReturnValue(false);
|
|
766
1022
|
|
|
767
|
-
await handleSharedAction(
|
|
1023
|
+
await handleSharedAction(
|
|
1024
|
+
{ action: "fetch_url", url: "https://example.com/img.png" },
|
|
1025
|
+
123,
|
|
1026
|
+
);
|
|
768
1027
|
|
|
769
|
-
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
1028
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
1029
|
+
expect.stringContaining("uploads"),
|
|
1030
|
+
{ recursive: true },
|
|
1031
|
+
);
|
|
770
1032
|
});
|
|
771
1033
|
|
|
772
1034
|
it("does not create uploads directory when it exists", async () => {
|
|
773
1035
|
const buffer = new ArrayBuffer(256);
|
|
774
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1036
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1037
|
+
mockResponse({
|
|
1038
|
+
ok: true,
|
|
1039
|
+
contentType: "image/png",
|
|
1040
|
+
arrayBuffer: buffer,
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
779
1043
|
vi.stubGlobal("fetch", mockFetch);
|
|
780
1044
|
mockExistsSync.mockReturnValue(true);
|
|
781
1045
|
|
|
782
|
-
await handleSharedAction(
|
|
1046
|
+
await handleSharedAction(
|
|
1047
|
+
{ action: "fetch_url", url: "https://example.com/img.png" },
|
|
1048
|
+
123,
|
|
1049
|
+
);
|
|
783
1050
|
|
|
784
1051
|
expect(mockMkdirSync).not.toHaveBeenCalled();
|
|
785
1052
|
});
|
|
786
1053
|
|
|
787
1054
|
it("rejects files larger than 20MB", async () => {
|
|
788
1055
|
const buffer = new ArrayBuffer(21 * 1024 * 1024); // 21MB
|
|
789
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1056
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1057
|
+
mockResponse({
|
|
1058
|
+
ok: true,
|
|
1059
|
+
contentType: "image/png",
|
|
1060
|
+
arrayBuffer: buffer,
|
|
1061
|
+
}),
|
|
1062
|
+
);
|
|
794
1063
|
vi.stubGlobal("fetch", mockFetch);
|
|
795
1064
|
|
|
796
|
-
const result = await handleSharedAction(
|
|
1065
|
+
const result = await handleSharedAction(
|
|
1066
|
+
{ action: "fetch_url", url: "https://example.com/huge.png" },
|
|
1067
|
+
123,
|
|
1068
|
+
);
|
|
797
1069
|
|
|
798
1070
|
expect(result?.ok).toBe(false);
|
|
799
1071
|
expect(result?.error).toContain("File too large");
|
|
@@ -803,29 +1075,41 @@ describe("gateway shared actions", () => {
|
|
|
803
1075
|
|
|
804
1076
|
it("includes send instructions in download response", async () => {
|
|
805
1077
|
const buffer = imageBuffer("png");
|
|
806
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1078
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1079
|
+
mockResponse({
|
|
1080
|
+
ok: true,
|
|
1081
|
+
contentType: "image/png",
|
|
1082
|
+
arrayBuffer: buffer,
|
|
1083
|
+
}),
|
|
1084
|
+
);
|
|
811
1085
|
vi.stubGlobal("fetch", mockFetch);
|
|
812
1086
|
|
|
813
|
-
const result = await handleSharedAction(
|
|
1087
|
+
const result = await handleSharedAction(
|
|
1088
|
+
{ action: "fetch_url", url: "https://example.com/img.png" },
|
|
1089
|
+
123,
|
|
1090
|
+
);
|
|
814
1091
|
|
|
815
1092
|
expect(result?.text).toContain("Read it with the Read tool");
|
|
816
1093
|
expect(result?.text).toContain('send(type="file"');
|
|
817
1094
|
});
|
|
818
1095
|
|
|
819
1096
|
it("rejects HTML error page disguised as image (magic byte validation)", async () => {
|
|
820
|
-
const htmlError = new TextEncoder().encode(
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1097
|
+
const htmlError = new TextEncoder().encode(
|
|
1098
|
+
"<!DOCTYPE html><html><body>Wikimedia Error</body></html>",
|
|
1099
|
+
);
|
|
1100
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1101
|
+
mockResponse({
|
|
1102
|
+
ok: true,
|
|
1103
|
+
contentType: "image/jpeg",
|
|
1104
|
+
arrayBuffer: htmlError.buffer,
|
|
1105
|
+
}),
|
|
1106
|
+
);
|
|
826
1107
|
vi.stubGlobal("fetch", mockFetch);
|
|
827
1108
|
|
|
828
|
-
const result = await handleSharedAction(
|
|
1109
|
+
const result = await handleSharedAction(
|
|
1110
|
+
{ action: "fetch_url", url: "https://upload.wikimedia.org/img.jpg" },
|
|
1111
|
+
123,
|
|
1112
|
+
);
|
|
829
1113
|
|
|
830
1114
|
expect(result?.ok).toBe(false);
|
|
831
1115
|
expect(result?.error).toContain("error page instead of an image");
|
|
@@ -833,45 +1117,63 @@ describe("gateway shared actions", () => {
|
|
|
833
1117
|
});
|
|
834
1118
|
|
|
835
1119
|
it("rejects empty response", async () => {
|
|
836
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1120
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1121
|
+
mockResponse({
|
|
1122
|
+
ok: true,
|
|
1123
|
+
contentType: "image/png",
|
|
1124
|
+
arrayBuffer: new ArrayBuffer(0),
|
|
1125
|
+
}),
|
|
1126
|
+
);
|
|
841
1127
|
vi.stubGlobal("fetch", mockFetch);
|
|
842
1128
|
|
|
843
|
-
const result = await handleSharedAction(
|
|
1129
|
+
const result = await handleSharedAction(
|
|
1130
|
+
{ action: "fetch_url", url: "https://example.com/empty.png" },
|
|
1131
|
+
123,
|
|
1132
|
+
);
|
|
844
1133
|
|
|
845
1134
|
expect(result?.ok).toBe(false);
|
|
846
1135
|
expect(result?.error).toContain("Empty response");
|
|
847
1136
|
});
|
|
848
1137
|
|
|
849
1138
|
it("passes User-Agent header in fetch", async () => {
|
|
850
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1139
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1140
|
+
mockResponse({
|
|
1141
|
+
ok: true,
|
|
1142
|
+
contentType: "text/html",
|
|
1143
|
+
body: "<p>Content here is enough to pass the 20 char minimum check test</p>",
|
|
1144
|
+
}),
|
|
1145
|
+
);
|
|
855
1146
|
vi.stubGlobal("fetch", mockFetch);
|
|
856
1147
|
|
|
857
|
-
await handleSharedAction(
|
|
1148
|
+
await handleSharedAction(
|
|
1149
|
+
{ action: "fetch_url", url: "https://example.com" },
|
|
1150
|
+
123,
|
|
1151
|
+
);
|
|
858
1152
|
|
|
859
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1153
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1154
|
+
"https://example.com",
|
|
1155
|
+
expect.objectContaining({
|
|
1156
|
+
headers: { "User-Agent": "Talon/1.0" },
|
|
1157
|
+
redirect: "follow",
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
863
1160
|
});
|
|
864
1161
|
|
|
865
1162
|
it("labels non-image binary as content subtype", async () => {
|
|
866
1163
|
const buffer = new ArrayBuffer(512);
|
|
867
|
-
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1164
|
+
const mockFetch = vi.fn().mockResolvedValueOnce(
|
|
1165
|
+
mockResponse({
|
|
1166
|
+
ok: true,
|
|
1167
|
+
contentType: "application/pdf",
|
|
1168
|
+
arrayBuffer: buffer,
|
|
1169
|
+
}),
|
|
1170
|
+
);
|
|
872
1171
|
vi.stubGlobal("fetch", mockFetch);
|
|
873
1172
|
|
|
874
|
-
const result = await handleSharedAction(
|
|
1173
|
+
const result = await handleSharedAction(
|
|
1174
|
+
{ action: "fetch_url", url: "https://example.com/doc.pdf" },
|
|
1175
|
+
123,
|
|
1176
|
+
);
|
|
875
1177
|
|
|
876
1178
|
// For non-image types, it uses ct.split("/")[1]?.split(";")[0] => "pdf"
|
|
877
1179
|
expect(result?.text).toContain("Downloaded pdf");
|
|
@@ -887,95 +1189,128 @@ describe("gateway shared actions", () => {
|
|
|
887
1189
|
|
|
888
1190
|
describe("create_cron_job", () => {
|
|
889
1191
|
it("creates a cron job with all fields", async () => {
|
|
890
|
-
const result = await handleSharedAction(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1192
|
+
const result = await handleSharedAction(
|
|
1193
|
+
{
|
|
1194
|
+
action: "create_cron_job",
|
|
1195
|
+
name: "Morning Greeting",
|
|
1196
|
+
schedule: "0 9 * * *",
|
|
1197
|
+
type: "message",
|
|
1198
|
+
content: "Good morning!",
|
|
1199
|
+
timezone: "America/New_York",
|
|
1200
|
+
},
|
|
1201
|
+
42,
|
|
1202
|
+
);
|
|
898
1203
|
|
|
899
1204
|
expect(result?.ok).toBe(true);
|
|
900
1205
|
expect(result?.text).toContain('Created cron job "Morning Greeting"');
|
|
901
1206
|
expect(result?.text).toContain("test-id-123");
|
|
902
1207
|
expect(result?.text).toContain("0 9 * * *");
|
|
903
1208
|
expect(result?.text).toContain("Type: message");
|
|
904
|
-
expect(mockAddCronJob).toHaveBeenCalledWith(
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1209
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(
|
|
1210
|
+
expect.objectContaining({
|
|
1211
|
+
id: "test-id-123",
|
|
1212
|
+
chatId: "42",
|
|
1213
|
+
schedule: "0 9 * * *",
|
|
1214
|
+
type: "message",
|
|
1215
|
+
content: "Good morning!",
|
|
1216
|
+
name: "Morning Greeting",
|
|
1217
|
+
enabled: true,
|
|
1218
|
+
timezone: "America/New_York",
|
|
1219
|
+
}),
|
|
1220
|
+
);
|
|
914
1221
|
});
|
|
915
1222
|
|
|
916
1223
|
it("uses default name when not provided", async () => {
|
|
917
|
-
await handleSharedAction(
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1224
|
+
await handleSharedAction(
|
|
1225
|
+
{
|
|
1226
|
+
action: "create_cron_job",
|
|
1227
|
+
schedule: "0 9 * * *",
|
|
1228
|
+
content: "hi",
|
|
1229
|
+
},
|
|
1230
|
+
42,
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(
|
|
1234
|
+
expect.objectContaining({
|
|
1235
|
+
name: "Unnamed job",
|
|
1236
|
+
}),
|
|
1237
|
+
);
|
|
926
1238
|
});
|
|
927
1239
|
|
|
928
1240
|
it("uses default type 'message' when not specified", async () => {
|
|
929
|
-
await handleSharedAction(
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1241
|
+
await handleSharedAction(
|
|
1242
|
+
{
|
|
1243
|
+
action: "create_cron_job",
|
|
1244
|
+
schedule: "*/5 * * * *",
|
|
1245
|
+
content: "ping",
|
|
1246
|
+
},
|
|
1247
|
+
42,
|
|
1248
|
+
);
|
|
934
1249
|
|
|
935
|
-
expect(mockAddCronJob).toHaveBeenCalledWith(
|
|
936
|
-
|
|
937
|
-
|
|
1250
|
+
expect(mockAddCronJob).toHaveBeenCalledWith(
|
|
1251
|
+
expect.objectContaining({
|
|
1252
|
+
type: "message",
|
|
1253
|
+
}),
|
|
1254
|
+
);
|
|
938
1255
|
});
|
|
939
1256
|
|
|
940
1257
|
it("rejects missing schedule", async () => {
|
|
941
|
-
const result = await handleSharedAction(
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1258
|
+
const result = await handleSharedAction(
|
|
1259
|
+
{
|
|
1260
|
+
action: "create_cron_job",
|
|
1261
|
+
name: "test",
|
|
1262
|
+
content: "hi",
|
|
1263
|
+
},
|
|
1264
|
+
123,
|
|
1265
|
+
);
|
|
1266
|
+
expect(result).toEqual({
|
|
1267
|
+
ok: false,
|
|
1268
|
+
error: "Missing schedule expression",
|
|
1269
|
+
});
|
|
947
1270
|
});
|
|
948
1271
|
|
|
949
1272
|
it("rejects missing content", async () => {
|
|
950
|
-
const result = await handleSharedAction(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1273
|
+
const result = await handleSharedAction(
|
|
1274
|
+
{
|
|
1275
|
+
action: "create_cron_job",
|
|
1276
|
+
name: "test",
|
|
1277
|
+
schedule: "0 9 * * *",
|
|
1278
|
+
},
|
|
1279
|
+
123,
|
|
1280
|
+
);
|
|
955
1281
|
expect(result).toEqual({ ok: false, error: "Missing content" });
|
|
956
1282
|
});
|
|
957
1283
|
|
|
958
1284
|
it("rejects content over 10,000 chars", async () => {
|
|
959
|
-
const result = await handleSharedAction(
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1285
|
+
const result = await handleSharedAction(
|
|
1286
|
+
{
|
|
1287
|
+
action: "create_cron_job",
|
|
1288
|
+
name: "test",
|
|
1289
|
+
schedule: "0 9 * * *",
|
|
1290
|
+
content: "x".repeat(10001),
|
|
1291
|
+
},
|
|
1292
|
+
123,
|
|
1293
|
+
);
|
|
965
1294
|
expect(result?.ok).toBe(false);
|
|
966
1295
|
expect(result?.error).toContain("too long");
|
|
967
1296
|
expect(result?.error).toContain("10,000");
|
|
968
1297
|
});
|
|
969
1298
|
|
|
970
1299
|
it("rejects invalid cron expression", async () => {
|
|
971
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1300
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1301
|
+
valid: false,
|
|
1302
|
+
error: "bad syntax",
|
|
1303
|
+
});
|
|
972
1304
|
|
|
973
|
-
const result = await handleSharedAction(
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1305
|
+
const result = await handleSharedAction(
|
|
1306
|
+
{
|
|
1307
|
+
action: "create_cron_job",
|
|
1308
|
+
name: "test",
|
|
1309
|
+
schedule: "not valid",
|
|
1310
|
+
content: "hi",
|
|
1311
|
+
},
|
|
1312
|
+
123,
|
|
1313
|
+
);
|
|
979
1314
|
|
|
980
1315
|
expect(result?.ok).toBe(false);
|
|
981
1316
|
expect(result?.error).toContain("Invalid cron expression");
|
|
@@ -983,13 +1318,19 @@ describe("gateway shared actions", () => {
|
|
|
983
1318
|
});
|
|
984
1319
|
|
|
985
1320
|
it("includes next run time in response", async () => {
|
|
986
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1321
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1322
|
+
valid: true,
|
|
1323
|
+
next: "2026-04-01T09:00:00.000Z",
|
|
1324
|
+
});
|
|
987
1325
|
|
|
988
|
-
const result = await handleSharedAction(
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1326
|
+
const result = await handleSharedAction(
|
|
1327
|
+
{
|
|
1328
|
+
action: "create_cron_job",
|
|
1329
|
+
schedule: "0 9 * * *",
|
|
1330
|
+
content: "morning",
|
|
1331
|
+
},
|
|
1332
|
+
123,
|
|
1333
|
+
);
|
|
993
1334
|
|
|
994
1335
|
expect(result?.text).toContain("2026-04-01T09:00:00.000Z");
|
|
995
1336
|
});
|
|
@@ -997,34 +1338,49 @@ describe("gateway shared actions", () => {
|
|
|
997
1338
|
it("shows 'unknown' when next run time is not available", async () => {
|
|
998
1339
|
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
999
1340
|
|
|
1000
|
-
const result = await handleSharedAction(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1341
|
+
const result = await handleSharedAction(
|
|
1342
|
+
{
|
|
1343
|
+
action: "create_cron_job",
|
|
1344
|
+
schedule: "0 9 * * *",
|
|
1345
|
+
content: "morning",
|
|
1346
|
+
},
|
|
1347
|
+
123,
|
|
1348
|
+
);
|
|
1005
1349
|
|
|
1006
1350
|
expect(result?.text).toContain("Next run: unknown");
|
|
1007
1351
|
});
|
|
1008
1352
|
|
|
1009
1353
|
it("passes timezone to validation", async () => {
|
|
1010
|
-
await handleSharedAction(
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1354
|
+
await handleSharedAction(
|
|
1355
|
+
{
|
|
1356
|
+
action: "create_cron_job",
|
|
1357
|
+
schedule: "0 9 * * *",
|
|
1358
|
+
content: "hi",
|
|
1359
|
+
timezone: "Europe/London",
|
|
1360
|
+
},
|
|
1361
|
+
123,
|
|
1362
|
+
);
|
|
1016
1363
|
|
|
1017
|
-
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1364
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1365
|
+
"0 9 * * *",
|
|
1366
|
+
"Europe/London",
|
|
1367
|
+
);
|
|
1018
1368
|
});
|
|
1019
1369
|
|
|
1020
1370
|
it("passes undefined timezone when not specified", async () => {
|
|
1021
|
-
await handleSharedAction(
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1371
|
+
await handleSharedAction(
|
|
1372
|
+
{
|
|
1373
|
+
action: "create_cron_job",
|
|
1374
|
+
schedule: "0 9 * * *",
|
|
1375
|
+
content: "hi",
|
|
1376
|
+
},
|
|
1377
|
+
123,
|
|
1378
|
+
);
|
|
1026
1379
|
|
|
1027
|
-
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1380
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1381
|
+
"0 9 * * *",
|
|
1382
|
+
undefined,
|
|
1383
|
+
);
|
|
1028
1384
|
});
|
|
1029
1385
|
});
|
|
1030
1386
|
|
|
@@ -1034,9 +1390,15 @@ describe("gateway shared actions", () => {
|
|
|
1034
1390
|
it("returns 'no cron jobs' when empty", async () => {
|
|
1035
1391
|
mockGetCronJobsForChat.mockReturnValue([]);
|
|
1036
1392
|
|
|
1037
|
-
const result = await handleSharedAction(
|
|
1393
|
+
const result = await handleSharedAction(
|
|
1394
|
+
{ action: "list_cron_jobs" },
|
|
1395
|
+
42,
|
|
1396
|
+
);
|
|
1038
1397
|
|
|
1039
|
-
expect(result).toEqual({
|
|
1398
|
+
expect(result).toEqual({
|
|
1399
|
+
ok: true,
|
|
1400
|
+
text: "No cron jobs in this chat.",
|
|
1401
|
+
});
|
|
1040
1402
|
});
|
|
1041
1403
|
|
|
1042
1404
|
it("lists existing jobs with all details", async () => {
|
|
@@ -1055,9 +1417,15 @@ describe("gateway shared actions", () => {
|
|
|
1055
1417
|
timezone: "America/New_York",
|
|
1056
1418
|
},
|
|
1057
1419
|
]);
|
|
1058
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1420
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1421
|
+
valid: true,
|
|
1422
|
+
next: new Date("2026-04-02T09:00:00Z").toISOString(),
|
|
1423
|
+
});
|
|
1059
1424
|
|
|
1060
|
-
const result = await handleSharedAction(
|
|
1425
|
+
const result = await handleSharedAction(
|
|
1426
|
+
{ action: "list_cron_jobs" },
|
|
1427
|
+
42,
|
|
1428
|
+
);
|
|
1061
1429
|
|
|
1062
1430
|
expect(result?.ok).toBe(true);
|
|
1063
1431
|
expect(result?.text).toContain("Cron jobs (1)");
|
|
@@ -1084,9 +1452,15 @@ describe("gateway shared actions", () => {
|
|
|
1084
1452
|
runCount: 0,
|
|
1085
1453
|
},
|
|
1086
1454
|
]);
|
|
1087
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1455
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1456
|
+
valid: true,
|
|
1457
|
+
next: new Date(Date.now() + 60000).toISOString(),
|
|
1458
|
+
});
|
|
1088
1459
|
|
|
1089
|
-
const result = await handleSharedAction(
|
|
1460
|
+
const result = await handleSharedAction(
|
|
1461
|
+
{ action: "list_cron_jobs" },
|
|
1462
|
+
42,
|
|
1463
|
+
);
|
|
1090
1464
|
|
|
1091
1465
|
expect(result?.text).toContain("Last: never");
|
|
1092
1466
|
});
|
|
@@ -1108,7 +1482,10 @@ describe("gateway shared actions", () => {
|
|
|
1108
1482
|
]);
|
|
1109
1483
|
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1110
1484
|
|
|
1111
|
-
const result = await handleSharedAction(
|
|
1485
|
+
const result = await handleSharedAction(
|
|
1486
|
+
{ action: "list_cron_jobs" },
|
|
1487
|
+
42,
|
|
1488
|
+
);
|
|
1112
1489
|
|
|
1113
1490
|
expect(result?.text).toContain("Lunch Alert (disabled)");
|
|
1114
1491
|
});
|
|
@@ -1129,7 +1506,10 @@ describe("gateway shared actions", () => {
|
|
|
1129
1506
|
]);
|
|
1130
1507
|
mockValidateCronExpression.mockReturnValueOnce({ valid: false });
|
|
1131
1508
|
|
|
1132
|
-
const result = await handleSharedAction(
|
|
1509
|
+
const result = await handleSharedAction(
|
|
1510
|
+
{ action: "list_cron_jobs" },
|
|
1511
|
+
42,
|
|
1512
|
+
);
|
|
1133
1513
|
|
|
1134
1514
|
expect(result?.text).toContain("Next: unknown");
|
|
1135
1515
|
});
|
|
@@ -1149,9 +1529,15 @@ describe("gateway shared actions", () => {
|
|
|
1149
1529
|
runCount: 0,
|
|
1150
1530
|
},
|
|
1151
1531
|
]);
|
|
1152
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1532
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1533
|
+
valid: true,
|
|
1534
|
+
next: new Date().toISOString(),
|
|
1535
|
+
});
|
|
1153
1536
|
|
|
1154
|
-
const result = await handleSharedAction(
|
|
1537
|
+
const result = await handleSharedAction(
|
|
1538
|
+
{ action: "list_cron_jobs" },
|
|
1539
|
+
42,
|
|
1540
|
+
);
|
|
1155
1541
|
|
|
1156
1542
|
expect(result?.text).toContain("B".repeat(100) + "...");
|
|
1157
1543
|
expect(result?.text).not.toContain("B".repeat(101));
|
|
@@ -1160,18 +1546,41 @@ describe("gateway shared actions", () => {
|
|
|
1160
1546
|
it("lists multiple jobs", async () => {
|
|
1161
1547
|
mockGetCronJobsForChat.mockReturnValue([
|
|
1162
1548
|
{
|
|
1163
|
-
id: "j1",
|
|
1164
|
-
|
|
1549
|
+
id: "j1",
|
|
1550
|
+
chatId: "42",
|
|
1551
|
+
schedule: "0 9 * * *",
|
|
1552
|
+
type: "message",
|
|
1553
|
+
content: "morning",
|
|
1554
|
+
name: "Job A",
|
|
1555
|
+
enabled: true,
|
|
1556
|
+
createdAt: 1700000000000,
|
|
1557
|
+
runCount: 1,
|
|
1165
1558
|
},
|
|
1166
1559
|
{
|
|
1167
|
-
id: "j2",
|
|
1168
|
-
|
|
1560
|
+
id: "j2",
|
|
1561
|
+
chatId: "42",
|
|
1562
|
+
schedule: "0 17 * * *",
|
|
1563
|
+
type: "query",
|
|
1564
|
+
content: "evening",
|
|
1565
|
+
name: "Job B",
|
|
1566
|
+
enabled: false,
|
|
1567
|
+
createdAt: 1700000000000,
|
|
1568
|
+
runCount: 2,
|
|
1169
1569
|
},
|
|
1170
1570
|
]);
|
|
1171
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1172
|
-
|
|
1571
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1572
|
+
valid: true,
|
|
1573
|
+
next: new Date().toISOString(),
|
|
1574
|
+
});
|
|
1575
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1576
|
+
valid: true,
|
|
1577
|
+
next: new Date().toISOString(),
|
|
1578
|
+
});
|
|
1173
1579
|
|
|
1174
|
-
const result = await handleSharedAction(
|
|
1580
|
+
const result = await handleSharedAction(
|
|
1581
|
+
{ action: "list_cron_jobs" },
|
|
1582
|
+
42,
|
|
1583
|
+
);
|
|
1175
1584
|
|
|
1176
1585
|
expect(result?.text).toContain("Cron jobs (2)");
|
|
1177
1586
|
expect(result?.text).toContain("Job A");
|
|
@@ -1181,13 +1590,26 @@ describe("gateway shared actions", () => {
|
|
|
1181
1590
|
it("shows no timezone when not set", async () => {
|
|
1182
1591
|
mockGetCronJobsForChat.mockReturnValue([
|
|
1183
1592
|
{
|
|
1184
|
-
id: "j-tz",
|
|
1185
|
-
|
|
1593
|
+
id: "j-tz",
|
|
1594
|
+
chatId: "42",
|
|
1595
|
+
schedule: "0 9 * * *",
|
|
1596
|
+
type: "message",
|
|
1597
|
+
content: "hi",
|
|
1598
|
+
name: "No TZ",
|
|
1599
|
+
enabled: true,
|
|
1600
|
+
createdAt: 1700000000000,
|
|
1601
|
+
runCount: 0,
|
|
1186
1602
|
},
|
|
1187
1603
|
]);
|
|
1188
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1604
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1605
|
+
valid: true,
|
|
1606
|
+
next: new Date().toISOString(),
|
|
1607
|
+
});
|
|
1189
1608
|
|
|
1190
|
-
const result = await handleSharedAction(
|
|
1609
|
+
const result = await handleSharedAction(
|
|
1610
|
+
{ action: "list_cron_jobs" },
|
|
1611
|
+
42,
|
|
1612
|
+
);
|
|
1191
1613
|
|
|
1192
1614
|
// Should show schedule without timezone in parens
|
|
1193
1615
|
expect(result?.text).toContain("Schedule: 0 9 * * *");
|
|
@@ -1199,14 +1621,20 @@ describe("gateway shared actions", () => {
|
|
|
1199
1621
|
|
|
1200
1622
|
describe("edit_cron_job", () => {
|
|
1201
1623
|
it("rejects missing job_id", async () => {
|
|
1202
|
-
const result = await handleSharedAction(
|
|
1624
|
+
const result = await handleSharedAction(
|
|
1625
|
+
{ action: "edit_cron_job" },
|
|
1626
|
+
123,
|
|
1627
|
+
);
|
|
1203
1628
|
expect(result).toEqual({ ok: false, error: "Missing job_id" });
|
|
1204
1629
|
});
|
|
1205
1630
|
|
|
1206
1631
|
it("rejects non-existent job", async () => {
|
|
1207
1632
|
mockGetCronJob.mockReturnValue(undefined);
|
|
1208
1633
|
|
|
1209
|
-
const result = await handleSharedAction(
|
|
1634
|
+
const result = await handleSharedAction(
|
|
1635
|
+
{ action: "edit_cron_job", job_id: "nonexistent" },
|
|
1636
|
+
123,
|
|
1637
|
+
);
|
|
1210
1638
|
|
|
1211
1639
|
expect(result?.ok).toBe(false);
|
|
1212
1640
|
expect(result?.error).toContain("not found");
|
|
@@ -1214,11 +1642,21 @@ describe("gateway shared actions", () => {
|
|
|
1214
1642
|
|
|
1215
1643
|
it("rejects job from different chat", async () => {
|
|
1216
1644
|
mockGetCronJob.mockReturnValue({
|
|
1217
|
-
id: "job-x",
|
|
1218
|
-
|
|
1645
|
+
id: "job-x",
|
|
1646
|
+
chatId: "999",
|
|
1647
|
+
schedule: "0 9 * * *",
|
|
1648
|
+
type: "message",
|
|
1649
|
+
content: "hi",
|
|
1650
|
+
name: "Other Chat",
|
|
1651
|
+
enabled: true,
|
|
1652
|
+
createdAt: 1700000000000,
|
|
1653
|
+
runCount: 0,
|
|
1219
1654
|
});
|
|
1220
1655
|
|
|
1221
|
-
const result = await handleSharedAction(
|
|
1656
|
+
const result = await handleSharedAction(
|
|
1657
|
+
{ action: "edit_cron_job", job_id: "job-x" },
|
|
1658
|
+
123,
|
|
1659
|
+
);
|
|
1222
1660
|
|
|
1223
1661
|
expect(result?.ok).toBe(false);
|
|
1224
1662
|
expect(result?.error).toBe("Job belongs to a different chat");
|
|
@@ -1226,79 +1664,156 @@ describe("gateway shared actions", () => {
|
|
|
1226
1664
|
|
|
1227
1665
|
it("updates job name", async () => {
|
|
1228
1666
|
mockGetCronJob.mockReturnValue({
|
|
1229
|
-
id: "job-e1",
|
|
1230
|
-
|
|
1667
|
+
id: "job-e1",
|
|
1668
|
+
chatId: "123",
|
|
1669
|
+
schedule: "0 9 * * *",
|
|
1670
|
+
type: "message",
|
|
1671
|
+
content: "hi",
|
|
1672
|
+
name: "Old Name",
|
|
1673
|
+
enabled: true,
|
|
1674
|
+
createdAt: 1700000000000,
|
|
1675
|
+
runCount: 0,
|
|
1231
1676
|
});
|
|
1232
1677
|
mockUpdateCronJob.mockReturnValue({ name: "New Name" });
|
|
1233
1678
|
|
|
1234
|
-
const result = await handleSharedAction(
|
|
1235
|
-
|
|
1236
|
-
|
|
1679
|
+
const result = await handleSharedAction(
|
|
1680
|
+
{
|
|
1681
|
+
action: "edit_cron_job",
|
|
1682
|
+
job_id: "job-e1",
|
|
1683
|
+
name: "New Name",
|
|
1684
|
+
},
|
|
1685
|
+
123,
|
|
1686
|
+
);
|
|
1237
1687
|
|
|
1238
1688
|
expect(result?.ok).toBe(true);
|
|
1239
1689
|
expect(result?.text).toContain("New Name");
|
|
1240
1690
|
expect(result?.text).toContain("Fields changed: name");
|
|
1241
|
-
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e1", {
|
|
1691
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e1", {
|
|
1692
|
+
name: "New Name",
|
|
1693
|
+
});
|
|
1242
1694
|
});
|
|
1243
1695
|
|
|
1244
1696
|
it("updates content", async () => {
|
|
1245
1697
|
mockGetCronJob.mockReturnValue({
|
|
1246
|
-
id: "job-e2",
|
|
1247
|
-
|
|
1698
|
+
id: "job-e2",
|
|
1699
|
+
chatId: "123",
|
|
1700
|
+
schedule: "0 9 * * *",
|
|
1701
|
+
type: "message",
|
|
1702
|
+
content: "old",
|
|
1703
|
+
name: "Job",
|
|
1704
|
+
enabled: true,
|
|
1705
|
+
createdAt: 1700000000000,
|
|
1706
|
+
runCount: 0,
|
|
1248
1707
|
});
|
|
1249
1708
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1250
1709
|
|
|
1251
|
-
const result = await handleSharedAction(
|
|
1252
|
-
|
|
1253
|
-
|
|
1710
|
+
const result = await handleSharedAction(
|
|
1711
|
+
{
|
|
1712
|
+
action: "edit_cron_job",
|
|
1713
|
+
job_id: "job-e2",
|
|
1714
|
+
content: "new content",
|
|
1715
|
+
},
|
|
1716
|
+
123,
|
|
1717
|
+
);
|
|
1254
1718
|
|
|
1255
1719
|
expect(result?.ok).toBe(true);
|
|
1256
|
-
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e2", {
|
|
1720
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e2", {
|
|
1721
|
+
content: "new content",
|
|
1722
|
+
});
|
|
1257
1723
|
});
|
|
1258
1724
|
|
|
1259
1725
|
it("updates enabled flag", async () => {
|
|
1260
1726
|
mockGetCronJob.mockReturnValue({
|
|
1261
|
-
id: "job-e3",
|
|
1262
|
-
|
|
1727
|
+
id: "job-e3",
|
|
1728
|
+
chatId: "123",
|
|
1729
|
+
schedule: "0 9 * * *",
|
|
1730
|
+
type: "message",
|
|
1731
|
+
content: "hi",
|
|
1732
|
+
name: "Job",
|
|
1733
|
+
enabled: true,
|
|
1734
|
+
createdAt: 1700000000000,
|
|
1735
|
+
runCount: 0,
|
|
1263
1736
|
});
|
|
1264
1737
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1265
1738
|
|
|
1266
|
-
const result = await handleSharedAction(
|
|
1267
|
-
|
|
1268
|
-
|
|
1739
|
+
const result = await handleSharedAction(
|
|
1740
|
+
{
|
|
1741
|
+
action: "edit_cron_job",
|
|
1742
|
+
job_id: "job-e3",
|
|
1743
|
+
enabled: false,
|
|
1744
|
+
},
|
|
1745
|
+
123,
|
|
1746
|
+
);
|
|
1269
1747
|
|
|
1270
1748
|
expect(result?.ok).toBe(true);
|
|
1271
|
-
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e3", {
|
|
1749
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e3", {
|
|
1750
|
+
enabled: false,
|
|
1751
|
+
});
|
|
1272
1752
|
});
|
|
1273
1753
|
|
|
1274
1754
|
it("updates schedule with validation", async () => {
|
|
1275
1755
|
mockGetCronJob.mockReturnValue({
|
|
1276
|
-
id: "job-e4",
|
|
1277
|
-
|
|
1756
|
+
id: "job-e4",
|
|
1757
|
+
chatId: "123",
|
|
1758
|
+
schedule: "0 9 * * *",
|
|
1759
|
+
type: "message",
|
|
1760
|
+
content: "hi",
|
|
1761
|
+
name: "Job",
|
|
1762
|
+
enabled: true,
|
|
1763
|
+
createdAt: 1700000000000,
|
|
1764
|
+
runCount: 0,
|
|
1278
1765
|
timezone: "America/New_York",
|
|
1279
1766
|
});
|
|
1280
|
-
mockValidateCronExpression.mockReturnValueOnce({
|
|
1767
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1768
|
+
valid: true,
|
|
1769
|
+
next: "2026-05-01T12:00:00Z",
|
|
1770
|
+
});
|
|
1281
1771
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1282
1772
|
|
|
1283
|
-
const result = await handleSharedAction(
|
|
1284
|
-
|
|
1285
|
-
|
|
1773
|
+
const result = await handleSharedAction(
|
|
1774
|
+
{
|
|
1775
|
+
action: "edit_cron_job",
|
|
1776
|
+
job_id: "job-e4",
|
|
1777
|
+
schedule: "0 12 * * *",
|
|
1778
|
+
},
|
|
1779
|
+
123,
|
|
1780
|
+
);
|
|
1286
1781
|
|
|
1287
1782
|
expect(result?.ok).toBe(true);
|
|
1288
|
-
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1289
|
-
|
|
1783
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1784
|
+
"0 12 * * *",
|
|
1785
|
+
"America/New_York",
|
|
1786
|
+
);
|
|
1787
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e4", {
|
|
1788
|
+
schedule: "0 12 * * *",
|
|
1789
|
+
});
|
|
1290
1790
|
});
|
|
1291
1791
|
|
|
1292
1792
|
it("rejects invalid schedule update", async () => {
|
|
1293
1793
|
mockGetCronJob.mockReturnValue({
|
|
1294
|
-
id: "job-e5",
|
|
1295
|
-
|
|
1794
|
+
id: "job-e5",
|
|
1795
|
+
chatId: "123",
|
|
1796
|
+
schedule: "0 9 * * *",
|
|
1797
|
+
type: "message",
|
|
1798
|
+
content: "hi",
|
|
1799
|
+
name: "Job",
|
|
1800
|
+
enabled: true,
|
|
1801
|
+
createdAt: 1700000000000,
|
|
1802
|
+
runCount: 0,
|
|
1803
|
+
});
|
|
1804
|
+
mockValidateCronExpression.mockReturnValueOnce({
|
|
1805
|
+
valid: false,
|
|
1806
|
+
error: "too many fields",
|
|
1296
1807
|
});
|
|
1297
|
-
mockValidateCronExpression.mockReturnValueOnce({ valid: false, error: "too many fields" });
|
|
1298
1808
|
|
|
1299
|
-
const result = await handleSharedAction(
|
|
1300
|
-
|
|
1301
|
-
|
|
1809
|
+
const result = await handleSharedAction(
|
|
1810
|
+
{
|
|
1811
|
+
action: "edit_cron_job",
|
|
1812
|
+
job_id: "job-e5",
|
|
1813
|
+
schedule: "bad schedule",
|
|
1814
|
+
},
|
|
1815
|
+
123,
|
|
1816
|
+
);
|
|
1302
1817
|
|
|
1303
1818
|
expect(result?.ok).toBe(false);
|
|
1304
1819
|
expect(result?.error).toContain("Invalid cron expression");
|
|
@@ -1308,98 +1823,187 @@ describe("gateway shared actions", () => {
|
|
|
1308
1823
|
|
|
1309
1824
|
it("updates multiple fields at once", async () => {
|
|
1310
1825
|
mockGetCronJob.mockReturnValue({
|
|
1311
|
-
id: "job-e6",
|
|
1312
|
-
|
|
1826
|
+
id: "job-e6",
|
|
1827
|
+
chatId: "123",
|
|
1828
|
+
schedule: "0 9 * * *",
|
|
1829
|
+
type: "message",
|
|
1830
|
+
content: "hi",
|
|
1831
|
+
name: "Job",
|
|
1832
|
+
enabled: true,
|
|
1833
|
+
createdAt: 1700000000000,
|
|
1834
|
+
runCount: 0,
|
|
1313
1835
|
});
|
|
1314
1836
|
mockUpdateCronJob.mockReturnValue({ name: "Updated" });
|
|
1315
1837
|
|
|
1316
|
-
const result = await handleSharedAction(
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1838
|
+
const result = await handleSharedAction(
|
|
1839
|
+
{
|
|
1840
|
+
action: "edit_cron_job",
|
|
1841
|
+
job_id: "job-e6",
|
|
1842
|
+
name: "Updated",
|
|
1843
|
+
content: "new",
|
|
1844
|
+
enabled: false,
|
|
1845
|
+
type: "query",
|
|
1846
|
+
},
|
|
1847
|
+
123,
|
|
1848
|
+
);
|
|
1320
1849
|
|
|
1321
1850
|
expect(result?.ok).toBe(true);
|
|
1322
|
-
expect(result?.text).toContain(
|
|
1851
|
+
expect(result?.text).toContain(
|
|
1852
|
+
"Fields changed: name, content, enabled, type",
|
|
1853
|
+
);
|
|
1323
1854
|
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e6", {
|
|
1324
|
-
name: "Updated",
|
|
1855
|
+
name: "Updated",
|
|
1856
|
+
content: "new",
|
|
1857
|
+
enabled: false,
|
|
1858
|
+
type: "query",
|
|
1325
1859
|
});
|
|
1326
1860
|
});
|
|
1327
1861
|
|
|
1328
1862
|
it("updates timezone", async () => {
|
|
1329
1863
|
mockGetCronJob.mockReturnValue({
|
|
1330
|
-
id: "job-e7",
|
|
1331
|
-
|
|
1864
|
+
id: "job-e7",
|
|
1865
|
+
chatId: "123",
|
|
1866
|
+
schedule: "0 9 * * *",
|
|
1867
|
+
type: "message",
|
|
1868
|
+
content: "hi",
|
|
1869
|
+
name: "Job",
|
|
1870
|
+
enabled: true,
|
|
1871
|
+
createdAt: 1700000000000,
|
|
1872
|
+
runCount: 0,
|
|
1332
1873
|
});
|
|
1333
1874
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1334
1875
|
|
|
1335
|
-
const result = await handleSharedAction(
|
|
1336
|
-
|
|
1337
|
-
|
|
1876
|
+
const result = await handleSharedAction(
|
|
1877
|
+
{
|
|
1878
|
+
action: "edit_cron_job",
|
|
1879
|
+
job_id: "job-e7",
|
|
1880
|
+
timezone: "Europe/Berlin",
|
|
1881
|
+
},
|
|
1882
|
+
123,
|
|
1883
|
+
);
|
|
1338
1884
|
|
|
1339
1885
|
expect(result?.ok).toBe(true);
|
|
1340
|
-
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e7", {
|
|
1886
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e7", {
|
|
1887
|
+
timezone: "Europe/Berlin",
|
|
1888
|
+
});
|
|
1341
1889
|
});
|
|
1342
1890
|
|
|
1343
1891
|
it("clears timezone when set to empty", async () => {
|
|
1344
1892
|
mockGetCronJob.mockReturnValue({
|
|
1345
|
-
id: "job-e8",
|
|
1346
|
-
|
|
1893
|
+
id: "job-e8",
|
|
1894
|
+
chatId: "123",
|
|
1895
|
+
schedule: "0 9 * * *",
|
|
1896
|
+
type: "message",
|
|
1897
|
+
content: "hi",
|
|
1898
|
+
name: "Job",
|
|
1899
|
+
enabled: true,
|
|
1900
|
+
createdAt: 1700000000000,
|
|
1901
|
+
runCount: 0,
|
|
1347
1902
|
timezone: "America/New_York",
|
|
1348
1903
|
});
|
|
1349
1904
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1350
1905
|
|
|
1351
|
-
const result = await handleSharedAction(
|
|
1352
|
-
|
|
1353
|
-
|
|
1906
|
+
const result = await handleSharedAction(
|
|
1907
|
+
{
|
|
1908
|
+
action: "edit_cron_job",
|
|
1909
|
+
job_id: "job-e8",
|
|
1910
|
+
timezone: "",
|
|
1911
|
+
},
|
|
1912
|
+
123,
|
|
1913
|
+
);
|
|
1354
1914
|
|
|
1355
1915
|
expect(result?.ok).toBe(true);
|
|
1356
|
-
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e8", {
|
|
1916
|
+
expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e8", {
|
|
1917
|
+
timezone: undefined,
|
|
1918
|
+
});
|
|
1357
1919
|
});
|
|
1358
1920
|
|
|
1359
1921
|
it("uses new timezone for schedule validation when both change", async () => {
|
|
1360
1922
|
mockGetCronJob.mockReturnValue({
|
|
1361
|
-
id: "job-e9",
|
|
1362
|
-
|
|
1923
|
+
id: "job-e9",
|
|
1924
|
+
chatId: "123",
|
|
1925
|
+
schedule: "0 9 * * *",
|
|
1926
|
+
type: "message",
|
|
1927
|
+
content: "hi",
|
|
1928
|
+
name: "Job",
|
|
1929
|
+
enabled: true,
|
|
1930
|
+
createdAt: 1700000000000,
|
|
1931
|
+
runCount: 0,
|
|
1363
1932
|
timezone: "America/New_York",
|
|
1364
1933
|
});
|
|
1365
1934
|
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1366
1935
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1367
1936
|
|
|
1368
|
-
await handleSharedAction(
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1937
|
+
await handleSharedAction(
|
|
1938
|
+
{
|
|
1939
|
+
action: "edit_cron_job",
|
|
1940
|
+
job_id: "job-e9",
|
|
1941
|
+
schedule: "0 12 * * *",
|
|
1942
|
+
timezone: "Asia/Tokyo",
|
|
1943
|
+
},
|
|
1944
|
+
123,
|
|
1945
|
+
);
|
|
1372
1946
|
|
|
1373
1947
|
// Should validate with the NEW timezone, not the old one
|
|
1374
|
-
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1948
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1949
|
+
"0 12 * * *",
|
|
1950
|
+
"Asia/Tokyo",
|
|
1951
|
+
);
|
|
1375
1952
|
});
|
|
1376
1953
|
|
|
1377
1954
|
it("falls back to job timezone when schedule changes but timezone does not", async () => {
|
|
1378
1955
|
mockGetCronJob.mockReturnValue({
|
|
1379
|
-
id: "job-e10",
|
|
1380
|
-
|
|
1956
|
+
id: "job-e10",
|
|
1957
|
+
chatId: "123",
|
|
1958
|
+
schedule: "0 9 * * *",
|
|
1959
|
+
type: "message",
|
|
1960
|
+
content: "hi",
|
|
1961
|
+
name: "Job",
|
|
1962
|
+
enabled: true,
|
|
1963
|
+
createdAt: 1700000000000,
|
|
1964
|
+
runCount: 0,
|
|
1381
1965
|
timezone: "US/Pacific",
|
|
1382
1966
|
});
|
|
1383
1967
|
mockValidateCronExpression.mockReturnValueOnce({ valid: true });
|
|
1384
1968
|
mockUpdateCronJob.mockReturnValue({ name: "Job" });
|
|
1385
1969
|
|
|
1386
|
-
await handleSharedAction(
|
|
1387
|
-
|
|
1388
|
-
|
|
1970
|
+
await handleSharedAction(
|
|
1971
|
+
{
|
|
1972
|
+
action: "edit_cron_job",
|
|
1973
|
+
job_id: "job-e10",
|
|
1974
|
+
schedule: "30 8 * * 1-5",
|
|
1975
|
+
},
|
|
1976
|
+
123,
|
|
1977
|
+
);
|
|
1389
1978
|
|
|
1390
|
-
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1979
|
+
expect(mockValidateCronExpression).toHaveBeenCalledWith(
|
|
1980
|
+
"30 8 * * 1-5",
|
|
1981
|
+
"US/Pacific",
|
|
1982
|
+
);
|
|
1391
1983
|
});
|
|
1392
1984
|
|
|
1393
1985
|
it("uses job_id as fallback name when updateCronJob returns no name", async () => {
|
|
1394
1986
|
mockGetCronJob.mockReturnValue({
|
|
1395
|
-
id: "job-e11",
|
|
1396
|
-
|
|
1987
|
+
id: "job-e11",
|
|
1988
|
+
chatId: "123",
|
|
1989
|
+
schedule: "0 9 * * *",
|
|
1990
|
+
type: "message",
|
|
1991
|
+
content: "hi",
|
|
1992
|
+
name: "Job",
|
|
1993
|
+
enabled: true,
|
|
1994
|
+
createdAt: 1700000000000,
|
|
1995
|
+
runCount: 0,
|
|
1397
1996
|
});
|
|
1398
1997
|
mockUpdateCronJob.mockReturnValue(undefined);
|
|
1399
1998
|
|
|
1400
|
-
const result = await handleSharedAction(
|
|
1401
|
-
|
|
1402
|
-
|
|
1999
|
+
const result = await handleSharedAction(
|
|
2000
|
+
{
|
|
2001
|
+
action: "edit_cron_job",
|
|
2002
|
+
job_id: "job-e11",
|
|
2003
|
+
content: "new",
|
|
2004
|
+
},
|
|
2005
|
+
123,
|
|
2006
|
+
);
|
|
1403
2007
|
|
|
1404
2008
|
expect(result?.ok).toBe(true);
|
|
1405
2009
|
expect(result?.text).toContain("job-e11");
|
|
@@ -1410,14 +2014,20 @@ describe("gateway shared actions", () => {
|
|
|
1410
2014
|
|
|
1411
2015
|
describe("delete_cron_job", () => {
|
|
1412
2016
|
it("rejects missing job_id", async () => {
|
|
1413
|
-
const result = await handleSharedAction(
|
|
2017
|
+
const result = await handleSharedAction(
|
|
2018
|
+
{ action: "delete_cron_job" },
|
|
2019
|
+
123,
|
|
2020
|
+
);
|
|
1414
2021
|
expect(result).toEqual({ ok: false, error: "Missing job_id" });
|
|
1415
2022
|
});
|
|
1416
2023
|
|
|
1417
2024
|
it("rejects non-existent job", async () => {
|
|
1418
2025
|
mockGetCronJob.mockReturnValue(undefined);
|
|
1419
2026
|
|
|
1420
|
-
const result = await handleSharedAction(
|
|
2027
|
+
const result = await handleSharedAction(
|
|
2028
|
+
{ action: "delete_cron_job", job_id: "ghost" },
|
|
2029
|
+
123,
|
|
2030
|
+
);
|
|
1421
2031
|
|
|
1422
2032
|
expect(result?.ok).toBe(false);
|
|
1423
2033
|
expect(result?.error).toContain("not found");
|
|
@@ -1425,11 +2035,21 @@ describe("gateway shared actions", () => {
|
|
|
1425
2035
|
|
|
1426
2036
|
it("rejects job from different chat", async () => {
|
|
1427
2037
|
mockGetCronJob.mockReturnValue({
|
|
1428
|
-
id: "job-d1",
|
|
1429
|
-
|
|
2038
|
+
id: "job-d1",
|
|
2039
|
+
chatId: "999",
|
|
2040
|
+
schedule: "0 9 * * *",
|
|
2041
|
+
type: "message",
|
|
2042
|
+
content: "hi",
|
|
2043
|
+
name: "Other",
|
|
2044
|
+
enabled: true,
|
|
2045
|
+
createdAt: 1700000000000,
|
|
2046
|
+
runCount: 0,
|
|
1430
2047
|
});
|
|
1431
2048
|
|
|
1432
|
-
const result = await handleSharedAction(
|
|
2049
|
+
const result = await handleSharedAction(
|
|
2050
|
+
{ action: "delete_cron_job", job_id: "job-d1" },
|
|
2051
|
+
123,
|
|
2052
|
+
);
|
|
1433
2053
|
|
|
1434
2054
|
expect(result?.ok).toBe(false);
|
|
1435
2055
|
expect(result?.error).toBe("Job belongs to a different chat");
|
|
@@ -1437,11 +2057,21 @@ describe("gateway shared actions", () => {
|
|
|
1437
2057
|
|
|
1438
2058
|
it("deletes a job successfully", async () => {
|
|
1439
2059
|
mockGetCronJob.mockReturnValue({
|
|
1440
|
-
id: "job-d2",
|
|
1441
|
-
|
|
2060
|
+
id: "job-d2",
|
|
2061
|
+
chatId: "123",
|
|
2062
|
+
schedule: "0 9 * * *",
|
|
2063
|
+
type: "message",
|
|
2064
|
+
content: "hi",
|
|
2065
|
+
name: "To Delete",
|
|
2066
|
+
enabled: true,
|
|
2067
|
+
createdAt: 1700000000000,
|
|
2068
|
+
runCount: 5,
|
|
1442
2069
|
});
|
|
1443
2070
|
|
|
1444
|
-
const result = await handleSharedAction(
|
|
2071
|
+
const result = await handleSharedAction(
|
|
2072
|
+
{ action: "delete_cron_job", job_id: "job-d2" },
|
|
2073
|
+
123,
|
|
2074
|
+
);
|
|
1445
2075
|
|
|
1446
2076
|
expect(result?.ok).toBe(true);
|
|
1447
2077
|
expect(result?.text).toContain('Deleted cron job "To Delete"');
|
|
@@ -1451,3 +2081,114 @@ describe("gateway shared actions", () => {
|
|
|
1451
2081
|
});
|
|
1452
2082
|
});
|
|
1453
2083
|
});
|
|
2084
|
+
|
|
2085
|
+
// ── Additional branch coverage for fetch_url and web_search ──────────────
|
|
2086
|
+
|
|
2087
|
+
describe("gateway-actions — additional branch coverage", () => {
|
|
2088
|
+
let originalFetch: typeof globalThis.fetch;
|
|
2089
|
+
|
|
2090
|
+
beforeEach(() => {
|
|
2091
|
+
originalFetch = globalThis.fetch;
|
|
2092
|
+
delete process.env.TALON_BRAVE_API_KEY;
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
afterEach(() => {
|
|
2096
|
+
globalThis.fetch = originalFetch;
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
it("rejects fetch_url when Content-Length header exceeds 20MB (line 152 TRUE branch)", async () => {
|
|
2100
|
+
// Build a response that has a Content-Length header > 20MB
|
|
2101
|
+
const headers = new Headers();
|
|
2102
|
+
headers.set("content-type", "text/html");
|
|
2103
|
+
headers.set("content-length", String(25 * 1024 * 1024)); // 25MB
|
|
2104
|
+
const bigResponse = {
|
|
2105
|
+
ok: true,
|
|
2106
|
+
status: 200,
|
|
2107
|
+
headers,
|
|
2108
|
+
text: async () => "some text",
|
|
2109
|
+
json: async () => ({}),
|
|
2110
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
2111
|
+
} as unknown as Response;
|
|
2112
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(bigResponse));
|
|
2113
|
+
|
|
2114
|
+
const result = await handleSharedAction(
|
|
2115
|
+
{ action: "fetch_url", url: "https://example.com/huge.html" },
|
|
2116
|
+
123,
|
|
2117
|
+
);
|
|
2118
|
+
|
|
2119
|
+
expect(result?.ok).toBe(false);
|
|
2120
|
+
expect(result?.error).toContain("too large");
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
it("uses empty string when content-type header is absent in fetch_url (line 146 FALSE branch)", async () => {
|
|
2124
|
+
// Response with no content-type header and binary content → falls through to binary path
|
|
2125
|
+
// ct="" → mimeType="" → isText=false → binary download
|
|
2126
|
+
const noCtHeaders = new Headers(); // no content-type
|
|
2127
|
+
const buf = new ArrayBuffer(16);
|
|
2128
|
+
const view = new Uint8Array(buf);
|
|
2129
|
+
view[0] = 0x25; // not a known image magic byte → ext = "bin"
|
|
2130
|
+
const noCtResponse = {
|
|
2131
|
+
ok: true,
|
|
2132
|
+
status: 200,
|
|
2133
|
+
headers: noCtHeaders,
|
|
2134
|
+
text: async () => "",
|
|
2135
|
+
json: async () => ({}),
|
|
2136
|
+
arrayBuffer: async () => buf,
|
|
2137
|
+
} as unknown as Response;
|
|
2138
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(noCtResponse));
|
|
2139
|
+
|
|
2140
|
+
const result = await handleSharedAction(
|
|
2141
|
+
{ action: "fetch_url", url: "https://example.com/unknown" },
|
|
2142
|
+
123,
|
|
2143
|
+
);
|
|
2144
|
+
// Should succeed (downloaded as bin), covering ct ?? "" right side and ct.split("/")[1] ?? "file" right side
|
|
2145
|
+
expect(result?.ok).toBe(true);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it("handles search result with missing content field (line 113 r.content ?? '' branch)", async () => {
|
|
2149
|
+
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
2150
|
+
ok: true,
|
|
2151
|
+
status: 200,
|
|
2152
|
+
headers: new Headers(),
|
|
2153
|
+
json: async () => ({
|
|
2154
|
+
results: [
|
|
2155
|
+
{ title: "Result", url: "https://example.com", content: undefined },
|
|
2156
|
+
],
|
|
2157
|
+
}),
|
|
2158
|
+
text: async () => "",
|
|
2159
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
2160
|
+
} as unknown as Response);
|
|
2161
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
2162
|
+
process.env.TALON_SEARXNG_URL = "http://localhost:8080";
|
|
2163
|
+
|
|
2164
|
+
const result = await handleSharedAction(
|
|
2165
|
+
{ action: "web_search", query: "test" },
|
|
2166
|
+
123,
|
|
2167
|
+
);
|
|
2168
|
+
expect(result?.ok).toBe(true);
|
|
2169
|
+
// snippet should be "" (from ?? "")
|
|
2170
|
+
expect(result?.text).toBeDefined();
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it("handles search response with no results array (line 113 data.results ?? [] branch)", async () => {
|
|
2174
|
+
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
2175
|
+
ok: true,
|
|
2176
|
+
status: 200,
|
|
2177
|
+
headers: new Headers(),
|
|
2178
|
+
json: async () => ({
|
|
2179
|
+
/* no results property */
|
|
2180
|
+
}),
|
|
2181
|
+
text: async () => "",
|
|
2182
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
2183
|
+
} as unknown as Response);
|
|
2184
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
2185
|
+
process.env.TALON_SEARXNG_URL = "http://localhost:8080";
|
|
2186
|
+
|
|
2187
|
+
const result = await handleSharedAction(
|
|
2188
|
+
{ action: "web_search", query: "empty" },
|
|
2189
|
+
123,
|
|
2190
|
+
);
|
|
2191
|
+
expect(result?.ok).toBe(true);
|
|
2192
|
+
expect(result?.text).toContain("No results for");
|
|
2193
|
+
});
|
|
2194
|
+
});
|