postgresai 0.14.0-dev.56 → 0.14.0-dev.58
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/bin/postgres-ai.ts +201 -8
- package/dist/bin/postgres-ai.js +693 -83
- package/dist/sql/05.helpers.sql +31 -7
- package/dist/sql/sql/05.helpers.sql +31 -7
- package/lib/config.ts +4 -4
- package/lib/issues.ts +318 -0
- package/lib/mcp-server.ts +207 -73
- package/lib/metrics-embedded.ts +1 -1
- package/package.json +1 -1
- package/sql/05.helpers.sql +31 -7
- package/test/init.integration.test.ts +98 -0
- package/test/init.test.ts +72 -0
- package/test/issues.cli.test.ts +314 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +988 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import { handleToolCall, interpretEscapes, type McpToolRequest } from "../lib/mcp-server";
|
|
3
|
+
import * as config from "../lib/config";
|
|
4
|
+
import * as issues from "../lib/issues";
|
|
5
|
+
|
|
6
|
+
// Save originals for restoration
|
|
7
|
+
const originalFetch = globalThis.fetch;
|
|
8
|
+
const originalEnv = { ...process.env };
|
|
9
|
+
|
|
10
|
+
// Helper to create MCP tool request
|
|
11
|
+
function createRequest(name: string, args?: Record<string, unknown>): McpToolRequest {
|
|
12
|
+
return {
|
|
13
|
+
params: {
|
|
14
|
+
name,
|
|
15
|
+
arguments: args,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Helper to extract text from response
|
|
21
|
+
function getResponseText(response: { content: Array<{ text: string }> }): string {
|
|
22
|
+
return response.content[0]?.text || "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("MCP Server", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Clear env vars that might interfere
|
|
28
|
+
delete process.env.PGAI_API_KEY;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
// Restore originals
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
process.env = { ...originalEnv };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("interpretEscapes", () => {
|
|
38
|
+
test("converts \\n to newline", () => {
|
|
39
|
+
expect(interpretEscapes("line1\\nline2")).toBe("line1\nline2");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("converts \\t to tab", () => {
|
|
43
|
+
expect(interpretEscapes("col1\\tcol2")).toBe("col1\tcol2");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("converts \\r to carriage return", () => {
|
|
47
|
+
expect(interpretEscapes("text\\rmore")).toBe("text\rmore");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('converts \\" to double quote', () => {
|
|
51
|
+
expect(interpretEscapes('say \\"hello\\"')).toBe('say "hello"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("converts \\' to single quote", () => {
|
|
55
|
+
expect(interpretEscapes("it\\'s")).toBe("it's");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles multiple escape sequences", () => {
|
|
59
|
+
expect(interpretEscapes("line1\\nline2\\ttab\\nline3")).toBe("line1\nline2\ttab\nline3");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("handles empty string", () => {
|
|
63
|
+
expect(interpretEscapes("")).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles null/undefined gracefully", () => {
|
|
67
|
+
expect(interpretEscapes(null as unknown as string)).toBe("");
|
|
68
|
+
expect(interpretEscapes(undefined as unknown as string)).toBe("");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("API key validation", () => {
|
|
73
|
+
test("returns error when no API key available", async () => {
|
|
74
|
+
// Mock config to return no API key
|
|
75
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
76
|
+
apiKey: null,
|
|
77
|
+
baseUrl: null,
|
|
78
|
+
orgId: null,
|
|
79
|
+
defaultProject: null,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
83
|
+
|
|
84
|
+
expect(response.isError).toBe(true);
|
|
85
|
+
expect(getResponseText(response)).toContain("API key is required");
|
|
86
|
+
|
|
87
|
+
readConfigSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("uses API key from rootOpts when provided", async () => {
|
|
91
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
92
|
+
apiKey: null,
|
|
93
|
+
baseUrl: null,
|
|
94
|
+
orgId: null,
|
|
95
|
+
defaultProject: null,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Mock fetch to verify API key is used
|
|
99
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
100
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
101
|
+
capturedHeaders = options?.headers;
|
|
102
|
+
return Promise.resolve(
|
|
103
|
+
new Response(JSON.stringify([]), {
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await handleToolCall(createRequest("list_issues"), { apiKey: "test-api-key" });
|
|
111
|
+
|
|
112
|
+
expect(capturedHeaders).toBeDefined();
|
|
113
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("test-api-key");
|
|
114
|
+
|
|
115
|
+
readConfigSpy.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("falls back to config API key when rootOpts not provided", async () => {
|
|
119
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
120
|
+
apiKey: "config-api-key",
|
|
121
|
+
baseUrl: null,
|
|
122
|
+
orgId: null,
|
|
123
|
+
defaultProject: null,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
127
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
128
|
+
capturedHeaders = options?.headers;
|
|
129
|
+
return Promise.resolve(
|
|
130
|
+
new Response(JSON.stringify([]), {
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await handleToolCall(createRequest("list_issues"));
|
|
138
|
+
|
|
139
|
+
expect(capturedHeaders).toBeDefined();
|
|
140
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("config-api-key");
|
|
141
|
+
|
|
142
|
+
readConfigSpy.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("uses PGAI_API_KEY env var as fallback", async () => {
|
|
146
|
+
process.env.PGAI_API_KEY = "env-api-key";
|
|
147
|
+
|
|
148
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
149
|
+
apiKey: null,
|
|
150
|
+
baseUrl: null,
|
|
151
|
+
orgId: null,
|
|
152
|
+
defaultProject: null,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
156
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
157
|
+
capturedHeaders = options?.headers;
|
|
158
|
+
return Promise.resolve(
|
|
159
|
+
new Response(JSON.stringify([]), {
|
|
160
|
+
status: 200,
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await handleToolCall(createRequest("list_issues"));
|
|
167
|
+
|
|
168
|
+
expect(capturedHeaders).toBeDefined();
|
|
169
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("env-api-key");
|
|
170
|
+
|
|
171
|
+
readConfigSpy.mockRestore();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("list_issues tool", () => {
|
|
176
|
+
test("successfully returns issues list as JSON", async () => {
|
|
177
|
+
const mockIssues = [
|
|
178
|
+
{ id: "issue-1", title: "First Issue" },
|
|
179
|
+
{ id: "issue-2", title: "Second Issue" },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
183
|
+
apiKey: "test-key",
|
|
184
|
+
baseUrl: null,
|
|
185
|
+
orgId: null,
|
|
186
|
+
defaultProject: null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
globalThis.fetch = mock(() =>
|
|
190
|
+
Promise.resolve(
|
|
191
|
+
new Response(JSON.stringify(mockIssues), {
|
|
192
|
+
status: 200,
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
199
|
+
|
|
200
|
+
expect(response.isError).toBeUndefined();
|
|
201
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
202
|
+
expect(parsed).toHaveLength(2);
|
|
203
|
+
expect(parsed[0].title).toBe("First Issue");
|
|
204
|
+
|
|
205
|
+
readConfigSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("handles API errors gracefully", async () => {
|
|
209
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
210
|
+
apiKey: "test-key",
|
|
211
|
+
baseUrl: null,
|
|
212
|
+
orgId: null,
|
|
213
|
+
defaultProject: null,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
globalThis.fetch = mock(() =>
|
|
217
|
+
Promise.resolve(
|
|
218
|
+
new Response('{"message": "Unauthorized"}', {
|
|
219
|
+
status: 401,
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
226
|
+
|
|
227
|
+
expect(response.isError).toBe(true);
|
|
228
|
+
expect(getResponseText(response)).toContain("401");
|
|
229
|
+
|
|
230
|
+
readConfigSpy.mockRestore();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("view_issue tool", () => {
|
|
235
|
+
test("returns error when issue_id is empty", async () => {
|
|
236
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
237
|
+
apiKey: "test-key",
|
|
238
|
+
baseUrl: null,
|
|
239
|
+
orgId: null,
|
|
240
|
+
defaultProject: null,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "" }));
|
|
244
|
+
|
|
245
|
+
expect(response.isError).toBe(true);
|
|
246
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
247
|
+
|
|
248
|
+
readConfigSpy.mockRestore();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("returns error when issue_id is whitespace only", async () => {
|
|
252
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
253
|
+
apiKey: "test-key",
|
|
254
|
+
baseUrl: null,
|
|
255
|
+
orgId: null,
|
|
256
|
+
defaultProject: null,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: " " }));
|
|
260
|
+
|
|
261
|
+
expect(response.isError).toBe(true);
|
|
262
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
263
|
+
|
|
264
|
+
readConfigSpy.mockRestore();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("returns error when issue not found", async () => {
|
|
268
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
269
|
+
apiKey: "test-key",
|
|
270
|
+
baseUrl: null,
|
|
271
|
+
orgId: null,
|
|
272
|
+
defaultProject: null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Return null for issue (not found)
|
|
276
|
+
globalThis.fetch = mock(() =>
|
|
277
|
+
Promise.resolve(
|
|
278
|
+
new Response("null", {
|
|
279
|
+
status: 200,
|
|
280
|
+
headers: { "Content-Type": "application/json" },
|
|
281
|
+
})
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "nonexistent-id" }));
|
|
286
|
+
|
|
287
|
+
expect(response.isError).toBe(true);
|
|
288
|
+
expect(getResponseText(response)).toBe("Issue not found");
|
|
289
|
+
|
|
290
|
+
readConfigSpy.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("successfully returns combined issue and comments", async () => {
|
|
294
|
+
const mockIssue = { id: "issue-1", title: "Test Issue" };
|
|
295
|
+
const mockComments = [{ id: "comment-1", content: "Test comment" }];
|
|
296
|
+
|
|
297
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
298
|
+
apiKey: "test-key",
|
|
299
|
+
baseUrl: null,
|
|
300
|
+
orgId: null,
|
|
301
|
+
defaultProject: null,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
let callCount = 0;
|
|
305
|
+
globalThis.fetch = mock((url: string) => {
|
|
306
|
+
callCount++;
|
|
307
|
+
// First call is for the issue, second is for comments
|
|
308
|
+
if (url.includes("issue_get") || callCount === 1) {
|
|
309
|
+
return Promise.resolve(
|
|
310
|
+
new Response(JSON.stringify(mockIssue), {
|
|
311
|
+
status: 200,
|
|
312
|
+
headers: { "Content-Type": "application/json" },
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return Promise.resolve(
|
|
317
|
+
new Response(JSON.stringify(mockComments), {
|
|
318
|
+
status: 200,
|
|
319
|
+
headers: { "Content-Type": "application/json" },
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "issue-1" }));
|
|
325
|
+
|
|
326
|
+
expect(response.isError).toBeUndefined();
|
|
327
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
328
|
+
expect(parsed.issue.title).toBe("Test Issue");
|
|
329
|
+
expect(parsed.comments).toHaveLength(1);
|
|
330
|
+
|
|
331
|
+
readConfigSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("post_issue_comment tool", () => {
|
|
336
|
+
test("returns error when issue_id is empty", async () => {
|
|
337
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
338
|
+
apiKey: "test-key",
|
|
339
|
+
baseUrl: null,
|
|
340
|
+
orgId: null,
|
|
341
|
+
defaultProject: null,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const response = await handleToolCall(
|
|
345
|
+
createRequest("post_issue_comment", { issue_id: "", content: "test" })
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(response.isError).toBe(true);
|
|
349
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
350
|
+
|
|
351
|
+
readConfigSpy.mockRestore();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("returns error when content is empty", async () => {
|
|
355
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
356
|
+
apiKey: "test-key",
|
|
357
|
+
baseUrl: null,
|
|
358
|
+
orgId: null,
|
|
359
|
+
defaultProject: null,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const response = await handleToolCall(
|
|
363
|
+
createRequest("post_issue_comment", { issue_id: "issue-1", content: "" })
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(response.isError).toBe(true);
|
|
367
|
+
expect(getResponseText(response)).toBe("content is required");
|
|
368
|
+
|
|
369
|
+
readConfigSpy.mockRestore();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("interprets escape sequences in content", async () => {
|
|
373
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
374
|
+
apiKey: "test-key",
|
|
375
|
+
baseUrl: null,
|
|
376
|
+
orgId: null,
|
|
377
|
+
defaultProject: null,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let capturedBody: string | undefined;
|
|
381
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
382
|
+
capturedBody = options?.body as string;
|
|
383
|
+
return Promise.resolve(
|
|
384
|
+
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
385
|
+
status: 200,
|
|
386
|
+
headers: { "Content-Type": "application/json" },
|
|
387
|
+
})
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await handleToolCall(
|
|
392
|
+
createRequest("post_issue_comment", {
|
|
393
|
+
issue_id: "issue-1",
|
|
394
|
+
content: "line1\\nline2\\ttab",
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(capturedBody).toBeDefined();
|
|
399
|
+
const parsed = JSON.parse(capturedBody!);
|
|
400
|
+
expect(parsed.content).toBe("line1\nline2\ttab");
|
|
401
|
+
|
|
402
|
+
readConfigSpy.mockRestore();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("successfully creates comment with parent_comment_id", async () => {
|
|
406
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
407
|
+
apiKey: "test-key",
|
|
408
|
+
baseUrl: null,
|
|
409
|
+
orgId: null,
|
|
410
|
+
defaultProject: null,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
let capturedBody: string | undefined;
|
|
414
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
415
|
+
capturedBody = options?.body as string;
|
|
416
|
+
return Promise.resolve(
|
|
417
|
+
new Response(JSON.stringify({ id: "comment-1", parent_comment_id: "parent-1" }), {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: { "Content-Type": "application/json" },
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const response = await handleToolCall(
|
|
425
|
+
createRequest("post_issue_comment", {
|
|
426
|
+
issue_id: "issue-1",
|
|
427
|
+
content: "Reply content",
|
|
428
|
+
parent_comment_id: "parent-1",
|
|
429
|
+
})
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(response.isError).toBeUndefined();
|
|
433
|
+
expect(capturedBody).toBeDefined();
|
|
434
|
+
const parsed = JSON.parse(capturedBody!);
|
|
435
|
+
expect(parsed.parent_comment_id).toBe("parent-1");
|
|
436
|
+
|
|
437
|
+
readConfigSpy.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("create_issue tool", () => {
|
|
442
|
+
test("returns error when title is empty", async () => {
|
|
443
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
444
|
+
apiKey: "test-key",
|
|
445
|
+
baseUrl: null,
|
|
446
|
+
orgId: 1,
|
|
447
|
+
defaultProject: null,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const response = await handleToolCall(createRequest("create_issue", { title: "" }));
|
|
451
|
+
|
|
452
|
+
expect(response.isError).toBe(true);
|
|
453
|
+
expect(getResponseText(response)).toBe("title is required");
|
|
454
|
+
|
|
455
|
+
readConfigSpy.mockRestore();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("returns error when title is whitespace only", async () => {
|
|
459
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
460
|
+
apiKey: "test-key",
|
|
461
|
+
baseUrl: null,
|
|
462
|
+
orgId: 1,
|
|
463
|
+
defaultProject: null,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const response = await handleToolCall(createRequest("create_issue", { title: " " }));
|
|
467
|
+
|
|
468
|
+
expect(response.isError).toBe(true);
|
|
469
|
+
expect(getResponseText(response)).toBe("title is required");
|
|
470
|
+
|
|
471
|
+
readConfigSpy.mockRestore();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("returns error when org_id not provided and not in config", async () => {
|
|
475
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
476
|
+
apiKey: "test-key",
|
|
477
|
+
baseUrl: null,
|
|
478
|
+
orgId: null,
|
|
479
|
+
defaultProject: null,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const response = await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
483
|
+
|
|
484
|
+
expect(response.isError).toBe(true);
|
|
485
|
+
expect(getResponseText(response)).toContain("org_id is required");
|
|
486
|
+
|
|
487
|
+
readConfigSpy.mockRestore();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("falls back to config orgId when not provided in args", async () => {
|
|
491
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
492
|
+
apiKey: "test-key",
|
|
493
|
+
baseUrl: null,
|
|
494
|
+
orgId: 42,
|
|
495
|
+
defaultProject: null,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
let capturedBody: string | undefined;
|
|
499
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
500
|
+
capturedBody = options?.body as string;
|
|
501
|
+
return Promise.resolve(
|
|
502
|
+
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
503
|
+
status: 200,
|
|
504
|
+
headers: { "Content-Type": "application/json" },
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
510
|
+
|
|
511
|
+
expect(capturedBody).toBeDefined();
|
|
512
|
+
const parsed = JSON.parse(capturedBody!);
|
|
513
|
+
expect(parsed.org_id).toBe(42);
|
|
514
|
+
|
|
515
|
+
readConfigSpy.mockRestore();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("interprets escape sequences in title and description", async () => {
|
|
519
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
520
|
+
apiKey: "test-key",
|
|
521
|
+
baseUrl: null,
|
|
522
|
+
orgId: 1,
|
|
523
|
+
defaultProject: null,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
let capturedBody: string | undefined;
|
|
527
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
528
|
+
capturedBody = options?.body as string;
|
|
529
|
+
return Promise.resolve(
|
|
530
|
+
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
531
|
+
status: 200,
|
|
532
|
+
headers: { "Content-Type": "application/json" },
|
|
533
|
+
})
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await handleToolCall(
|
|
538
|
+
createRequest("create_issue", {
|
|
539
|
+
title: "Title\\nwith newline",
|
|
540
|
+
description: "Desc\\twith tab",
|
|
541
|
+
})
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(capturedBody).toBeDefined();
|
|
545
|
+
const parsed = JSON.parse(capturedBody!);
|
|
546
|
+
expect(parsed.title).toBe("Title\nwith newline");
|
|
547
|
+
expect(parsed.description).toBe("Desc\twith tab");
|
|
548
|
+
|
|
549
|
+
readConfigSpy.mockRestore();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("successfully creates issue with all parameters", async () => {
|
|
553
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
554
|
+
apiKey: "test-key",
|
|
555
|
+
baseUrl: null,
|
|
556
|
+
orgId: null,
|
|
557
|
+
defaultProject: null,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
let capturedBody: string | undefined;
|
|
561
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
562
|
+
capturedBody = options?.body as string;
|
|
563
|
+
return Promise.resolve(
|
|
564
|
+
new Response(JSON.stringify({ id: "new-issue", title: "Test" }), {
|
|
565
|
+
status: 200,
|
|
566
|
+
headers: { "Content-Type": "application/json" },
|
|
567
|
+
})
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const response = await handleToolCall(
|
|
572
|
+
createRequest("create_issue", {
|
|
573
|
+
title: "Test Issue",
|
|
574
|
+
description: "Test description",
|
|
575
|
+
org_id: 123,
|
|
576
|
+
project_id: 456,
|
|
577
|
+
labels: ["bug", "urgent"],
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
expect(response.isError).toBeUndefined();
|
|
582
|
+
expect(capturedBody).toBeDefined();
|
|
583
|
+
const parsed = JSON.parse(capturedBody!);
|
|
584
|
+
expect(parsed.title).toBe("Test Issue");
|
|
585
|
+
expect(parsed.description).toBe("Test description");
|
|
586
|
+
expect(parsed.org_id).toBe(123);
|
|
587
|
+
expect(parsed.project_id).toBe(456);
|
|
588
|
+
expect(parsed.labels).toEqual(["bug", "urgent"]);
|
|
589
|
+
|
|
590
|
+
readConfigSpy.mockRestore();
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("update_issue tool", () => {
|
|
595
|
+
test("returns error when issue_id is empty", async () => {
|
|
596
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
597
|
+
apiKey: "test-key",
|
|
598
|
+
baseUrl: null,
|
|
599
|
+
orgId: null,
|
|
600
|
+
defaultProject: null,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const response = await handleToolCall(
|
|
604
|
+
createRequest("update_issue", { issue_id: "", title: "New Title" })
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
expect(response.isError).toBe(true);
|
|
608
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
609
|
+
|
|
610
|
+
readConfigSpy.mockRestore();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("returns error when no update fields provided", async () => {
|
|
614
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
615
|
+
apiKey: "test-key",
|
|
616
|
+
baseUrl: null,
|
|
617
|
+
orgId: null,
|
|
618
|
+
defaultProject: null,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const response = await handleToolCall(createRequest("update_issue", { issue_id: "issue-1" }));
|
|
622
|
+
|
|
623
|
+
expect(response.isError).toBe(true);
|
|
624
|
+
expect(getResponseText(response)).toContain("At least one field to update is required");
|
|
625
|
+
|
|
626
|
+
readConfigSpy.mockRestore();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("returns error when status is not 0 or 1", async () => {
|
|
630
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
631
|
+
apiKey: "test-key",
|
|
632
|
+
baseUrl: null,
|
|
633
|
+
orgId: null,
|
|
634
|
+
defaultProject: null,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const response = await handleToolCall(
|
|
638
|
+
createRequest("update_issue", { issue_id: "issue-1", status: 2 })
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
expect(response.isError).toBe(true);
|
|
642
|
+
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
643
|
+
|
|
644
|
+
readConfigSpy.mockRestore();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("returns error when status is negative", async () => {
|
|
648
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
649
|
+
apiKey: "test-key",
|
|
650
|
+
baseUrl: null,
|
|
651
|
+
orgId: null,
|
|
652
|
+
defaultProject: null,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const response = await handleToolCall(
|
|
656
|
+
createRequest("update_issue", { issue_id: "issue-1", status: -1 })
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
expect(response.isError).toBe(true);
|
|
660
|
+
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
661
|
+
|
|
662
|
+
readConfigSpy.mockRestore();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("interprets escape sequences in title and description", async () => {
|
|
666
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
667
|
+
apiKey: "test-key",
|
|
668
|
+
baseUrl: null,
|
|
669
|
+
orgId: null,
|
|
670
|
+
defaultProject: null,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
let capturedBody: string | undefined;
|
|
674
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
675
|
+
capturedBody = options?.body as string;
|
|
676
|
+
return Promise.resolve(
|
|
677
|
+
new Response(JSON.stringify({ id: "issue-1" }), {
|
|
678
|
+
status: 200,
|
|
679
|
+
headers: { "Content-Type": "application/json" },
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
await handleToolCall(
|
|
685
|
+
createRequest("update_issue", {
|
|
686
|
+
issue_id: "issue-1",
|
|
687
|
+
title: "Updated\\nTitle",
|
|
688
|
+
description: "Updated\\tDescription",
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(capturedBody).toBeDefined();
|
|
693
|
+
const parsed = JSON.parse(capturedBody!);
|
|
694
|
+
expect(parsed.p_title).toBe("Updated\nTitle");
|
|
695
|
+
expect(parsed.p_description).toBe("Updated\tDescription");
|
|
696
|
+
|
|
697
|
+
readConfigSpy.mockRestore();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("successfully updates with only title", async () => {
|
|
701
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
702
|
+
apiKey: "test-key",
|
|
703
|
+
baseUrl: null,
|
|
704
|
+
orgId: null,
|
|
705
|
+
defaultProject: null,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
globalThis.fetch = mock(() =>
|
|
709
|
+
Promise.resolve(
|
|
710
|
+
new Response(JSON.stringify({ id: "issue-1", title: "New Title" }), {
|
|
711
|
+
status: 200,
|
|
712
|
+
headers: { "Content-Type": "application/json" },
|
|
713
|
+
})
|
|
714
|
+
)
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const response = await handleToolCall(
|
|
718
|
+
createRequest("update_issue", { issue_id: "issue-1", title: "New Title" })
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
expect(response.isError).toBeUndefined();
|
|
722
|
+
|
|
723
|
+
readConfigSpy.mockRestore();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("successfully updates with only status", async () => {
|
|
727
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
728
|
+
apiKey: "test-key",
|
|
729
|
+
baseUrl: null,
|
|
730
|
+
orgId: null,
|
|
731
|
+
defaultProject: null,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
let capturedBody: string | undefined;
|
|
735
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
736
|
+
capturedBody = options?.body as string;
|
|
737
|
+
return Promise.resolve(
|
|
738
|
+
new Response(JSON.stringify({ id: "issue-1", status: 1 }), {
|
|
739
|
+
status: 200,
|
|
740
|
+
headers: { "Content-Type": "application/json" },
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const response = await handleToolCall(
|
|
746
|
+
createRequest("update_issue", { issue_id: "issue-1", status: 1 })
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
expect(response.isError).toBeUndefined();
|
|
750
|
+
expect(capturedBody).toBeDefined();
|
|
751
|
+
const parsed = JSON.parse(capturedBody!);
|
|
752
|
+
expect(parsed.p_status).toBe(1);
|
|
753
|
+
|
|
754
|
+
readConfigSpy.mockRestore();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("successfully updates with only labels", async () => {
|
|
758
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
759
|
+
apiKey: "test-key",
|
|
760
|
+
baseUrl: null,
|
|
761
|
+
orgId: null,
|
|
762
|
+
defaultProject: null,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
let capturedBody: string | undefined;
|
|
766
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
767
|
+
capturedBody = options?.body as string;
|
|
768
|
+
return Promise.resolve(
|
|
769
|
+
new Response(JSON.stringify({ id: "issue-1", labels: ["new-label"] }), {
|
|
770
|
+
status: 200,
|
|
771
|
+
headers: { "Content-Type": "application/json" },
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const response = await handleToolCall(
|
|
777
|
+
createRequest("update_issue", { issue_id: "issue-1", labels: ["new-label"] })
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
expect(response.isError).toBeUndefined();
|
|
781
|
+
expect(capturedBody).toBeDefined();
|
|
782
|
+
const parsed = JSON.parse(capturedBody!);
|
|
783
|
+
expect(parsed.p_labels).toEqual(["new-label"]);
|
|
784
|
+
|
|
785
|
+
readConfigSpy.mockRestore();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("accepts status=0 to reopen issue", async () => {
|
|
789
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
790
|
+
apiKey: "test-key",
|
|
791
|
+
baseUrl: null,
|
|
792
|
+
orgId: null,
|
|
793
|
+
defaultProject: null,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
let capturedBody: string | undefined;
|
|
797
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
798
|
+
capturedBody = options?.body as string;
|
|
799
|
+
return Promise.resolve(
|
|
800
|
+
new Response(JSON.stringify({ id: "issue-1", status: 0 }), {
|
|
801
|
+
status: 200,
|
|
802
|
+
headers: { "Content-Type": "application/json" },
|
|
803
|
+
})
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const response = await handleToolCall(
|
|
808
|
+
createRequest("update_issue", { issue_id: "issue-1", status: 0 })
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
expect(response.isError).toBeUndefined();
|
|
812
|
+
expect(capturedBody).toBeDefined();
|
|
813
|
+
const parsed = JSON.parse(capturedBody!);
|
|
814
|
+
expect(parsed.p_status).toBe(0);
|
|
815
|
+
|
|
816
|
+
readConfigSpy.mockRestore();
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe("update_issue_comment tool", () => {
|
|
821
|
+
test("returns error when comment_id is empty", async () => {
|
|
822
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
823
|
+
apiKey: "test-key",
|
|
824
|
+
baseUrl: null,
|
|
825
|
+
orgId: null,
|
|
826
|
+
defaultProject: null,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
const response = await handleToolCall(
|
|
830
|
+
createRequest("update_issue_comment", { comment_id: "", content: "new content" })
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
expect(response.isError).toBe(true);
|
|
834
|
+
expect(getResponseText(response)).toBe("comment_id is required");
|
|
835
|
+
|
|
836
|
+
readConfigSpy.mockRestore();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("returns error when content is empty", async () => {
|
|
840
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
841
|
+
apiKey: "test-key",
|
|
842
|
+
baseUrl: null,
|
|
843
|
+
orgId: null,
|
|
844
|
+
defaultProject: null,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const response = await handleToolCall(
|
|
848
|
+
createRequest("update_issue_comment", { comment_id: "comment-1", content: "" })
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
expect(response.isError).toBe(true);
|
|
852
|
+
expect(getResponseText(response)).toBe("content is required");
|
|
853
|
+
|
|
854
|
+
readConfigSpy.mockRestore();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("interprets escape sequences in content", async () => {
|
|
858
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
859
|
+
apiKey: "test-key",
|
|
860
|
+
baseUrl: null,
|
|
861
|
+
orgId: null,
|
|
862
|
+
defaultProject: null,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
let capturedBody: string | undefined;
|
|
866
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
867
|
+
capturedBody = options?.body as string;
|
|
868
|
+
return Promise.resolve(
|
|
869
|
+
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
870
|
+
status: 200,
|
|
871
|
+
headers: { "Content-Type": "application/json" },
|
|
872
|
+
})
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
await handleToolCall(
|
|
877
|
+
createRequest("update_issue_comment", {
|
|
878
|
+
comment_id: "comment-1",
|
|
879
|
+
content: "updated\\ncontent\\twith escapes",
|
|
880
|
+
})
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
expect(capturedBody).toBeDefined();
|
|
884
|
+
const parsed = JSON.parse(capturedBody!);
|
|
885
|
+
expect(parsed.p_content).toBe("updated\ncontent\twith escapes");
|
|
886
|
+
|
|
887
|
+
readConfigSpy.mockRestore();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("successfully updates comment", async () => {
|
|
891
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
892
|
+
apiKey: "test-key",
|
|
893
|
+
baseUrl: null,
|
|
894
|
+
orgId: null,
|
|
895
|
+
defaultProject: null,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
globalThis.fetch = mock(() =>
|
|
899
|
+
Promise.resolve(
|
|
900
|
+
new Response(JSON.stringify({ id: "comment-1", content: "Updated content" }), {
|
|
901
|
+
status: 200,
|
|
902
|
+
headers: { "Content-Type": "application/json" },
|
|
903
|
+
})
|
|
904
|
+
)
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
const response = await handleToolCall(
|
|
908
|
+
createRequest("update_issue_comment", {
|
|
909
|
+
comment_id: "comment-1",
|
|
910
|
+
content: "Updated content",
|
|
911
|
+
})
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
expect(response.isError).toBeUndefined();
|
|
915
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
916
|
+
expect(parsed.content).toBe("Updated content");
|
|
917
|
+
|
|
918
|
+
readConfigSpy.mockRestore();
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
describe("unknown tool handling", () => {
|
|
923
|
+
test("returns error for unknown tool name", async () => {
|
|
924
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
925
|
+
apiKey: "test-key",
|
|
926
|
+
baseUrl: null,
|
|
927
|
+
orgId: null,
|
|
928
|
+
defaultProject: null,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
const response = await handleToolCall(createRequest("nonexistent_tool"));
|
|
932
|
+
|
|
933
|
+
expect(response.isError).toBe(true);
|
|
934
|
+
expect(getResponseText(response)).toContain("Unknown tool: nonexistent_tool");
|
|
935
|
+
|
|
936
|
+
readConfigSpy.mockRestore();
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
describe("error propagation", () => {
|
|
941
|
+
test("propagates API errors through MCP layer", async () => {
|
|
942
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
943
|
+
apiKey: "test-key",
|
|
944
|
+
baseUrl: null,
|
|
945
|
+
orgId: 1,
|
|
946
|
+
defaultProject: null,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
globalThis.fetch = mock(() =>
|
|
950
|
+
Promise.resolve(
|
|
951
|
+
new Response('{"message": "Internal Server Error"}', {
|
|
952
|
+
status: 500,
|
|
953
|
+
headers: { "Content-Type": "application/json" },
|
|
954
|
+
})
|
|
955
|
+
)
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
const response = await handleToolCall(
|
|
959
|
+
createRequest("create_issue", { title: "Test Issue" })
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
expect(response.isError).toBe(true);
|
|
963
|
+
expect(getResponseText(response)).toContain("500");
|
|
964
|
+
|
|
965
|
+
readConfigSpy.mockRestore();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("handles network errors gracefully", async () => {
|
|
969
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
970
|
+
apiKey: "test-key",
|
|
971
|
+
baseUrl: null,
|
|
972
|
+
orgId: 1,
|
|
973
|
+
defaultProject: null,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Network error")));
|
|
977
|
+
|
|
978
|
+
const response = await handleToolCall(
|
|
979
|
+
createRequest("create_issue", { title: "Test Issue" })
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
expect(response.isError).toBe(true);
|
|
983
|
+
expect(getResponseText(response)).toContain("Network error");
|
|
984
|
+
|
|
985
|
+
readConfigSpy.mockRestore();
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
});
|