postgresai 0.15.0-dev.1 → 0.15.0-dev.3
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 +228 -4
- package/dist/bin/postgres-ai.js +704 -4
- package/lib/mcp-server.ts +90 -0
- package/lib/reports.ts +373 -0
- package/package.json +1 -1
- package/test/checkup.test.ts +28 -0
- package/test/mcp-server.test.ts +390 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { mkdtempSync, existsSync, readFileSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
7
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
8
|
+
const bunBin =
|
|
9
|
+
typeof process.execPath === "string" && process.execPath.length > 0
|
|
10
|
+
? process.execPath
|
|
11
|
+
: "bun";
|
|
12
|
+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
13
|
+
env: { ...process.env, ...env },
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
status: result.exitCode,
|
|
17
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
18
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runCliAsync(
|
|
23
|
+
args: string[],
|
|
24
|
+
env: Record<string, string> = {}
|
|
25
|
+
) {
|
|
26
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
27
|
+
const bunBin =
|
|
28
|
+
typeof process.execPath === "string" && process.execPath.length > 0
|
|
29
|
+
? process.execPath
|
|
30
|
+
: "bun";
|
|
31
|
+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
32
|
+
env: { ...process.env, ...env },
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
});
|
|
36
|
+
const [status, stdout, stderr] = await Promise.all([
|
|
37
|
+
proc.exited,
|
|
38
|
+
new Response(proc.stdout).text(),
|
|
39
|
+
new Response(proc.stderr).text(),
|
|
40
|
+
]);
|
|
41
|
+
return { status, stdout, stderr };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
45
|
+
const cfgHome = mkdtempSync(resolve(tmpdir(), "postgresai-cli-test-"));
|
|
46
|
+
return {
|
|
47
|
+
XDG_CONFIG_HOME: cfgHome,
|
|
48
|
+
HOME: cfgHome,
|
|
49
|
+
...extra,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startFakeApi() {
|
|
54
|
+
const requests: Array<{
|
|
55
|
+
method: string;
|
|
56
|
+
pathname: string;
|
|
57
|
+
searchParams: Record<string, string>;
|
|
58
|
+
headers: Record<string, string>;
|
|
59
|
+
}> = [];
|
|
60
|
+
|
|
61
|
+
const server = Bun.serve({
|
|
62
|
+
hostname: "127.0.0.1",
|
|
63
|
+
port: 0,
|
|
64
|
+
async fetch(req) {
|
|
65
|
+
const url = new URL(req.url);
|
|
66
|
+
const headers: Record<string, string> = {};
|
|
67
|
+
for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
|
|
68
|
+
const searchParams: Record<string, string> = {};
|
|
69
|
+
for (const [k, v] of url.searchParams.entries()) searchParams[k] = v;
|
|
70
|
+
|
|
71
|
+
requests.push({
|
|
72
|
+
method: req.method,
|
|
73
|
+
pathname: url.pathname,
|
|
74
|
+
searchParams,
|
|
75
|
+
headers,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// GET /checkup_reports
|
|
79
|
+
if (
|
|
80
|
+
req.method === "GET" &&
|
|
81
|
+
url.pathname.endsWith("/checkup_reports")
|
|
82
|
+
) {
|
|
83
|
+
return new Response(
|
|
84
|
+
JSON.stringify([
|
|
85
|
+
{
|
|
86
|
+
id: 1,
|
|
87
|
+
org_id: 1,
|
|
88
|
+
org_name: "TestOrg",
|
|
89
|
+
project_id: 10,
|
|
90
|
+
project_name: "TestProj",
|
|
91
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
92
|
+
created_formatted: "2025-01-01 00:00:00",
|
|
93
|
+
epoch: 1735689600,
|
|
94
|
+
status: "completed",
|
|
95
|
+
},
|
|
96
|
+
]),
|
|
97
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /checkup_report_files
|
|
102
|
+
if (
|
|
103
|
+
req.method === "GET" &&
|
|
104
|
+
url.pathname.endsWith("/checkup_report_files")
|
|
105
|
+
) {
|
|
106
|
+
return new Response(
|
|
107
|
+
JSON.stringify([
|
|
108
|
+
{
|
|
109
|
+
id: 100,
|
|
110
|
+
checkup_report_id: 1,
|
|
111
|
+
filename: "H002.json",
|
|
112
|
+
check_id: "H002",
|
|
113
|
+
type: "json",
|
|
114
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
115
|
+
created_formatted: "2025-01-01 00:00:00",
|
|
116
|
+
project_id: 10,
|
|
117
|
+
project_name: "TestProj",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 101,
|
|
121
|
+
checkup_report_id: 1,
|
|
122
|
+
filename: "H002.md",
|
|
123
|
+
check_id: "H002",
|
|
124
|
+
type: "md",
|
|
125
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
126
|
+
created_formatted: "2025-01-01 00:00:00",
|
|
127
|
+
project_id: 10,
|
|
128
|
+
project_name: "TestProj",
|
|
129
|
+
},
|
|
130
|
+
]),
|
|
131
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// GET /checkup_report_file_data
|
|
136
|
+
if (
|
|
137
|
+
req.method === "GET" &&
|
|
138
|
+
url.pathname.endsWith("/checkup_report_file_data")
|
|
139
|
+
) {
|
|
140
|
+
return new Response(
|
|
141
|
+
JSON.stringify([
|
|
142
|
+
{
|
|
143
|
+
id: 100,
|
|
144
|
+
checkup_report_id: 1,
|
|
145
|
+
filename: "H002.md",
|
|
146
|
+
check_id: "H002",
|
|
147
|
+
type: "md",
|
|
148
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
149
|
+
created_formatted: "2025-01-01 00:00:00",
|
|
150
|
+
project_id: 10,
|
|
151
|
+
project_name: "TestProj",
|
|
152
|
+
data: "# H002 Report\n\nUnused indexes found.\n",
|
|
153
|
+
},
|
|
154
|
+
]),
|
|
155
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return new Response("not found", { status: 404 });
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
baseUrl: `http://${server.hostname}:${server.port}/api/general`,
|
|
165
|
+
requests,
|
|
166
|
+
stop: () => server.stop(true),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Help output
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
describe("CLI reports command group", () => {
|
|
174
|
+
test("reports help exposes list, files, data subcommands", () => {
|
|
175
|
+
const r = runCli(["reports", "--help"], isolatedEnv());
|
|
176
|
+
expect(r.status).toBe(0);
|
|
177
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
178
|
+
expect(out).toContain("list");
|
|
179
|
+
expect(out).toContain("files");
|
|
180
|
+
expect(out).toContain("data");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
// Input validation
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
test("reports list fails fast when API key is missing", () => {
|
|
187
|
+
const r = runCli(["reports", "list"], isolatedEnv());
|
|
188
|
+
expect(r.status).toBe(1);
|
|
189
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("reports files fails fast when API key is missing", () => {
|
|
193
|
+
const r = runCli(["reports", "files", "1"], isolatedEnv());
|
|
194
|
+
expect(r.status).toBe(1);
|
|
195
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("reports files fails when reportId is not a number", () => {
|
|
199
|
+
const r = runCli(
|
|
200
|
+
["reports", "files", "abc"],
|
|
201
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
202
|
+
);
|
|
203
|
+
expect(r.status).toBe(1);
|
|
204
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("reportId must be a number");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("reports files fails when neither reportId nor --check-id is provided", () => {
|
|
208
|
+
const r = runCli(
|
|
209
|
+
["reports", "files"],
|
|
210
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
211
|
+
);
|
|
212
|
+
expect(r.status).toBe(1);
|
|
213
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Either reportId or --check-id is required");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("reports data fails fast when API key is missing", () => {
|
|
217
|
+
const r = runCli(["reports", "data", "1"], isolatedEnv());
|
|
218
|
+
expect(r.status).toBe(1);
|
|
219
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("reports data fails when reportId is not a number", () => {
|
|
223
|
+
const r = runCli(
|
|
224
|
+
["reports", "data", "abc"],
|
|
225
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
226
|
+
);
|
|
227
|
+
expect(r.status).toBe(1);
|
|
228
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("reportId must be a number");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("reports data fails when neither reportId nor --check-id is provided", () => {
|
|
232
|
+
const r = runCli(
|
|
233
|
+
["reports", "data"],
|
|
234
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
235
|
+
);
|
|
236
|
+
expect(r.status).toBe(1);
|
|
237
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Either reportId or --check-id is required");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// -----------------------------------------------------------------------
|
|
241
|
+
// Successful API calls
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
test("reports list succeeds against a fake API", async () => {
|
|
244
|
+
const api = await startFakeApi();
|
|
245
|
+
try {
|
|
246
|
+
const r = await runCliAsync(
|
|
247
|
+
["reports", "list"],
|
|
248
|
+
isolatedEnv({
|
|
249
|
+
PGAI_API_KEY: "test-key",
|
|
250
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
expect(r.status).toBe(0);
|
|
254
|
+
|
|
255
|
+
const out = JSON.parse(r.stdout.trim());
|
|
256
|
+
expect(Array.isArray(out)).toBe(true);
|
|
257
|
+
expect(out[0].id).toBe(1);
|
|
258
|
+
expect(out[0].status).toBe("completed");
|
|
259
|
+
|
|
260
|
+
const req = api.requests.find((x) =>
|
|
261
|
+
x.pathname.endsWith("/checkup_reports")
|
|
262
|
+
);
|
|
263
|
+
expect(req).toBeTruthy();
|
|
264
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
265
|
+
} finally {
|
|
266
|
+
api.stop();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("reports list passes correct filters to API", async () => {
|
|
271
|
+
const api = await startFakeApi();
|
|
272
|
+
try {
|
|
273
|
+
await runCliAsync(
|
|
274
|
+
[
|
|
275
|
+
"reports",
|
|
276
|
+
"list",
|
|
277
|
+
"--project-id",
|
|
278
|
+
"10",
|
|
279
|
+
"--status",
|
|
280
|
+
"completed",
|
|
281
|
+
"--limit",
|
|
282
|
+
"5",
|
|
283
|
+
],
|
|
284
|
+
isolatedEnv({
|
|
285
|
+
PGAI_API_KEY: "test-key",
|
|
286
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const req = api.requests.find((x) =>
|
|
291
|
+
x.pathname.endsWith("/checkup_reports")
|
|
292
|
+
);
|
|
293
|
+
expect(req).toBeTruthy();
|
|
294
|
+
expect(req!.searchParams.project_id).toBe("eq.10");
|
|
295
|
+
expect(req!.searchParams.status).toBe("eq.completed");
|
|
296
|
+
expect(req!.searchParams.limit).toBe("5");
|
|
297
|
+
} finally {
|
|
298
|
+
api.stop();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("reports list --limit caps at 100", async () => {
|
|
303
|
+
const api = await startFakeApi();
|
|
304
|
+
try {
|
|
305
|
+
await runCliAsync(
|
|
306
|
+
["reports", "list", "--limit", "200"],
|
|
307
|
+
isolatedEnv({
|
|
308
|
+
PGAI_API_KEY: "test-key",
|
|
309
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const req = api.requests.find((x) =>
|
|
314
|
+
x.pathname.endsWith("/checkup_reports")
|
|
315
|
+
);
|
|
316
|
+
expect(req).toBeTruthy();
|
|
317
|
+
expect(req!.searchParams.limit).toBe("100");
|
|
318
|
+
} finally {
|
|
319
|
+
api.stop();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("reports list --limit below cap passes through", async () => {
|
|
324
|
+
const api = await startFakeApi();
|
|
325
|
+
try {
|
|
326
|
+
await runCliAsync(
|
|
327
|
+
["reports", "list", "--limit", "50"],
|
|
328
|
+
isolatedEnv({
|
|
329
|
+
PGAI_API_KEY: "test-key",
|
|
330
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const req = api.requests.find((x) =>
|
|
335
|
+
x.pathname.endsWith("/checkup_reports")
|
|
336
|
+
);
|
|
337
|
+
expect(req).toBeTruthy();
|
|
338
|
+
expect(req!.searchParams.limit).toBe("50");
|
|
339
|
+
} finally {
|
|
340
|
+
api.stop();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("reports list --limit with invalid value falls back to default", async () => {
|
|
345
|
+
const api = await startFakeApi();
|
|
346
|
+
try {
|
|
347
|
+
await runCliAsync(
|
|
348
|
+
["reports", "list", "--limit", "abc"],
|
|
349
|
+
isolatedEnv({
|
|
350
|
+
PGAI_API_KEY: "test-key",
|
|
351
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
352
|
+
})
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const req = api.requests.find((x) =>
|
|
356
|
+
x.pathname.endsWith("/checkup_reports")
|
|
357
|
+
);
|
|
358
|
+
expect(req).toBeTruthy();
|
|
359
|
+
expect(req!.searchParams.limit).toBe("20");
|
|
360
|
+
} finally {
|
|
361
|
+
api.stop();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("reports list --limit with negative value clamps to 1", async () => {
|
|
366
|
+
const api = await startFakeApi();
|
|
367
|
+
try {
|
|
368
|
+
await runCliAsync(
|
|
369
|
+
["reports", "list", "--limit", "-5"],
|
|
370
|
+
isolatedEnv({
|
|
371
|
+
PGAI_API_KEY: "test-key",
|
|
372
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const req = api.requests.find((x) =>
|
|
377
|
+
x.pathname.endsWith("/checkup_reports")
|
|
378
|
+
);
|
|
379
|
+
expect(req).toBeTruthy();
|
|
380
|
+
expect(req!.searchParams.limit).toBe("1");
|
|
381
|
+
} finally {
|
|
382
|
+
api.stop();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("reports files succeeds against a fake API", async () => {
|
|
387
|
+
const api = await startFakeApi();
|
|
388
|
+
try {
|
|
389
|
+
const r = await runCliAsync(
|
|
390
|
+
["reports", "files", "1"],
|
|
391
|
+
isolatedEnv({
|
|
392
|
+
PGAI_API_KEY: "test-key",
|
|
393
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
394
|
+
})
|
|
395
|
+
);
|
|
396
|
+
expect(r.status).toBe(0);
|
|
397
|
+
|
|
398
|
+
const out = JSON.parse(r.stdout.trim());
|
|
399
|
+
expect(Array.isArray(out)).toBe(true);
|
|
400
|
+
expect(out[0].filename).toBe("H002.json");
|
|
401
|
+
|
|
402
|
+
const req = api.requests.find((x) =>
|
|
403
|
+
x.pathname.endsWith("/checkup_report_files")
|
|
404
|
+
);
|
|
405
|
+
expect(req).toBeTruthy();
|
|
406
|
+
expect(req!.searchParams.checkup_report_id).toBe("eq.1");
|
|
407
|
+
} finally {
|
|
408
|
+
api.stop();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("reports files passes type and check-id filters", async () => {
|
|
413
|
+
const api = await startFakeApi();
|
|
414
|
+
try {
|
|
415
|
+
await runCliAsync(
|
|
416
|
+
["reports", "files", "1", "--type", "md", "--check-id", "H002"],
|
|
417
|
+
isolatedEnv({
|
|
418
|
+
PGAI_API_KEY: "test-key",
|
|
419
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const req = api.requests.find((x) =>
|
|
424
|
+
x.pathname.endsWith("/checkup_report_files")
|
|
425
|
+
);
|
|
426
|
+
expect(req).toBeTruthy();
|
|
427
|
+
expect(req!.searchParams.type).toBe("eq.md");
|
|
428
|
+
expect(req!.searchParams.check_id).toBe("eq.H002");
|
|
429
|
+
} finally {
|
|
430
|
+
api.stop();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("reports data outputs raw markdown by default", async () => {
|
|
435
|
+
const api = await startFakeApi();
|
|
436
|
+
try {
|
|
437
|
+
const r = await runCliAsync(
|
|
438
|
+
["reports", "data", "1"],
|
|
439
|
+
isolatedEnv({
|
|
440
|
+
PGAI_API_KEY: "test-key",
|
|
441
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
442
|
+
})
|
|
443
|
+
);
|
|
444
|
+
expect(r.status).toBe(0);
|
|
445
|
+
expect(r.stdout).toContain("# H002 Report");
|
|
446
|
+
expect(r.stdout).toContain("Unused indexes found.");
|
|
447
|
+
} finally {
|
|
448
|
+
api.stop();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("reports data succeeds against a fake API with --json", async () => {
|
|
453
|
+
const api = await startFakeApi();
|
|
454
|
+
try {
|
|
455
|
+
const r = await runCliAsync(
|
|
456
|
+
["reports", "data", "1", "--json"],
|
|
457
|
+
isolatedEnv({
|
|
458
|
+
PGAI_API_KEY: "test-key",
|
|
459
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
460
|
+
})
|
|
461
|
+
);
|
|
462
|
+
expect(r.status).toBe(0);
|
|
463
|
+
|
|
464
|
+
const out = JSON.parse(r.stdout.trim());
|
|
465
|
+
expect(Array.isArray(out)).toBe(true);
|
|
466
|
+
expect(out[0].data).toContain("# H002 Report");
|
|
467
|
+
} finally {
|
|
468
|
+
api.stop();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("reports files succeeds with only --check-id (no reportId)", async () => {
|
|
473
|
+
const api = await startFakeApi();
|
|
474
|
+
try {
|
|
475
|
+
const r = await runCliAsync(
|
|
476
|
+
["reports", "files", "--check-id", "H002"],
|
|
477
|
+
isolatedEnv({
|
|
478
|
+
PGAI_API_KEY: "test-key",
|
|
479
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
480
|
+
})
|
|
481
|
+
);
|
|
482
|
+
expect(r.status).toBe(0);
|
|
483
|
+
|
|
484
|
+
const req = api.requests.find((x) =>
|
|
485
|
+
x.pathname.endsWith("/checkup_report_files")
|
|
486
|
+
);
|
|
487
|
+
expect(req).toBeTruthy();
|
|
488
|
+
expect(req!.searchParams.check_id).toBe("eq.H002");
|
|
489
|
+
expect(req!.searchParams.checkup_report_id).toBeUndefined();
|
|
490
|
+
} finally {
|
|
491
|
+
api.stop();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("reports data succeeds with only --check-id (no reportId)", async () => {
|
|
496
|
+
const api = await startFakeApi();
|
|
497
|
+
try {
|
|
498
|
+
const r = await runCliAsync(
|
|
499
|
+
["reports", "data", "--check-id", "H002", "--json"],
|
|
500
|
+
isolatedEnv({
|
|
501
|
+
PGAI_API_KEY: "test-key",
|
|
502
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
expect(r.status).toBe(0);
|
|
506
|
+
|
|
507
|
+
const req = api.requests.find((x) =>
|
|
508
|
+
x.pathname.endsWith("/checkup_report_file_data")
|
|
509
|
+
);
|
|
510
|
+
expect(req).toBeTruthy();
|
|
511
|
+
expect(req!.searchParams.check_id).toBe("eq.H002");
|
|
512
|
+
expect(req!.searchParams.checkup_report_id).toBeUndefined();
|
|
513
|
+
} finally {
|
|
514
|
+
api.stop();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("reports data --output saves files to directory", async () => {
|
|
519
|
+
const api = await startFakeApi();
|
|
520
|
+
try {
|
|
521
|
+
const outDir = mkdtempSync(resolve(tmpdir(), "pgai-output-test-"));
|
|
522
|
+
const r = await runCliAsync(
|
|
523
|
+
["reports", "data", "1", "--output", outDir],
|
|
524
|
+
isolatedEnv({
|
|
525
|
+
PGAI_API_KEY: "test-key",
|
|
526
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
expect(r.status).toBe(0);
|
|
530
|
+
|
|
531
|
+
// Should print the file path to stdout
|
|
532
|
+
expect(r.stdout).toContain("H002.md");
|
|
533
|
+
|
|
534
|
+
// File should exist with correct content
|
|
535
|
+
const filePath = resolve(outDir, "H002.md");
|
|
536
|
+
expect(existsSync(filePath)).toBe(true);
|
|
537
|
+
const content = readFileSync(filePath, "utf-8");
|
|
538
|
+
expect(content).toContain("# H002 Report");
|
|
539
|
+
expect(content).toContain("Unused indexes found.");
|
|
540
|
+
} finally {
|
|
541
|
+
api.stop();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("reports data -o creates directory if it does not exist", async () => {
|
|
546
|
+
const api = await startFakeApi();
|
|
547
|
+
try {
|
|
548
|
+
const base = mkdtempSync(resolve(tmpdir(), "pgai-output-test-"));
|
|
549
|
+
const outDir = resolve(base, "nested", "dir");
|
|
550
|
+
const r = await runCliAsync(
|
|
551
|
+
["reports", "data", "1", "-o", outDir],
|
|
552
|
+
isolatedEnv({
|
|
553
|
+
PGAI_API_KEY: "test-key",
|
|
554
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
expect(r.status).toBe(0);
|
|
558
|
+
expect(existsSync(resolve(outDir, "H002.md"))).toBe(true);
|
|
559
|
+
} finally {
|
|
560
|
+
api.stop();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("reports data --formatted is accepted", async () => {
|
|
565
|
+
const api = await startFakeApi();
|
|
566
|
+
try {
|
|
567
|
+
const r = await runCliAsync(
|
|
568
|
+
["reports", "data", "1", "--formatted"],
|
|
569
|
+
isolatedEnv({
|
|
570
|
+
PGAI_API_KEY: "test-key",
|
|
571
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
572
|
+
})
|
|
573
|
+
);
|
|
574
|
+
expect(r.status).toBe(0);
|
|
575
|
+
// The command should succeed — ANSI formatting only active in TTY
|
|
576
|
+
// In non-TTY (our test pipe), it falls back to raw output
|
|
577
|
+
expect(r.stdout).toContain("H002 Report");
|
|
578
|
+
} finally {
|
|
579
|
+
api.stop();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("reports data sends correct filters to API", async () => {
|
|
584
|
+
const api = await startFakeApi();
|
|
585
|
+
try {
|
|
586
|
+
await runCliAsync(
|
|
587
|
+
[
|
|
588
|
+
"reports",
|
|
589
|
+
"data",
|
|
590
|
+
"1",
|
|
591
|
+
"--type",
|
|
592
|
+
"md",
|
|
593
|
+
"--check-id",
|
|
594
|
+
"H001",
|
|
595
|
+
"--json",
|
|
596
|
+
],
|
|
597
|
+
isolatedEnv({
|
|
598
|
+
PGAI_API_KEY: "test-key",
|
|
599
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
600
|
+
})
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const req = api.requests.find((x) =>
|
|
604
|
+
x.pathname.endsWith("/checkup_report_file_data")
|
|
605
|
+
);
|
|
606
|
+
expect(req).toBeTruthy();
|
|
607
|
+
expect(req!.searchParams.checkup_report_id).toBe("eq.1");
|
|
608
|
+
expect(req!.searchParams.type).toBe("eq.md");
|
|
609
|
+
expect(req!.searchParams.check_id).toBe("eq.H001");
|
|
610
|
+
} finally {
|
|
611
|
+
api.stop();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("reports list --before filters by created_at with ISO date", async () => {
|
|
616
|
+
const api = await startFakeApi();
|
|
617
|
+
try {
|
|
618
|
+
await runCliAsync(
|
|
619
|
+
["reports", "list", "--before", "2025-01-15"],
|
|
620
|
+
isolatedEnv({
|
|
621
|
+
PGAI_API_KEY: "test-key",
|
|
622
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const req = api.requests.find((x) =>
|
|
627
|
+
x.pathname.endsWith("/checkup_reports")
|
|
628
|
+
);
|
|
629
|
+
expect(req).toBeTruthy();
|
|
630
|
+
expect(req!.searchParams.created_at).toContain("lt.2025-01-15");
|
|
631
|
+
expect(req!.searchParams.order).toBe("id.desc");
|
|
632
|
+
} finally {
|
|
633
|
+
api.stop();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("reports list --before accepts DD.MM.YYYY format", async () => {
|
|
638
|
+
const api = await startFakeApi();
|
|
639
|
+
try {
|
|
640
|
+
await runCliAsync(
|
|
641
|
+
["reports", "list", "--before", "15.01.2025"],
|
|
642
|
+
isolatedEnv({
|
|
643
|
+
PGAI_API_KEY: "test-key",
|
|
644
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
645
|
+
})
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const req = api.requests.find((x) =>
|
|
649
|
+
x.pathname.endsWith("/checkup_reports")
|
|
650
|
+
);
|
|
651
|
+
expect(req).toBeTruthy();
|
|
652
|
+
expect(req!.searchParams.created_at).toContain("lt.2025-01-15");
|
|
653
|
+
} finally {
|
|
654
|
+
api.stop();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("reports list --before rejects invalid date", async () => {
|
|
659
|
+
const r = runCli(
|
|
660
|
+
["reports", "list", "--before", "not-a-date"],
|
|
661
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
662
|
+
);
|
|
663
|
+
expect(r.status).toBe(1);
|
|
664
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Unrecognized date format");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("reports list --all --before rejects conflicting flags", () => {
|
|
668
|
+
const r = runCli(
|
|
669
|
+
["reports", "list", "--all", "--before", "2025-01-15"],
|
|
670
|
+
isolatedEnv({ PGAI_API_KEY: "test-key" })
|
|
671
|
+
);
|
|
672
|
+
expect(r.status).toBe(1);
|
|
673
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("--all and --before cannot be used together");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("reports data without --type defaults to md for terminal output", async () => {
|
|
677
|
+
const api = await startFakeApi();
|
|
678
|
+
try {
|
|
679
|
+
await runCliAsync(
|
|
680
|
+
["reports", "data", "1"],
|
|
681
|
+
isolatedEnv({
|
|
682
|
+
PGAI_API_KEY: "test-key",
|
|
683
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const req = api.requests.find((x) =>
|
|
688
|
+
x.pathname.endsWith("/checkup_report_file_data")
|
|
689
|
+
);
|
|
690
|
+
expect(req).toBeTruthy();
|
|
691
|
+
expect(req!.searchParams.type).toBe("eq.md");
|
|
692
|
+
} finally {
|
|
693
|
+
api.stop();
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("reports data --json without --type fetches all types", async () => {
|
|
698
|
+
const api = await startFakeApi();
|
|
699
|
+
try {
|
|
700
|
+
await runCliAsync(
|
|
701
|
+
["reports", "data", "1", "--json"],
|
|
702
|
+
isolatedEnv({
|
|
703
|
+
PGAI_API_KEY: "test-key",
|
|
704
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const req = api.requests.find((x) =>
|
|
709
|
+
x.pathname.endsWith("/checkup_report_file_data")
|
|
710
|
+
);
|
|
711
|
+
expect(req).toBeTruthy();
|
|
712
|
+
expect(req!.searchParams.type).toBeUndefined();
|
|
713
|
+
} finally {
|
|
714
|
+
api.stop();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("reports data --output strips path traversal from filenames", async () => {
|
|
719
|
+
// Start a fake API that returns a filename with path traversal
|
|
720
|
+
const traversalRequests: typeof Array.prototype = [];
|
|
721
|
+
const server = Bun.serve({
|
|
722
|
+
hostname: "127.0.0.1",
|
|
723
|
+
port: 0,
|
|
724
|
+
async fetch(req) {
|
|
725
|
+
const url = new URL(req.url);
|
|
726
|
+
if (url.pathname.endsWith("/checkup_report_file_data")) {
|
|
727
|
+
return new Response(
|
|
728
|
+
JSON.stringify([
|
|
729
|
+
{
|
|
730
|
+
id: 100,
|
|
731
|
+
checkup_report_id: 1,
|
|
732
|
+
filename: "../../etc/malicious.md",
|
|
733
|
+
check_id: "H002",
|
|
734
|
+
type: "md",
|
|
735
|
+
created_at: "2025-01-01T00:00:00Z",
|
|
736
|
+
created_formatted: "2025-01-01 00:00:00",
|
|
737
|
+
project_id: 10,
|
|
738
|
+
project_name: "TestProj",
|
|
739
|
+
data: "# Malicious content\n",
|
|
740
|
+
},
|
|
741
|
+
]),
|
|
742
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return new Response("not found", { status: 404 });
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const outDir = mkdtempSync(resolve(tmpdir(), "pgai-traversal-test-"));
|
|
751
|
+
const r = await runCliAsync(
|
|
752
|
+
["reports", "data", "1", "--output", outDir],
|
|
753
|
+
isolatedEnv({
|
|
754
|
+
PGAI_API_KEY: "test-key",
|
|
755
|
+
PGAI_API_BASE_URL: `http://${server.hostname}:${server.port}/api/general`,
|
|
756
|
+
})
|
|
757
|
+
);
|
|
758
|
+
expect(r.status).toBe(0);
|
|
759
|
+
|
|
760
|
+
// File should be saved as basename only, not the traversal path
|
|
761
|
+
expect(existsSync(resolve(outDir, "malicious.md"))).toBe(true);
|
|
762
|
+
// Traversal path should NOT exist
|
|
763
|
+
expect(existsSync(resolve(outDir, "..", "..", "etc", "malicious.md"))).toBe(false);
|
|
764
|
+
// Stdout should show the safe name
|
|
765
|
+
expect(r.stdout).toContain("malicious.md");
|
|
766
|
+
expect(r.stdout).not.toContain("../../");
|
|
767
|
+
} finally {
|
|
768
|
+
server.stop(true);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("reports list --all fetches all pages", async () => {
|
|
773
|
+
const api = await startFakeApi();
|
|
774
|
+
try {
|
|
775
|
+
const r = await runCliAsync(
|
|
776
|
+
["reports", "list", "--all"],
|
|
777
|
+
isolatedEnv({
|
|
778
|
+
PGAI_API_KEY: "test-key",
|
|
779
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
expect(r.status).toBe(0);
|
|
783
|
+
|
|
784
|
+
const out = JSON.parse(r.stdout.trim());
|
|
785
|
+
expect(Array.isArray(out)).toBe(true);
|
|
786
|
+
// The fake API always returns the same 1-item array, so --all will get 1 item
|
|
787
|
+
// (the page size > result count triggers stop)
|
|
788
|
+
expect(out.length).toBeGreaterThanOrEqual(1);
|
|
789
|
+
} finally {
|
|
790
|
+
api.stop();
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|