postgresai 0.16.0-rc.4 → 0.16.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/README.md +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
package/test/checkup.test.ts
DELETED
|
@@ -1,3626 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { resolve } from "path";
|
|
3
|
-
import type { Client } from "pg";
|
|
4
|
-
|
|
5
|
-
// Import from source directly since we're using Bun
|
|
6
|
-
import * as checkup from "../lib/checkup";
|
|
7
|
-
import * as api from "../lib/checkup-api";
|
|
8
|
-
import * as metricsLoader from "../lib/metrics-loader";
|
|
9
|
-
import { createMockClient } from "./test-utils";
|
|
10
|
-
|
|
11
|
-
const SUPPORTED_PG_VERSIONS = [
|
|
12
|
-
{ major: 13, minor: 16, versionNum: "130016" },
|
|
13
|
-
{ major: 14, minor: 12, versionNum: "140012" },
|
|
14
|
-
{ major: 15, minor: 7, versionNum: "150007" },
|
|
15
|
-
{ major: 16, minor: 3, versionNum: "160003" },
|
|
16
|
-
{ major: 17, minor: 2, versionNum: "170002" },
|
|
17
|
-
{ major: 18, minor: 0, versionNum: "180000" },
|
|
18
|
-
];
|
|
19
|
-
const SUPPORTED_PG_MAJOR_VERSIONS = SUPPORTED_PG_VERSIONS.map(({ major }) => major);
|
|
20
|
-
const SECONDS_PER_DAY = 86400;
|
|
21
|
-
const STATS_RESET_EPOCH = Date.UTC(2024, 0, 1) / 1000;
|
|
22
|
-
const DAYS_SINCE_RESET = 30;
|
|
23
|
-
const STATS_RESET_SECONDS_SINCE_RESET = DAYS_SINCE_RESET * SECONDS_PER_DAY;
|
|
24
|
-
const TEST_NOW_EPOCH = STATS_RESET_EPOCH + STATS_RESET_SECONDS_SINCE_RESET;
|
|
25
|
-
const POSTMASTER_STARTUP_EPOCH = TEST_NOW_EPOCH - STATS_RESET_SECONDS_SINCE_RESET;
|
|
26
|
-
const STATS_RESET_TIME = new Date(STATS_RESET_EPOCH * 1000).toISOString();
|
|
27
|
-
const formatPostgresTimestamp = (epochSeconds: number) =>
|
|
28
|
-
new Date(epochSeconds * 1000).toISOString().replace("T", " ").replace(".000Z", "+00");
|
|
29
|
-
const POSTMASTER_STARTUP_TIME = formatPostgresTimestamp(POSTMASTER_STARTUP_EPOCH);
|
|
30
|
-
const POSTMASTER_UPTIME_SECONDS = TEST_NOW_EPOCH - POSTMASTER_STARTUP_EPOCH;
|
|
31
|
-
|
|
32
|
-
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
33
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
34
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
35
|
-
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
36
|
-
env: { ...process.env, ...env },
|
|
37
|
-
});
|
|
38
|
-
return {
|
|
39
|
-
status: result.exitCode,
|
|
40
|
-
stdout: new TextDecoder().decode(result.stdout),
|
|
41
|
-
stderr: new TextDecoder().decode(result.stderr),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Async variant for tests that need an in-process fake API (Bun.serve):
|
|
46
|
-
// spawnSync would block the event loop and the fake server could never respond.
|
|
47
|
-
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
48
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
49
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
50
|
-
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
51
|
-
env: { ...process.env, ...env },
|
|
52
|
-
stdout: "pipe",
|
|
53
|
-
stderr: "pipe",
|
|
54
|
-
});
|
|
55
|
-
const [status, stdout, stderr] = await Promise.all([
|
|
56
|
-
proc.exited,
|
|
57
|
-
new Response(proc.stdout).text(),
|
|
58
|
-
new Response(proc.stderr).text(),
|
|
59
|
-
]);
|
|
60
|
-
return { status, stdout, stderr };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Unit tests for parseVersionNum
|
|
64
|
-
describe("parseVersionNum", () => {
|
|
65
|
-
test("parses PG 16.3 version number", () => {
|
|
66
|
-
const result = checkup.parseVersionNum("160003");
|
|
67
|
-
expect(result.major).toBe("16");
|
|
68
|
-
expect(result.minor).toBe("3");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("parses PG 15.7 version number", () => {
|
|
72
|
-
const result = checkup.parseVersionNum("150007");
|
|
73
|
-
expect(result.major).toBe("15");
|
|
74
|
-
expect(result.minor).toBe("7");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("parses PG 14.12 version number", () => {
|
|
78
|
-
const result = checkup.parseVersionNum("140012");
|
|
79
|
-
expect(result.major).toBe("14");
|
|
80
|
-
expect(result.minor).toBe("12");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("parses PG 13.16 version number", () => {
|
|
84
|
-
const result = checkup.parseVersionNum("130016");
|
|
85
|
-
expect(result.major).toBe("13");
|
|
86
|
-
expect(result.minor).toBe("16");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("parses PG 17.2 version number", () => {
|
|
90
|
-
const result = checkup.parseVersionNum("170002");
|
|
91
|
-
expect(result.major).toBe("17");
|
|
92
|
-
expect(result.minor).toBe("2");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("parses PG 18.0 version number", () => {
|
|
96
|
-
const result = checkup.parseVersionNum("180000");
|
|
97
|
-
expect(result.major).toBe("18");
|
|
98
|
-
expect(result.minor).toBe("0");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("handles empty string", () => {
|
|
102
|
-
const result = checkup.parseVersionNum("");
|
|
103
|
-
expect(result.major).toBe("");
|
|
104
|
-
expect(result.minor).toBe("");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("handles null/undefined", () => {
|
|
108
|
-
const result = checkup.parseVersionNum(null as any);
|
|
109
|
-
expect(result.major).toBe("");
|
|
110
|
-
expect(result.minor).toBe("");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("handles short string", () => {
|
|
114
|
-
const result = checkup.parseVersionNum("123");
|
|
115
|
-
expect(result.major).toBe("");
|
|
116
|
-
expect(result.minor).toBe("");
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Unit tests for createBaseReport
|
|
121
|
-
describe("createBaseReport", () => {
|
|
122
|
-
test("creates correct structure", () => {
|
|
123
|
-
const report = checkup.createBaseReport("A002", "Postgres major version", "test-node");
|
|
124
|
-
|
|
125
|
-
expect(report.checkId).toBe("A002");
|
|
126
|
-
expect(report.checkTitle).toBe("Postgres major version");
|
|
127
|
-
expect(typeof report.version).toBe("string");
|
|
128
|
-
expect(report.version!.length).toBeGreaterThan(0);
|
|
129
|
-
expect(typeof report.build_ts).toBe("string");
|
|
130
|
-
expect(report.nodes.primary).toBe("test-node");
|
|
131
|
-
expect(report.nodes.standbys).toEqual([]);
|
|
132
|
-
expect(report.results).toEqual({});
|
|
133
|
-
expect(typeof report.timestamptz).toBe("string");
|
|
134
|
-
// Verify timestamp is ISO format
|
|
135
|
-
expect(new Date(report.timestamptz).toISOString()).toBe(report.timestamptz);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("uses provided node name", () => {
|
|
139
|
-
const report = checkup.createBaseReport("A003", "Postgres settings", "my-custom-node");
|
|
140
|
-
expect(report.nodes.primary).toBe("my-custom-node");
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// Tests for CHECK_INFO
|
|
145
|
-
describe("CHECK_INFO and REPORT_GENERATORS", () => {
|
|
146
|
-
const expressCheckIds = Object.keys(checkup.REPORT_GENERATORS);
|
|
147
|
-
|
|
148
|
-
test("CHECK_INFO contains all express-mode checks", () => {
|
|
149
|
-
for (const checkId of expressCheckIds) {
|
|
150
|
-
expect(checkup.CHECK_INFO[checkId]).toBeDefined();
|
|
151
|
-
expect(typeof checkup.CHECK_INFO[checkId]).toBe("string");
|
|
152
|
-
expect(checkup.CHECK_INFO[checkId].length).toBeGreaterThan(0);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("CHECK_INFO titles are loaded from embedded dictionary", () => {
|
|
157
|
-
// Verify a few known titles match the API dictionary
|
|
158
|
-
// These are canonical titles from postgres.ai/api/general/checkup_dictionary
|
|
159
|
-
expect(checkup.CHECK_INFO["A002"]).toBe("Postgres major version");
|
|
160
|
-
expect(checkup.CHECK_INFO["H001"]).toBe("Invalid indexes");
|
|
161
|
-
expect(checkup.CHECK_INFO["H002"]).toBe("Unused indexes");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("REPORT_GENERATORS has function for each check", () => {
|
|
165
|
-
for (const checkId of expressCheckIds) {
|
|
166
|
-
expect(typeof checkup.REPORT_GENERATORS[checkId]).toBe("function");
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("REPORT_GENERATORS and CHECK_INFO have same keys", () => {
|
|
171
|
-
const generatorKeys = Object.keys(checkup.REPORT_GENERATORS).sort();
|
|
172
|
-
const infoKeys = Object.keys(checkup.CHECK_INFO).sort();
|
|
173
|
-
expect(generatorKeys).toEqual(infoKeys);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// Tests for formatBytes
|
|
178
|
-
describe("formatBytes", () => {
|
|
179
|
-
test("formats zero bytes", () => {
|
|
180
|
-
expect(checkup.formatBytes(0)).toBe("0 B");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("formats bytes", () => {
|
|
184
|
-
expect(checkup.formatBytes(500)).toBe("500.00 B");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("formats kibibytes", () => {
|
|
188
|
-
expect(checkup.formatBytes(1024)).toBe("1.00 KiB");
|
|
189
|
-
expect(checkup.formatBytes(1536)).toBe("1.50 KiB");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("formats mebibytes", () => {
|
|
193
|
-
expect(checkup.formatBytes(1048576)).toBe("1.00 MiB");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("formats gibibytes", () => {
|
|
197
|
-
expect(checkup.formatBytes(1073741824)).toBe("1.00 GiB");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("handles negative bytes", () => {
|
|
201
|
-
expect(checkup.formatBytes(-1024)).toBe("-1.00 KiB");
|
|
202
|
-
expect(checkup.formatBytes(-1048576)).toBe("-1.00 MiB");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("handles edge cases", () => {
|
|
206
|
-
expect(checkup.formatBytes(NaN)).toBe("NaN B");
|
|
207
|
-
expect(checkup.formatBytes(Infinity)).toBe("Infinity B");
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
function createI001MockClient(options: {
|
|
212
|
-
versionRows?: any[];
|
|
213
|
-
ioRows?: any[];
|
|
214
|
-
ioError?: boolean;
|
|
215
|
-
resetRows?: any[];
|
|
216
|
-
resetError?: boolean;
|
|
217
|
-
} = {}) {
|
|
218
|
-
const queries: string[] = [];
|
|
219
|
-
const {
|
|
220
|
-
versionRows = [
|
|
221
|
-
{ name: "server_version", setting: "16.3" },
|
|
222
|
-
{ name: "server_version_num", setting: "160003" },
|
|
223
|
-
],
|
|
224
|
-
ioRows = [],
|
|
225
|
-
ioError = false,
|
|
226
|
-
resetRows = [{ stats_reset_s: "86400" }],
|
|
227
|
-
resetError = false,
|
|
228
|
-
} = options;
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
queries,
|
|
232
|
-
query: async (sql: string) => {
|
|
233
|
-
queries.push(sql);
|
|
234
|
-
if (sql.includes("server_version") && sql.includes("server_version_num") && sql.includes("pg_settings")) {
|
|
235
|
-
return { rows: versionRows };
|
|
236
|
-
}
|
|
237
|
-
if (sql.includes("pg_stat_io") && sql.includes("rollup")) {
|
|
238
|
-
if (ioError) {
|
|
239
|
-
throw new Error("I/O statistics unavailable");
|
|
240
|
-
}
|
|
241
|
-
return { rows: ioRows };
|
|
242
|
-
}
|
|
243
|
-
if (sql.includes("pg_stat_io") && sql.includes("stats_reset_s")) {
|
|
244
|
-
if (resetError) {
|
|
245
|
-
throw new Error("stats reset unavailable");
|
|
246
|
-
}
|
|
247
|
-
return { rows: resetRows };
|
|
248
|
-
}
|
|
249
|
-
throw new Error(`Unexpected query: ${sql}`);
|
|
250
|
-
},
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const i001Rows = [
|
|
255
|
-
{
|
|
256
|
-
tag_backend_type: "client backend",
|
|
257
|
-
reads: "1000",
|
|
258
|
-
read_bytes_mb: "100",
|
|
259
|
-
read_time_ms: "500",
|
|
260
|
-
writes: "200",
|
|
261
|
-
write_bytes_mb: "50",
|
|
262
|
-
write_time_ms: "100",
|
|
263
|
-
writebacks: "20",
|
|
264
|
-
writeback_bytes_mb: "2",
|
|
265
|
-
writeback_time_ms: "4",
|
|
266
|
-
fsyncs: "1",
|
|
267
|
-
fsync_time_ms: "3",
|
|
268
|
-
extends: "6",
|
|
269
|
-
extend_bytes_mb: "8",
|
|
270
|
-
hits: "5000",
|
|
271
|
-
evictions: "3",
|
|
272
|
-
reuses: "7",
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
tag_backend_type: "total",
|
|
276
|
-
reads: "1500",
|
|
277
|
-
read_bytes_mb: "150",
|
|
278
|
-
read_time_ms: "750",
|
|
279
|
-
writes: "300",
|
|
280
|
-
write_bytes_mb: "75",
|
|
281
|
-
write_time_ms: "150",
|
|
282
|
-
writebacks: "30",
|
|
283
|
-
writeback_bytes_mb: "3",
|
|
284
|
-
writeback_time_ms: "6",
|
|
285
|
-
fsyncs: "2",
|
|
286
|
-
fsync_time_ms: "5",
|
|
287
|
-
extends: "9",
|
|
288
|
-
extend_bytes_mb: "12",
|
|
289
|
-
hits: "7500",
|
|
290
|
-
evictions: "4",
|
|
291
|
-
reuses: "11",
|
|
292
|
-
},
|
|
293
|
-
];
|
|
294
|
-
|
|
295
|
-
// Mock client tests for report generators
|
|
296
|
-
describe("Report generators with mock client", () => {
|
|
297
|
-
test("getPostgresVersion extracts version info", async () => {
|
|
298
|
-
const mockClient = createMockClient({
|
|
299
|
-
versionRows: [
|
|
300
|
-
{ name: "server_version", setting: "16.3" },
|
|
301
|
-
{ name: "server_version_num", setting: "160003" },
|
|
302
|
-
],
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const version = await checkup.getPostgresVersion(mockClient as any);
|
|
306
|
-
expect(version.version).toBe("16.3");
|
|
307
|
-
expect(version.server_version_num).toBe("160003");
|
|
308
|
-
expect(version.server_major_ver).toBe("16");
|
|
309
|
-
expect(version.server_minor_ver).toBe("3");
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
test("getIOStatistics returns empty for PostgreSQL versions before 16", async () => {
|
|
313
|
-
const mockClient = createI001MockClient({ ioRows: i001Rows });
|
|
314
|
-
|
|
315
|
-
const defaultStats = await checkup.getIOStatistics(mockClient as any);
|
|
316
|
-
const stats = await checkup.getIOStatistics(mockClient as any, 15);
|
|
317
|
-
|
|
318
|
-
expect(defaultStats).toEqual([]);
|
|
319
|
-
expect(stats).toEqual([]);
|
|
320
|
-
expect(mockClient.queries).toEqual([]);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
test("getIOStatistics skips placeholder SQL without querying", async () => {
|
|
324
|
-
const mockClient = createI001MockClient({ ioRows: i001Rows });
|
|
325
|
-
|
|
326
|
-
const stats = await checkup.getIOStatistics(mockClient as any, 16, "; -- pg_stat_io unavailable");
|
|
327
|
-
const barePlaceholderStats = await checkup.getIOStatistics(mockClient as any, 16, ";");
|
|
328
|
-
|
|
329
|
-
const whitespacePlaceholderStats = await checkup.getIOStatistics(mockClient as any, 16, " ; -- whitespace prefix");
|
|
330
|
-
|
|
331
|
-
expect(stats).toEqual([]);
|
|
332
|
-
expect(barePlaceholderStats).toEqual([]);
|
|
333
|
-
expect(whitespacePlaceholderStats).toEqual([]);
|
|
334
|
-
expect(mockClient.queries).toEqual([]);
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
test("getIOStatistics returns empty when the SQL result has no rows", async () => {
|
|
338
|
-
const mockClient = createI001MockClient({ ioRows: [] });
|
|
339
|
-
|
|
340
|
-
const stats = await checkup.getIOStatistics(mockClient as any, 16);
|
|
341
|
-
|
|
342
|
-
expect(stats).toEqual([]);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test("getIOStatistics catches primary query errors", async () => {
|
|
346
|
-
const mockClient = createI001MockClient({ ioError: true, resetRows: [] });
|
|
347
|
-
|
|
348
|
-
const stats = await checkup.getIOStatistics(mockClient as any, 16);
|
|
349
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
350
|
-
|
|
351
|
-
expect(stats).toEqual([]);
|
|
352
|
-
expect(report.results["node-01"].data).toEqual({
|
|
353
|
-
available: false,
|
|
354
|
-
by_backend_type: [],
|
|
355
|
-
analysis: {
|
|
356
|
-
total_read_mb: 0,
|
|
357
|
-
total_write_mb: 0,
|
|
358
|
-
total_io_time_ms: 0,
|
|
359
|
-
read_hit_ratio_pct: 0,
|
|
360
|
-
avg_read_time_ms: null,
|
|
361
|
-
avg_write_time_ms: null,
|
|
362
|
-
},
|
|
363
|
-
stats_reset_s: null,
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("getIOStatistics maps backend rows including extension bytes", async () => {
|
|
368
|
-
const mockClient = createI001MockClient({ ioRows: i001Rows });
|
|
369
|
-
|
|
370
|
-
const stats = await checkup.getIOStatistics(mockClient as any, 16);
|
|
371
|
-
|
|
372
|
-
expect(stats).toHaveLength(2);
|
|
373
|
-
expect(stats[0]).toMatchObject({
|
|
374
|
-
backend_type: "client backend",
|
|
375
|
-
reads: 1000,
|
|
376
|
-
read_bytes_mb: 100,
|
|
377
|
-
writes: 200,
|
|
378
|
-
write_bytes_mb: 50,
|
|
379
|
-
writebacks: 20,
|
|
380
|
-
writeback_bytes_mb: 2,
|
|
381
|
-
fsyncs: 1,
|
|
382
|
-
extends: 6,
|
|
383
|
-
extend_bytes_mb: 8,
|
|
384
|
-
hits: 5000,
|
|
385
|
-
evictions: 3,
|
|
386
|
-
reuses: 7,
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("generateI001 keeps report available when stats_reset query fails", async () => {
|
|
391
|
-
const mockClient = createI001MockClient({ ioRows: i001Rows, resetError: true });
|
|
392
|
-
|
|
393
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
394
|
-
const data = report.results["node-01"].data;
|
|
395
|
-
|
|
396
|
-
expect(report.checkId).toBe("I001");
|
|
397
|
-
expect(data.available).toBe(true);
|
|
398
|
-
expect(data.stats_reset_s).toBeNull();
|
|
399
|
-
expect(data.by_backend_type.map((row: any) => row.backend_type)).toEqual(["total", "client backend"]);
|
|
400
|
-
expect(data.by_backend_type[0].extend_bytes_mb).toBe(12);
|
|
401
|
-
expect(data.analysis.read_hit_ratio_pct).toBe(83.33);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("generateI001 dispatches version-specific pg_stat_io SQL", async () => {
|
|
405
|
-
const pg16Client = createI001MockClient({ ioRows: i001Rows });
|
|
406
|
-
const pg18Client = createI001MockClient({
|
|
407
|
-
versionRows: [
|
|
408
|
-
{ name: "server_version", setting: "18.0" },
|
|
409
|
-
{ name: "server_version_num", setting: "180000" },
|
|
410
|
-
],
|
|
411
|
-
ioRows: i001Rows,
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
await checkup.REPORT_GENERATORS.I001(pg16Client as any, "node-01");
|
|
415
|
-
await checkup.REPORT_GENERATORS.I001(pg18Client as any, "node-01");
|
|
416
|
-
|
|
417
|
-
const pg16MetricSql = pg16Client.queries.find((sql) => sql.includes("pg_stat_io") && sql.includes("rollup"));
|
|
418
|
-
const pg18MetricSql = pg18Client.queries.find((sql) => sql.includes("pg_stat_io") && sql.includes("rollup"));
|
|
419
|
-
|
|
420
|
-
expect(pg16MetricSql).toContain("sum(coalesce(reads, 0) * op_bytes)");
|
|
421
|
-
expect(pg16MetricSql).toContain("sum(coalesce(extends, 0) * op_bytes)");
|
|
422
|
-
expect(pg18MetricSql).toContain("sum(coalesce(read_bytes, 0))");
|
|
423
|
-
expect(pg18MetricSql).toContain("sum(coalesce(extend_bytes, 0))");
|
|
424
|
-
// PG18 removed op_bytes entirely; writeback bytes cannot be derived, so the SQL emits a constant 0.
|
|
425
|
-
expect(pg18MetricSql).toContain("0::int8 as writeback_bytes_mb");
|
|
426
|
-
// Regression guard: any op_bytes reference in the PG18 branch makes the whole query
|
|
427
|
-
// fail with `column "op_bytes" does not exist`, degrading all of I001.
|
|
428
|
-
expect(pg18MetricSql).not.toContain("op_bytes");
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("generateI001 returns unavailable on PostgreSQL 16 when ioStats are empty", async () => {
|
|
432
|
-
const mockClient = createI001MockClient({ ioRows: [], resetRows: [] });
|
|
433
|
-
|
|
434
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
435
|
-
const data = report.results["node-01"].data;
|
|
436
|
-
|
|
437
|
-
expect(data.available).toBe(false);
|
|
438
|
-
expect(data.by_backend_type).toEqual([]);
|
|
439
|
-
expect(data.stats_reset_s).toBeNull();
|
|
440
|
-
expect(data.analysis).toEqual({
|
|
441
|
-
total_read_mb: 0,
|
|
442
|
-
total_write_mb: 0,
|
|
443
|
-
total_io_time_ms: 0,
|
|
444
|
-
read_hit_ratio_pct: 0,
|
|
445
|
-
avg_read_time_ms: null,
|
|
446
|
-
avg_write_time_ms: null,
|
|
447
|
-
});
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
test("generateI001 returns unavailable on PostgreSQL before 16 without querying pg_stat_io", async () => {
|
|
451
|
-
const mockClient = createI001MockClient({
|
|
452
|
-
versionRows: [
|
|
453
|
-
{ name: "server_version", setting: "15.4" },
|
|
454
|
-
{ name: "server_version_num", setting: "150004" },
|
|
455
|
-
],
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
459
|
-
const data = report.results["node-01"].data;
|
|
460
|
-
|
|
461
|
-
expect(data.available).toBe(false);
|
|
462
|
-
expect(data.min_version_required).toBe("16");
|
|
463
|
-
expect(data.by_backend_type).toEqual([]);
|
|
464
|
-
expect(mockClient.queries.every((sql) => !sql.includes("pg_stat_io"))).toBe(true);
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
test("generateI001 handles unknown PostgreSQL version as unavailable", async () => {
|
|
468
|
-
const mockClient = createI001MockClient({
|
|
469
|
-
versionRows: [
|
|
470
|
-
{ name: "server_version", setting: "unknown" },
|
|
471
|
-
{ name: "server_version_num", setting: "unknown" },
|
|
472
|
-
],
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
476
|
-
const data = report.results["node-01"].data;
|
|
477
|
-
|
|
478
|
-
expect(data.available).toBe(false);
|
|
479
|
-
expect(data.min_version_required).toBe("16");
|
|
480
|
-
expect(data.by_backend_type).toEqual([]);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
test("generateI001 handles zero-request hit ratio and empty averages", async () => {
|
|
484
|
-
const mockClient = createI001MockClient({
|
|
485
|
-
ioRows: [{
|
|
486
|
-
tag_backend_type: "total",
|
|
487
|
-
reads: "0",
|
|
488
|
-
read_bytes_mb: "0",
|
|
489
|
-
read_time_ms: "0",
|
|
490
|
-
writes: "0",
|
|
491
|
-
write_bytes_mb: "0",
|
|
492
|
-
write_time_ms: "0",
|
|
493
|
-
writebacks: "0",
|
|
494
|
-
writeback_bytes_mb: "0",
|
|
495
|
-
writeback_time_ms: "0",
|
|
496
|
-
fsyncs: "0",
|
|
497
|
-
fsync_time_ms: "0",
|
|
498
|
-
extends: "0",
|
|
499
|
-
extend_bytes_mb: "0",
|
|
500
|
-
hits: "0",
|
|
501
|
-
evictions: "0",
|
|
502
|
-
reuses: "0",
|
|
503
|
-
}],
|
|
504
|
-
resetRows: [],
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
const report = await checkup.REPORT_GENERATORS.I001(mockClient as any, "node-01");
|
|
508
|
-
const data = report.results["node-01"].data;
|
|
509
|
-
const analysis = data.analysis;
|
|
510
|
-
|
|
511
|
-
expect(data.available).toBe(true);
|
|
512
|
-
expect(data.stats_reset_s).toBeNull();
|
|
513
|
-
expect(analysis.read_hit_ratio_pct).toBe(0);
|
|
514
|
-
expect(analysis.avg_read_time_ms).toBeNull();
|
|
515
|
-
expect(analysis.avg_write_time_ms).toBeNull();
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("getSettings transforms rows to keyed object", async () => {
|
|
519
|
-
const mockClient = createMockClient({
|
|
520
|
-
settingsRows: [
|
|
521
|
-
{
|
|
522
|
-
tag_setting_name: "shared_buffers",
|
|
523
|
-
tag_setting_value: "16384",
|
|
524
|
-
tag_unit: "8kB",
|
|
525
|
-
tag_category: "Resource Usage / Memory",
|
|
526
|
-
tag_vartype: "integer",
|
|
527
|
-
is_default: 1,
|
|
528
|
-
setting_normalized: "134217728", // 16384 * 8192
|
|
529
|
-
unit_normalized: "bytes",
|
|
530
|
-
},
|
|
531
|
-
{
|
|
532
|
-
tag_setting_name: "work_mem",
|
|
533
|
-
tag_setting_value: "4096",
|
|
534
|
-
tag_unit: "kB",
|
|
535
|
-
tag_category: "Resource Usage / Memory",
|
|
536
|
-
tag_vartype: "integer",
|
|
537
|
-
is_default: 1,
|
|
538
|
-
setting_normalized: "4194304", // 4096 * 1024
|
|
539
|
-
unit_normalized: "bytes",
|
|
540
|
-
},
|
|
541
|
-
],
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
const settings = await checkup.getSettings(mockClient as any);
|
|
545
|
-
expect("shared_buffers" in settings).toBe(true);
|
|
546
|
-
expect("work_mem" in settings).toBe(true);
|
|
547
|
-
expect(settings.shared_buffers.setting).toBe("16384");
|
|
548
|
-
expect(settings.shared_buffers.unit).toBe("8kB");
|
|
549
|
-
// pretty_value is now computed from setting_normalized
|
|
550
|
-
expect(settings.shared_buffers.pretty_value).toBe("128.00 MiB");
|
|
551
|
-
expect(settings.work_mem.pretty_value).toBe("4.00 MiB");
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
test("generateA002 creates report with version data", async () => {
|
|
555
|
-
const mockClient = createMockClient({
|
|
556
|
-
versionRows: [
|
|
557
|
-
{ name: "server_version", setting: "16.3" },
|
|
558
|
-
{ name: "server_version_num", setting: "160003" },
|
|
559
|
-
],
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
const report = await checkup.generateA002(mockClient as any, "test-node");
|
|
563
|
-
expect(report.checkId).toBe("A002");
|
|
564
|
-
expect(report.checkTitle).toBe("Postgres major version");
|
|
565
|
-
expect(report.nodes.primary).toBe("test-node");
|
|
566
|
-
expect("test-node" in report.results).toBe(true);
|
|
567
|
-
expect("version" in report.results["test-node"].data).toBe(true);
|
|
568
|
-
expect(report.results["test-node"].data.version.version).toBe("16.3");
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
test("generateA003 creates report with settings and version", async () => {
|
|
572
|
-
const mockClient = createMockClient({
|
|
573
|
-
versionRows: [
|
|
574
|
-
{ name: "server_version", setting: "16.3" },
|
|
575
|
-
{ name: "server_version_num", setting: "160003" },
|
|
576
|
-
],
|
|
577
|
-
settingsRows: [
|
|
578
|
-
{
|
|
579
|
-
tag_setting_name: "shared_buffers",
|
|
580
|
-
tag_setting_value: "16384",
|
|
581
|
-
tag_unit: "8kB",
|
|
582
|
-
tag_category: "Resource Usage / Memory",
|
|
583
|
-
tag_vartype: "integer",
|
|
584
|
-
is_default: 1,
|
|
585
|
-
setting_normalized: "134217728",
|
|
586
|
-
unit_normalized: "bytes",
|
|
587
|
-
},
|
|
588
|
-
],
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
const report = await checkup.generateA003(mockClient as any, "test-node");
|
|
592
|
-
expect(report.checkId).toBe("A003");
|
|
593
|
-
expect(report.checkTitle).toBe("Postgres settings");
|
|
594
|
-
expect("test-node" in report.results).toBe(true);
|
|
595
|
-
expect("shared_buffers" in report.results["test-node"].data).toBe(true);
|
|
596
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
597
|
-
expect(report.results["test-node"].postgres_version!.version).toBe("16.3");
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
test("generateA013 creates report with minor version data", async () => {
|
|
601
|
-
const mockClient = createMockClient({
|
|
602
|
-
versionRows: [
|
|
603
|
-
{ name: "server_version", setting: "16.3" },
|
|
604
|
-
{ name: "server_version_num", setting: "160003" },
|
|
605
|
-
],
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
const report = await checkup.generateA013(mockClient as any, "test-node");
|
|
609
|
-
expect(report.checkId).toBe("A013");
|
|
610
|
-
expect(report.checkTitle).toBe("Postgres minor version");
|
|
611
|
-
expect(report.nodes.primary).toBe("test-node");
|
|
612
|
-
expect("test-node" in report.results).toBe(true);
|
|
613
|
-
expect("version" in report.results["test-node"].data).toBe(true);
|
|
614
|
-
expect(report.results["test-node"].data.version.server_minor_ver).toBe("3");
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
test("generateAllReports returns reports for all checks", async () => {
|
|
618
|
-
const mockClient = createMockClient({
|
|
619
|
-
versionRows: [
|
|
620
|
-
{ name: "server_version", setting: "16.3" },
|
|
621
|
-
{ name: "server_version_num", setting: "160003" },
|
|
622
|
-
],
|
|
623
|
-
settingsRows: [
|
|
624
|
-
{
|
|
625
|
-
tag_setting_name: "shared_buffers",
|
|
626
|
-
tag_setting_value: "16384",
|
|
627
|
-
tag_unit: "8kB",
|
|
628
|
-
tag_category: "Resource Usage / Memory",
|
|
629
|
-
tag_vartype: "integer",
|
|
630
|
-
is_default: 0, // Non-default for A007
|
|
631
|
-
setting_normalized: "134217728",
|
|
632
|
-
unit_normalized: "bytes",
|
|
633
|
-
},
|
|
634
|
-
],
|
|
635
|
-
databaseSizesRows: [{ datname: "postgres", size_bytes: "1073741824" }],
|
|
636
|
-
dbStatsRows: [{
|
|
637
|
-
numbackends: 5,
|
|
638
|
-
xact_commit: 100,
|
|
639
|
-
xact_rollback: 1,
|
|
640
|
-
blks_read: 1000,
|
|
641
|
-
blks_hit: 9000,
|
|
642
|
-
tup_returned: 500,
|
|
643
|
-
tup_fetched: 400,
|
|
644
|
-
tup_inserted: 50,
|
|
645
|
-
tup_updated: 30,
|
|
646
|
-
tup_deleted: 10,
|
|
647
|
-
deadlocks: 0,
|
|
648
|
-
temp_files: 0,
|
|
649
|
-
temp_bytes: 0,
|
|
650
|
-
postmaster_uptime_s: 864000
|
|
651
|
-
}],
|
|
652
|
-
connectionStatesRows: [{ state: "active", count: 2 }, { state: "idle", count: 3 }],
|
|
653
|
-
uptimeRows: [{ start_time: new Date("2024-01-01T00:00:00Z"), uptime: "10 days" }],
|
|
654
|
-
invalidIndexesRows: [],
|
|
655
|
-
unusedIndexesRows: [],
|
|
656
|
-
redundantIndexesRows: [],
|
|
657
|
-
sensitiveColumnsRows: [],
|
|
658
|
-
}
|
|
659
|
-
);
|
|
660
|
-
|
|
661
|
-
const reports = await checkup.generateAllReports(mockClient as any, "test-node");
|
|
662
|
-
expect("A002" in reports).toBe(true);
|
|
663
|
-
expect("A003" in reports).toBe(true);
|
|
664
|
-
expect("A004" in reports).toBe(true);
|
|
665
|
-
expect("A007" in reports).toBe(true);
|
|
666
|
-
expect("A013" in reports).toBe(true);
|
|
667
|
-
expect("H001" in reports).toBe(true);
|
|
668
|
-
expect("H002" in reports).toBe(true);
|
|
669
|
-
expect("H004" in reports).toBe(true);
|
|
670
|
-
// S001 is only available in Python reporter, not in CLI express mode
|
|
671
|
-
expect(reports.A002.checkId).toBe("A002");
|
|
672
|
-
expect(reports.A003.checkId).toBe("A003");
|
|
673
|
-
expect(reports.A004.checkId).toBe("A004");
|
|
674
|
-
expect(reports.A007.checkId).toBe("A007");
|
|
675
|
-
expect(reports.A013.checkId).toBe("A013");
|
|
676
|
-
expect(reports.H001.checkId).toBe("H001");
|
|
677
|
-
expect(reports.H002.checkId).toBe("H002");
|
|
678
|
-
expect(reports.H004.checkId).toBe("H004");
|
|
679
|
-
});
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// Tests for A007 (Altered settings)
|
|
683
|
-
describe("A007 - Altered settings", () => {
|
|
684
|
-
test("getAlteredSettings returns non-default settings", async () => {
|
|
685
|
-
const mockClient = createMockClient({
|
|
686
|
-
settingsRows: [
|
|
687
|
-
{ tag_setting_name: "shared_buffers", tag_setting_value: "256MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 0, setting_normalized: null, unit_normalized: null },
|
|
688
|
-
{ tag_setting_name: "work_mem", tag_setting_value: "64MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 0, setting_normalized: null, unit_normalized: null },
|
|
689
|
-
{ tag_setting_name: "default_setting", tag_setting_value: "on", tag_unit: "", tag_category: "Other", tag_vartype: "bool", is_default: 1, setting_normalized: null, unit_normalized: null },
|
|
690
|
-
],
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
const settings = await checkup.getAlteredSettings(mockClient as any);
|
|
694
|
-
expect("shared_buffers" in settings).toBe(true);
|
|
695
|
-
expect("work_mem" in settings).toBe(true);
|
|
696
|
-
expect("default_setting" in settings).toBe(false); // Should be filtered out
|
|
697
|
-
expect(settings.shared_buffers.value).toBe("256MB");
|
|
698
|
-
expect(settings.work_mem.value).toBe("64MB");
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
test("generateA007 creates report with altered settings", async () => {
|
|
702
|
-
const mockClient = createMockClient({
|
|
703
|
-
versionRows: [
|
|
704
|
-
{ name: "server_version", setting: "16.3" },
|
|
705
|
-
{ name: "server_version_num", setting: "160003" },
|
|
706
|
-
],
|
|
707
|
-
settingsRows: [
|
|
708
|
-
{ tag_setting_name: "max_connections", tag_setting_value: "200", tag_unit: "", tag_category: "Connections and Authentication", tag_vartype: "integer", is_default: 0, setting_normalized: null, unit_normalized: null },
|
|
709
|
-
],
|
|
710
|
-
}
|
|
711
|
-
);
|
|
712
|
-
|
|
713
|
-
const report = await checkup.generateA007(mockClient as any, "test-node");
|
|
714
|
-
expect(report.checkId).toBe("A007");
|
|
715
|
-
expect(report.checkTitle).toBe("Altered settings");
|
|
716
|
-
expect(report.nodes.primary).toBe("test-node");
|
|
717
|
-
expect("test-node" in report.results).toBe(true);
|
|
718
|
-
expect("max_connections" in report.results["test-node"].data).toBe(true);
|
|
719
|
-
expect(report.results["test-node"].data.max_connections.value).toBe("200");
|
|
720
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
// Tests for A004 (Cluster information)
|
|
725
|
-
describe("A004 - Cluster information", () => {
|
|
726
|
-
test("getDatabaseSizes returns database sizes", async () => {
|
|
727
|
-
const mockClient = createMockClient({
|
|
728
|
-
databaseSizesRows: [
|
|
729
|
-
{ datname: "postgres", size_bytes: "1073741824" },
|
|
730
|
-
{ datname: "mydb", size_bytes: "536870912" },
|
|
731
|
-
],
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
const sizes = await checkup.getDatabaseSizes(mockClient as any);
|
|
735
|
-
expect("postgres" in sizes).toBe(true);
|
|
736
|
-
expect("mydb" in sizes).toBe(true);
|
|
737
|
-
expect(sizes.postgres).toBe(1073741824);
|
|
738
|
-
expect(sizes.mydb).toBe(536870912);
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
test("getClusterInfo returns cluster metrics", async () => {
|
|
742
|
-
const mockClient = createMockClient({
|
|
743
|
-
dbStatsRows: [{
|
|
744
|
-
numbackends: 10,
|
|
745
|
-
xact_commit: 1000,
|
|
746
|
-
xact_rollback: 5,
|
|
747
|
-
blks_read: 500,
|
|
748
|
-
blks_hit: 9500,
|
|
749
|
-
tup_returned: 5000,
|
|
750
|
-
tup_fetched: 4000,
|
|
751
|
-
tup_inserted: 100,
|
|
752
|
-
tup_updated: 50,
|
|
753
|
-
tup_deleted: 25,
|
|
754
|
-
deadlocks: 0,
|
|
755
|
-
temp_files: 2,
|
|
756
|
-
temp_bytes: 1048576,
|
|
757
|
-
postmaster_uptime_s: 2592000, // 30 days
|
|
758
|
-
}],
|
|
759
|
-
connectionStatesRows: [
|
|
760
|
-
{ state: "active", count: 3 },
|
|
761
|
-
{ state: "idle", count: 7 },
|
|
762
|
-
],
|
|
763
|
-
uptimeRows: [{
|
|
764
|
-
start_time: new Date("2024-01-01T00:00:00Z"),
|
|
765
|
-
uptime: "30 days",
|
|
766
|
-
}],
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
const info = await checkup.getClusterInfo(mockClient as any);
|
|
770
|
-
expect("total_connections" in info).toBe(true);
|
|
771
|
-
expect("cache_hit_ratio" in info).toBe(true);
|
|
772
|
-
expect("connections_active" in info).toBe(true);
|
|
773
|
-
expect("connections_idle" in info).toBe(true);
|
|
774
|
-
expect("start_time" in info).toBe(true);
|
|
775
|
-
expect(info.total_connections.value).toBe("10");
|
|
776
|
-
expect(info.cache_hit_ratio.value).toBe("95.00");
|
|
777
|
-
expect(info.connections_active.value).toBe("3");
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
test("generateA004 creates report with cluster info and database sizes", async () => {
|
|
781
|
-
const mockClient = createMockClient({
|
|
782
|
-
versionRows: [
|
|
783
|
-
{ name: "server_version", setting: "16.3" },
|
|
784
|
-
{ name: "server_version_num", setting: "160003" },
|
|
785
|
-
],
|
|
786
|
-
databaseSizesRows: [
|
|
787
|
-
{ datname: "postgres", size_bytes: "1073741824" },
|
|
788
|
-
],
|
|
789
|
-
dbStatsRows: [{
|
|
790
|
-
numbackends: 5,
|
|
791
|
-
xact_commit: 100,
|
|
792
|
-
xact_rollback: 1,
|
|
793
|
-
blks_read: 100,
|
|
794
|
-
blks_hit: 900,
|
|
795
|
-
tup_returned: 500,
|
|
796
|
-
tup_fetched: 400,
|
|
797
|
-
tup_inserted: 50,
|
|
798
|
-
tup_updated: 30,
|
|
799
|
-
tup_deleted: 10,
|
|
800
|
-
deadlocks: 0,
|
|
801
|
-
temp_files: 0,
|
|
802
|
-
temp_bytes: 0,
|
|
803
|
-
postmaster_uptime_s: 864000,
|
|
804
|
-
}],
|
|
805
|
-
connectionStatesRows: [{ state: "active", count: 2 }],
|
|
806
|
-
uptimeRows: [{ start_time: new Date("2024-01-01T00:00:00Z"), uptime: "10 days" }],
|
|
807
|
-
}
|
|
808
|
-
);
|
|
809
|
-
|
|
810
|
-
const report = await checkup.generateA004(mockClient as any, "test-node");
|
|
811
|
-
expect(report.checkId).toBe("A004");
|
|
812
|
-
expect(report.checkTitle).toBe("Cluster information");
|
|
813
|
-
expect(report.nodes.primary).toBe("test-node");
|
|
814
|
-
expect("test-node" in report.results).toBe(true);
|
|
815
|
-
|
|
816
|
-
const data = report.results["test-node"].data;
|
|
817
|
-
expect("general_info" in data).toBe(true);
|
|
818
|
-
expect("database_sizes" in data).toBe(true);
|
|
819
|
-
expect("total_connections" in data.general_info).toBe(true);
|
|
820
|
-
expect("postgres" in data.database_sizes).toBe(true);
|
|
821
|
-
expect(data.database_sizes.postgres).toBe(1073741824);
|
|
822
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
823
|
-
});
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
// Tests for H001 (Invalid indexes)
|
|
827
|
-
describe("H001 - Invalid indexes", () => {
|
|
828
|
-
test("getInvalidIndexes returns invalid indexes", async () => {
|
|
829
|
-
const mockClient = createMockClient({
|
|
830
|
-
invalidIndexesRows: [
|
|
831
|
-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
|
|
832
|
-
],
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
const indexes = await checkup.getInvalidIndexes(mockClient as any);
|
|
836
|
-
expect(indexes.length).toBe(1);
|
|
837
|
-
expect(indexes[0].schema_name).toBe("public");
|
|
838
|
-
expect(indexes[0].table_name).toBe("users");
|
|
839
|
-
expect(indexes[0].index_name).toBe("users_email_idx");
|
|
840
|
-
expect(indexes[0].index_size_bytes).toBe(1048576);
|
|
841
|
-
expect(indexes[0].index_size_pretty).toBeTruthy();
|
|
842
|
-
expect(indexes[0].index_definition).toMatch(/^CREATE INDEX/);
|
|
843
|
-
expect(indexes[0].relation_name).toBe("users");
|
|
844
|
-
expect(indexes[0].supports_fk).toBe(false);
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
test("generateH001 creates report with invalid indexes", async () => {
|
|
848
|
-
const mockClient = createMockClient({
|
|
849
|
-
versionRows: [
|
|
850
|
-
{ name: "server_version", setting: "16.3" },
|
|
851
|
-
{ name: "server_version_num", setting: "160003" },
|
|
852
|
-
],
|
|
853
|
-
invalidIndexesRows: [
|
|
854
|
-
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)", supports_fk: false },
|
|
855
|
-
],
|
|
856
|
-
}
|
|
857
|
-
);
|
|
858
|
-
|
|
859
|
-
const report = await checkup.generateH001(mockClient as any, "test-node");
|
|
860
|
-
expect(report.checkId).toBe("H001");
|
|
861
|
-
expect(report.checkTitle).toBe("Invalid indexes");
|
|
862
|
-
expect("test-node" in report.results).toBe(true);
|
|
863
|
-
|
|
864
|
-
// Data is now keyed by database name
|
|
865
|
-
const data = report.results["test-node"].data;
|
|
866
|
-
expect("testdb" in data).toBe(true);
|
|
867
|
-
const dbData = data["testdb"] as any;
|
|
868
|
-
expect(dbData.invalid_indexes).toBeTruthy();
|
|
869
|
-
expect(dbData.total_count).toBe(1);
|
|
870
|
-
expect(dbData.total_size_bytes).toBe(2097152);
|
|
871
|
-
expect(dbData.total_size_pretty).toBeTruthy();
|
|
872
|
-
expect(dbData.database_size_bytes).toBeTruthy();
|
|
873
|
-
expect(dbData.database_size_pretty).toBeTruthy();
|
|
874
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
test("getInvalidIndexes returns decision tree fields including valid_duplicate_definition", async () => {
|
|
878
|
-
const mockClient = createMockClient({
|
|
879
|
-
invalidIndexesRows: [
|
|
880
|
-
{
|
|
881
|
-
schema_name: "public",
|
|
882
|
-
table_name: "users",
|
|
883
|
-
index_name: "users_email_idx_invalid",
|
|
884
|
-
relation_name: "users",
|
|
885
|
-
index_size_bytes: "1048576",
|
|
886
|
-
index_definition: "CREATE INDEX users_email_idx_invalid ON public.users USING btree (email)",
|
|
887
|
-
supports_fk: false,
|
|
888
|
-
is_pk: false,
|
|
889
|
-
is_unique: false,
|
|
890
|
-
constraint_name: null,
|
|
891
|
-
table_row_estimate: "5000",
|
|
892
|
-
has_valid_duplicate: true,
|
|
893
|
-
valid_index_name: "users_email_idx",
|
|
894
|
-
valid_index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)",
|
|
895
|
-
},
|
|
896
|
-
],
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
const indexes = await checkup.getInvalidIndexes(mockClient as any);
|
|
900
|
-
expect(indexes.length).toBe(1);
|
|
901
|
-
expect(indexes[0].is_pk).toBe(false);
|
|
902
|
-
expect(indexes[0].is_unique).toBe(false);
|
|
903
|
-
expect(indexes[0].constraint_name).toBeNull();
|
|
904
|
-
expect(indexes[0].table_row_estimate).toBe(5000);
|
|
905
|
-
expect(indexes[0].has_valid_duplicate).toBe(true);
|
|
906
|
-
expect(indexes[0].valid_duplicate_name).toBe("users_email_idx");
|
|
907
|
-
expect(indexes[0].valid_duplicate_definition).toBe("CREATE INDEX users_email_idx ON public.users USING btree (email)");
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
test("getInvalidIndexes handles has_valid_duplicate: false with null values", async () => {
|
|
911
|
-
const mockClient = createMockClient({
|
|
912
|
-
invalidIndexesRows: [
|
|
913
|
-
{
|
|
914
|
-
schema_name: "public",
|
|
915
|
-
table_name: "orders",
|
|
916
|
-
index_name: "orders_status_idx_invalid",
|
|
917
|
-
relation_name: "orders",
|
|
918
|
-
index_size_bytes: "524288",
|
|
919
|
-
index_definition: "CREATE INDEX orders_status_idx_invalid ON public.orders USING btree (status)",
|
|
920
|
-
supports_fk: false,
|
|
921
|
-
is_pk: false,
|
|
922
|
-
is_unique: false,
|
|
923
|
-
constraint_name: null,
|
|
924
|
-
table_row_estimate: "100000",
|
|
925
|
-
has_valid_duplicate: false,
|
|
926
|
-
valid_index_name: null,
|
|
927
|
-
valid_index_definition: null,
|
|
928
|
-
},
|
|
929
|
-
],
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
const indexes = await checkup.getInvalidIndexes(mockClient as Client);
|
|
933
|
-
expect(indexes.length).toBe(1);
|
|
934
|
-
expect(indexes[0].has_valid_duplicate).toBe(false);
|
|
935
|
-
expect(indexes[0].valid_duplicate_name).toBeNull();
|
|
936
|
-
expect(indexes[0].valid_duplicate_definition).toBeNull();
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
test("getInvalidIndexes handles is_pk: true with constraint", async () => {
|
|
940
|
-
const mockClient = createMockClient({
|
|
941
|
-
invalidIndexesRows: [
|
|
942
|
-
{
|
|
943
|
-
schema_name: "public",
|
|
944
|
-
table_name: "accounts",
|
|
945
|
-
index_name: "accounts_pkey_invalid",
|
|
946
|
-
relation_name: "accounts",
|
|
947
|
-
index_size_bytes: "262144",
|
|
948
|
-
index_definition: "CREATE UNIQUE INDEX accounts_pkey_invalid ON public.accounts USING btree (id)",
|
|
949
|
-
supports_fk: true,
|
|
950
|
-
is_pk: true,
|
|
951
|
-
is_unique: true,
|
|
952
|
-
constraint_name: "accounts_pkey",
|
|
953
|
-
table_row_estimate: "500",
|
|
954
|
-
has_valid_duplicate: false,
|
|
955
|
-
valid_index_name: null,
|
|
956
|
-
valid_index_definition: null,
|
|
957
|
-
},
|
|
958
|
-
],
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
const indexes = await checkup.getInvalidIndexes(mockClient as Client);
|
|
962
|
-
expect(indexes.length).toBe(1);
|
|
963
|
-
expect(indexes[0].is_pk).toBe(true);
|
|
964
|
-
expect(indexes[0].is_unique).toBe(true);
|
|
965
|
-
expect(indexes[0].constraint_name).toBe("accounts_pkey");
|
|
966
|
-
expect(indexes[0].supports_fk).toBe(true);
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
test("getInvalidIndexes handles is_unique: true without PK", async () => {
|
|
970
|
-
const mockClient = createMockClient({
|
|
971
|
-
invalidIndexesRows: [
|
|
972
|
-
{
|
|
973
|
-
schema_name: "public",
|
|
974
|
-
table_name: "users",
|
|
975
|
-
index_name: "users_email_unique_invalid",
|
|
976
|
-
relation_name: "users",
|
|
977
|
-
index_size_bytes: "131072",
|
|
978
|
-
index_definition: "CREATE UNIQUE INDEX users_email_unique_invalid ON public.users USING btree (email)",
|
|
979
|
-
supports_fk: false,
|
|
980
|
-
is_pk: false,
|
|
981
|
-
is_unique: true,
|
|
982
|
-
constraint_name: "users_email_unique",
|
|
983
|
-
table_row_estimate: "25000",
|
|
984
|
-
has_valid_duplicate: true,
|
|
985
|
-
valid_index_name: "users_email_unique_idx",
|
|
986
|
-
valid_index_definition: "CREATE UNIQUE INDEX users_email_unique_idx ON public.users USING btree (email)",
|
|
987
|
-
},
|
|
988
|
-
],
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
const indexes = await checkup.getInvalidIndexes(mockClient as Client);
|
|
992
|
-
expect(indexes.length).toBe(1);
|
|
993
|
-
expect(indexes[0].is_pk).toBe(false);
|
|
994
|
-
expect(indexes[0].is_unique).toBe(true);
|
|
995
|
-
expect(indexes[0].constraint_name).toBe("users_email_unique");
|
|
996
|
-
expect(indexes[0].has_valid_duplicate).toBe(true);
|
|
997
|
-
});
|
|
998
|
-
// Top-level structure tests removed - covered by schema-validation.test.ts
|
|
999
|
-
});
|
|
1000
|
-
|
|
1001
|
-
// Tests for H001 decision tree recommendation logic
|
|
1002
|
-
describe("H001 - Decision tree recommendations", () => {
|
|
1003
|
-
// Helper to create a minimal InvalidIndex for testing
|
|
1004
|
-
const createTestIndex = (overrides: Partial<checkup.InvalidIndex> = {}): checkup.InvalidIndex => ({
|
|
1005
|
-
schema_name: "public",
|
|
1006
|
-
table_name: "test_table",
|
|
1007
|
-
index_name: "test_idx",
|
|
1008
|
-
relation_name: "public.test_table",
|
|
1009
|
-
index_size_bytes: 1024,
|
|
1010
|
-
index_size_pretty: "1 KiB",
|
|
1011
|
-
index_definition: "CREATE INDEX test_idx ON public.test_table USING btree (col)",
|
|
1012
|
-
supports_fk: false,
|
|
1013
|
-
is_pk: false,
|
|
1014
|
-
is_unique: false,
|
|
1015
|
-
constraint_name: null,
|
|
1016
|
-
table_row_estimate: 100000, // Large table by default
|
|
1017
|
-
has_valid_duplicate: false,
|
|
1018
|
-
valid_duplicate_name: null,
|
|
1019
|
-
valid_duplicate_definition: null,
|
|
1020
|
-
...overrides,
|
|
1021
|
-
});
|
|
1022
|
-
|
|
1023
|
-
test("returns DROP when has_valid_duplicate is true", () => {
|
|
1024
|
-
const index = createTestIndex({ has_valid_duplicate: true, valid_duplicate_name: "existing_idx" });
|
|
1025
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("DROP");
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
test("returns DROP even when is_pk is true if has_valid_duplicate is true", () => {
|
|
1029
|
-
// has_valid_duplicate takes precedence over is_pk
|
|
1030
|
-
const index = createTestIndex({
|
|
1031
|
-
has_valid_duplicate: true,
|
|
1032
|
-
is_pk: true,
|
|
1033
|
-
is_unique: true,
|
|
1034
|
-
});
|
|
1035
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("DROP");
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
test("returns RECREATE when is_pk is true and no valid duplicate", () => {
|
|
1039
|
-
const index = createTestIndex({
|
|
1040
|
-
is_pk: true,
|
|
1041
|
-
is_unique: true,
|
|
1042
|
-
constraint_name: "test_pkey",
|
|
1043
|
-
});
|
|
1044
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
test("returns RECREATE when is_unique is true (non-PK) and no valid duplicate", () => {
|
|
1048
|
-
const index = createTestIndex({
|
|
1049
|
-
is_unique: true,
|
|
1050
|
-
constraint_name: "test_unique",
|
|
1051
|
-
});
|
|
1052
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
test("returns RECREATE for small table (< 10K rows) without valid duplicate", () => {
|
|
1056
|
-
const index = createTestIndex({ table_row_estimate: 5000 });
|
|
1057
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
test("returns RECREATE for table at threshold boundary (9999 rows)", () => {
|
|
1061
|
-
const index = createTestIndex({ table_row_estimate: 9999 });
|
|
1062
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
test("returns UNCERTAIN for large table (>= 10K rows) at threshold boundary", () => {
|
|
1066
|
-
const index = createTestIndex({ table_row_estimate: 10000 });
|
|
1067
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("UNCERTAIN");
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
test("returns UNCERTAIN for large table without valid duplicate or constraint", () => {
|
|
1071
|
-
const index = createTestIndex({ table_row_estimate: 1000000 });
|
|
1072
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("UNCERTAIN");
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
test("returns UNCERTAIN for empty table (0 rows) with no valid duplicate - edge case", () => {
|
|
1076
|
-
// Empty table should be RECREATE (< 10K threshold)
|
|
1077
|
-
const index = createTestIndex({ table_row_estimate: 0 });
|
|
1078
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
test("decision tree priority: has_valid_duplicate > is_pk > small_table", () => {
|
|
1082
|
-
// Even with PK and small table, has_valid_duplicate should win
|
|
1083
|
-
const index = createTestIndex({
|
|
1084
|
-
has_valid_duplicate: true,
|
|
1085
|
-
is_pk: true,
|
|
1086
|
-
is_unique: true,
|
|
1087
|
-
table_row_estimate: 100,
|
|
1088
|
-
});
|
|
1089
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("DROP");
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
test("decision tree priority: is_pk > small_table", () => {
|
|
1093
|
-
// is_pk should return RECREATE regardless of table size
|
|
1094
|
-
const index = createTestIndex({
|
|
1095
|
-
is_pk: true,
|
|
1096
|
-
is_unique: true,
|
|
1097
|
-
table_row_estimate: 1000000, // Large table
|
|
1098
|
-
});
|
|
1099
|
-
expect(checkup.getInvalidIndexRecommendation(index)).toBe("RECREATE");
|
|
1100
|
-
});
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
// Tests for H002 (Unused indexes)
|
|
1104
|
-
describe("H002 - Unused indexes", () => {
|
|
1105
|
-
test("getUnusedIndexes returns unused indexes", async () => {
|
|
1106
|
-
const mockClient = createMockClient({
|
|
1107
|
-
unusedIndexesRows: [
|
|
1108
|
-
{
|
|
1109
|
-
schema_name: "public",
|
|
1110
|
-
table_name: "products",
|
|
1111
|
-
index_name: "products_old_idx",
|
|
1112
|
-
index_definition: "CREATE INDEX products_old_idx ON public.products USING btree (old_column)",
|
|
1113
|
-
reason: "Never Used Indexes",
|
|
1114
|
-
index_size_bytes: "4194304",
|
|
1115
|
-
idx_scan: "0",
|
|
1116
|
-
idx_is_btree: true,
|
|
1117
|
-
supports_fk: false,
|
|
1118
|
-
},
|
|
1119
|
-
],
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
const indexes = await checkup.getUnusedIndexes(mockClient as any);
|
|
1123
|
-
expect(indexes.length).toBe(1);
|
|
1124
|
-
expect(indexes[0].schema_name).toBe("public");
|
|
1125
|
-
expect(indexes[0].index_name).toBe("products_old_idx");
|
|
1126
|
-
expect(indexes[0].index_size_bytes).toBe(4194304);
|
|
1127
|
-
expect(indexes[0].idx_scan).toBe(0);
|
|
1128
|
-
expect(indexes[0].supports_fk).toBe(false);
|
|
1129
|
-
expect(indexes[0].index_definition).toBeTruthy();
|
|
1130
|
-
expect(indexes[0].idx_is_btree).toBe(true);
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
test("generateH002 creates report with unused indexes", async () => {
|
|
1134
|
-
const mockClient = createMockClient({
|
|
1135
|
-
versionRows: [
|
|
1136
|
-
{ name: "server_version", setting: "16.3" },
|
|
1137
|
-
{ name: "server_version_num", setting: "160003" },
|
|
1138
|
-
],
|
|
1139
|
-
unusedIndexesRows: [
|
|
1140
|
-
{
|
|
1141
|
-
schema_name: "public",
|
|
1142
|
-
table_name: "logs",
|
|
1143
|
-
index_name: "logs_created_idx",
|
|
1144
|
-
index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)",
|
|
1145
|
-
reason: "Never Used Indexes",
|
|
1146
|
-
index_size_bytes: "8388608",
|
|
1147
|
-
idx_scan: "0",
|
|
1148
|
-
idx_is_btree: true,
|
|
1149
|
-
supports_fk: false,
|
|
1150
|
-
},
|
|
1151
|
-
],
|
|
1152
|
-
}
|
|
1153
|
-
);
|
|
1154
|
-
|
|
1155
|
-
const report = await checkup.generateH002(mockClient as any, "test-node");
|
|
1156
|
-
expect(report.checkId).toBe("H002");
|
|
1157
|
-
expect(report.checkTitle).toBe("Unused indexes");
|
|
1158
|
-
expect("test-node" in report.results).toBe(true);
|
|
1159
|
-
|
|
1160
|
-
// Data is now keyed by database name
|
|
1161
|
-
const data = report.results["test-node"].data;
|
|
1162
|
-
expect("testdb" in data).toBe(true);
|
|
1163
|
-
const dbData = data["testdb"] as any;
|
|
1164
|
-
expect(dbData.unused_indexes).toBeTruthy();
|
|
1165
|
-
expect(dbData.total_count).toBe(1);
|
|
1166
|
-
expect(dbData.total_size_bytes).toBe(8388608);
|
|
1167
|
-
expect(dbData.total_size_pretty).toBeTruthy();
|
|
1168
|
-
expect(dbData.stats_reset).toBeTruthy();
|
|
1169
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
1170
|
-
});
|
|
1171
|
-
// Top-level structure tests removed - covered by schema-validation.test.ts
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
// Tests for H004 (Redundant indexes)
|
|
1175
|
-
describe("H004 - Redundant indexes", () => {
|
|
1176
|
-
test("getRedundantIndexes returns redundant indexes", async () => {
|
|
1177
|
-
const mockClient = createMockClient({
|
|
1178
|
-
redundantIndexesRows: [
|
|
1179
|
-
{
|
|
1180
|
-
schema_name: "public",
|
|
1181
|
-
table_name: "orders",
|
|
1182
|
-
index_name: "orders_user_id_idx",
|
|
1183
|
-
relation_name: "orders",
|
|
1184
|
-
access_method: "btree",
|
|
1185
|
-
reason: "public.orders_user_id_created_idx",
|
|
1186
|
-
index_size_bytes: "2097152",
|
|
1187
|
-
table_size_bytes: "16777216",
|
|
1188
|
-
index_usage: "0",
|
|
1189
|
-
supports_fk: false,
|
|
1190
|
-
index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)",
|
|
1191
|
-
redundant_to_json: JSON.stringify([
|
|
1192
|
-
{ index_name: "public.orders_user_id_created_idx", index_definition: "CREATE INDEX orders_user_id_created_idx ON public.orders USING btree (user_id, created_at)", index_size_bytes: 1048576 }
|
|
1193
|
-
]),
|
|
1194
|
-
},
|
|
1195
|
-
],
|
|
1196
|
-
});
|
|
1197
|
-
|
|
1198
|
-
const indexes = await checkup.getRedundantIndexes(mockClient as any);
|
|
1199
|
-
expect(indexes.length).toBe(1);
|
|
1200
|
-
expect(indexes[0].schema_name).toBe("public");
|
|
1201
|
-
expect(indexes[0].index_name).toBe("orders_user_id_idx");
|
|
1202
|
-
expect(indexes[0].reason).toBe("public.orders_user_id_created_idx");
|
|
1203
|
-
expect(indexes[0].index_size_bytes).toBe(2097152);
|
|
1204
|
-
expect(indexes[0].supports_fk).toBe(false);
|
|
1205
|
-
expect(indexes[0].index_definition).toBeTruthy();
|
|
1206
|
-
expect(indexes[0].relation_name).toBe("orders");
|
|
1207
|
-
// Verify redundant_to is populated with definitions and sizes
|
|
1208
|
-
expect(indexes[0].redundant_to).toBeInstanceOf(Array);
|
|
1209
|
-
expect(indexes[0].redundant_to.length).toBe(1);
|
|
1210
|
-
expect(indexes[0].redundant_to[0].index_name).toBe("public.orders_user_id_created_idx");
|
|
1211
|
-
expect(indexes[0].redundant_to[0].index_definition).toContain("CREATE INDEX");
|
|
1212
|
-
expect(indexes[0].redundant_to[0].index_size_bytes).toBe(1048576);
|
|
1213
|
-
expect(indexes[0].redundant_to[0].index_size_pretty).toBe("1.00 MiB");
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
test("generateH004 creates report with redundant indexes", async () => {
|
|
1217
|
-
const mockClient = createMockClient({
|
|
1218
|
-
versionRows: [
|
|
1219
|
-
{ name: "server_version", setting: "16.3" },
|
|
1220
|
-
{ name: "server_version_num", setting: "160003" },
|
|
1221
|
-
],
|
|
1222
|
-
redundantIndexesRows: [
|
|
1223
|
-
{
|
|
1224
|
-
schema_name: "public",
|
|
1225
|
-
table_name: "products",
|
|
1226
|
-
index_name: "products_category_idx",
|
|
1227
|
-
relation_name: "products",
|
|
1228
|
-
access_method: "btree",
|
|
1229
|
-
reason: "public.products_category_name_idx",
|
|
1230
|
-
index_size_bytes: "4194304",
|
|
1231
|
-
table_size_bytes: "33554432",
|
|
1232
|
-
index_usage: "5",
|
|
1233
|
-
supports_fk: false,
|
|
1234
|
-
index_definition: "CREATE INDEX products_category_idx ON public.products USING btree (category)",
|
|
1235
|
-
redundant_to_json: JSON.stringify([
|
|
1236
|
-
{ index_name: "public.products_category_name_idx", index_definition: "CREATE INDEX products_category_name_idx ON public.products USING btree (category, name)", index_size_bytes: 2097152 }
|
|
1237
|
-
]),
|
|
1238
|
-
},
|
|
1239
|
-
],
|
|
1240
|
-
}
|
|
1241
|
-
);
|
|
1242
|
-
|
|
1243
|
-
const report = await checkup.generateH004(mockClient as any, "test-node");
|
|
1244
|
-
expect(report.checkId).toBe("H004");
|
|
1245
|
-
expect(report.checkTitle).toBe("Redundant indexes");
|
|
1246
|
-
expect("test-node" in report.results).toBe(true);
|
|
1247
|
-
|
|
1248
|
-
// Data is now keyed by database name
|
|
1249
|
-
const data = report.results["test-node"].data;
|
|
1250
|
-
expect("testdb" in data).toBe(true);
|
|
1251
|
-
const dbData = data["testdb"] as any;
|
|
1252
|
-
expect(dbData.redundant_indexes).toBeTruthy();
|
|
1253
|
-
expect(dbData.total_count).toBe(1);
|
|
1254
|
-
expect(dbData.total_size_bytes).toBe(4194304);
|
|
1255
|
-
expect(dbData.total_size_pretty).toBeTruthy();
|
|
1256
|
-
expect(dbData.database_size_bytes).toBeTruthy();
|
|
1257
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
1258
|
-
});
|
|
1259
|
-
// Top-level structure tests removed - covered by schema-validation.test.ts
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
// Tests for F003 (Autovacuum: dead tuples)
|
|
1263
|
-
describe("F003 - Dead tuples", () => {
|
|
1264
|
-
// The seeded UX-test case that F004 missed: 8.27M dead tuples,
|
|
1265
|
-
// dead:live ratio 1.3:1 (dead_pct ~56.5%), autovacuum disabled via reloptions.
|
|
1266
|
-
const seededProblemRow = {
|
|
1267
|
-
tag_schemaname: "public",
|
|
1268
|
-
tag_relname: "events",
|
|
1269
|
-
n_live_tup: "6361538",
|
|
1270
|
-
n_dead_tup: "8270000",
|
|
1271
|
-
dead_pct: 56.52,
|
|
1272
|
-
last_autovacuum: "0",
|
|
1273
|
-
last_vacuum: "0",
|
|
1274
|
-
autovacuum_count: "0",
|
|
1275
|
-
vacuum_count: "0",
|
|
1276
|
-
autovacuum_disabled: 1,
|
|
1277
|
-
table_size_b: "2147483648",
|
|
1278
|
-
};
|
|
1279
|
-
|
|
1280
|
-
test("getDeadTuples maps rows and computes flags", async () => {
|
|
1281
|
-
const mockClient = createMockClient({ deadTuplesRows: [seededProblemRow] });
|
|
1282
|
-
|
|
1283
|
-
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1284
|
-
expect(tables.length).toBe(1);
|
|
1285
|
-
const t = tables[0];
|
|
1286
|
-
expect(t.schema_name).toBe("public");
|
|
1287
|
-
expect(t.table_name).toBe("events");
|
|
1288
|
-
expect(t.n_live_tup).toBe(6361538);
|
|
1289
|
-
expect(t.n_dead_tup).toBe(8270000);
|
|
1290
|
-
expect(t.dead_pct).toBe(56.52);
|
|
1291
|
-
expect(t.autovacuum_disabled).toBe(true);
|
|
1292
|
-
expect(t.last_autovacuum).toBeNull();
|
|
1293
|
-
expect(t.last_autovacuum_epoch).toBe(0);
|
|
1294
|
-
expect(t.last_vacuum).toBeNull();
|
|
1295
|
-
expect(t.table_size_bytes).toBe(2147483648);
|
|
1296
|
-
expect(t.table_size_pretty).toBe("2.00 GiB");
|
|
1297
|
-
expect(t.exceeds_dead_tuple_thresholds).toBe(true);
|
|
1298
|
-
expect(t.autovacuum_disabled_flagged).toBe(true);
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
|
-
test("getDeadTuples converts non-zero vacuum epochs to ISO timestamps", async () => {
|
|
1302
|
-
const mockClient = createMockClient({
|
|
1303
|
-
deadTuplesRows: [{
|
|
1304
|
-
...seededProblemRow,
|
|
1305
|
-
last_autovacuum: "1704067200", // 2024-01-01T00:00:00Z
|
|
1306
|
-
last_vacuum: "1706745600", // 2024-02-01T00:00:00Z
|
|
1307
|
-
autovacuum_count: "3",
|
|
1308
|
-
vacuum_count: "1",
|
|
1309
|
-
autovacuum_disabled: 0,
|
|
1310
|
-
}],
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
const [t] = await checkup.getDeadTuples(mockClient as any);
|
|
1314
|
-
expect(t.last_autovacuum).toBe("2024-01-01T00:00:00.000Z");
|
|
1315
|
-
expect(t.last_autovacuum_epoch).toBe(1704067200);
|
|
1316
|
-
expect(t.last_vacuum).toBe("2024-02-01T00:00:00.000Z");
|
|
1317
|
-
expect(t.autovacuum_count).toBe(3);
|
|
1318
|
-
expect(t.vacuum_count).toBe(1);
|
|
1319
|
-
expect(t.autovacuum_disabled).toBe(false);
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
test("dead-tuple thresholds require BOTH absolute and relative excess", async () => {
|
|
1323
|
-
const mockClient = createMockClient({
|
|
1324
|
-
deadTuplesRows: [
|
|
1325
|
-
// High count, low ratio (large healthy table churning under active autovacuum)
|
|
1326
|
-
{ ...seededProblemRow, tag_relname: "big_churn", n_live_tup: "100000000", n_dead_tup: "150000", dead_pct: 0.15, autovacuum_disabled: 0 },
|
|
1327
|
-
// High ratio, low count (small table - not worth flagging)
|
|
1328
|
-
{ ...seededProblemRow, tag_relname: "tiny", n_live_tup: "100", n_dead_tup: "900", dead_pct: 90, autovacuum_disabled: 0 },
|
|
1329
|
-
// Both high - must be flagged
|
|
1330
|
-
{ ...seededProblemRow, tag_relname: "problem", n_live_tup: "100000", n_dead_tup: "100000", dead_pct: 50, autovacuum_disabled: 0 },
|
|
1331
|
-
],
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1335
|
-
const byName = new Map(tables.map((t) => [t.table_name, t]));
|
|
1336
|
-
expect(byName.get("big_churn")!.exceeds_dead_tuple_thresholds).toBe(false);
|
|
1337
|
-
expect(byName.get("tiny")!.exceeds_dead_tuple_thresholds).toBe(false);
|
|
1338
|
-
expect(byName.get("problem")!.exceeds_dead_tuple_thresholds).toBe(true);
|
|
1339
|
-
});
|
|
1340
|
-
|
|
1341
|
-
test("autovacuum disabled is flagged only on non-tiny tables", async () => {
|
|
1342
|
-
const mockClient = createMockClient({
|
|
1343
|
-
deadTuplesRows: [
|
|
1344
|
-
{ ...seededProblemRow, tag_relname: "tiny_disabled", n_live_tup: "50", n_dead_tup: "0", dead_pct: 0 },
|
|
1345
|
-
{ ...seededProblemRow, tag_relname: "big_disabled", n_live_tup: "20000", n_dead_tup: "0", dead_pct: 0 },
|
|
1346
|
-
],
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1350
|
-
const byName = new Map(tables.map((t) => [t.table_name, t]));
|
|
1351
|
-
expect(byName.get("tiny_disabled")!.autovacuum_disabled).toBe(true);
|
|
1352
|
-
expect(byName.get("tiny_disabled")!.autovacuum_disabled_flagged).toBe(false);
|
|
1353
|
-
expect(byName.get("big_disabled")!.autovacuum_disabled_flagged).toBe(true);
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
test("buildDeadTuplesConclusions produces concrete conclusions and recommendations", async () => {
|
|
1357
|
-
const mockClient = createMockClient({ deadTuplesRows: [seededProblemRow] });
|
|
1358
|
-
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1359
|
-
|
|
1360
|
-
const { conclusions, recommendations } = checkup.buildDeadTuplesConclusions(tables);
|
|
1361
|
-
expect(conclusions.length).toBe(1);
|
|
1362
|
-
expect(conclusions[0]).toContain('"public"."events"');
|
|
1363
|
-
expect(conclusions[0]).toContain("8,270,000 dead tuples");
|
|
1364
|
-
expect(conclusions[0]).toContain("56.52% of all tuples");
|
|
1365
|
-
expect(conclusions[0]).toContain("autovacuum is disabled");
|
|
1366
|
-
expect(conclusions[0]).toContain("never vacuumed");
|
|
1367
|
-
|
|
1368
|
-
expect(recommendations.length).toBe(1);
|
|
1369
|
-
expect(recommendations[0]).toContain('alter table "public"."events" reset (autovacuum_enabled);');
|
|
1370
|
-
expect(recommendations[0]).toContain('vacuum (analyze) "public"."events";');
|
|
1371
|
-
});
|
|
1372
|
-
|
|
1373
|
-
test("buildDeadTuplesConclusions distinguishes vacuum-lag from disabled-autovacuum cases", async () => {
|
|
1374
|
-
const mockClient = createMockClient({
|
|
1375
|
-
deadTuplesRows: [
|
|
1376
|
-
// Dead tuples high but autovacuum enabled -> vacuum + tuning advice
|
|
1377
|
-
{ ...seededProblemRow, tag_relname: "lagging", autovacuum_disabled: 0, last_autovacuum: "1704067200" },
|
|
1378
|
-
// Autovacuum disabled, no dead tuples yet -> re-enable advice
|
|
1379
|
-
{ ...seededProblemRow, tag_relname: "disabled_only", n_live_tup: "50000", n_dead_tup: "0", dead_pct: 0 },
|
|
1380
|
-
],
|
|
1381
|
-
});
|
|
1382
|
-
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1383
|
-
|
|
1384
|
-
const { conclusions, recommendations } = checkup.buildDeadTuplesConclusions(tables);
|
|
1385
|
-
expect(conclusions.length).toBe(2);
|
|
1386
|
-
const laggingRec = recommendations.find((r) => r.includes('"public"."lagging"'))!;
|
|
1387
|
-
expect(laggingRec).toContain("vacuum (analyze)");
|
|
1388
|
-
expect(laggingRec).toContain("autovacuum_vacuum_scale_factor");
|
|
1389
|
-
expect(laggingRec).not.toContain("reset (autovacuum_enabled)");
|
|
1390
|
-
|
|
1391
|
-
const disabledRec = recommendations.find((r) => r.includes('"public"."disabled_only"'))!;
|
|
1392
|
-
expect(disabledRec).toContain('alter table "public"."disabled_only" reset (autovacuum_enabled);');
|
|
1393
|
-
});
|
|
1394
|
-
|
|
1395
|
-
test("generateF003 creates report with counts, thresholds, and conclusions", async () => {
|
|
1396
|
-
const mockClient = createMockClient({
|
|
1397
|
-
deadTuplesRows: [
|
|
1398
|
-
seededProblemRow,
|
|
1399
|
-
{ ...seededProblemRow, tag_relname: "disabled_only", n_live_tup: "50000", n_dead_tup: "0", dead_pct: 0 },
|
|
1400
|
-
],
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "test-node");
|
|
1404
|
-
expect(report.checkId).toBe("F003");
|
|
1405
|
-
expect(report.checkTitle).toBe("Autovacuum: dead tuples");
|
|
1406
|
-
|
|
1407
|
-
const data = report.results["test-node"].data;
|
|
1408
|
-
expect("testdb" in data).toBe(true);
|
|
1409
|
-
const dbData = data["testdb"] as any;
|
|
1410
|
-
expect(dbData.dead_tuples_tables.length).toBe(2);
|
|
1411
|
-
expect(dbData.total_count).toBe(2);
|
|
1412
|
-
expect(dbData.flagged_count).toBe(1);
|
|
1413
|
-
expect(dbData.autovacuum_disabled_count).toBe(2);
|
|
1414
|
-
expect(dbData.autovacuum_disabled_flagged_count).toBe(2);
|
|
1415
|
-
expect(dbData.total_dead_tuples).toBe(8270000);
|
|
1416
|
-
expect(dbData.thresholds).toEqual({
|
|
1417
|
-
dead_tuples_min: checkup.F003_DEAD_TUPLES_MIN,
|
|
1418
|
-
dead_pct_min: checkup.F003_DEAD_PCT_MIN,
|
|
1419
|
-
autovacuum_disabled_min_rows: checkup.F003_AUTOVACUUM_DISABLED_MIN_ROWS,
|
|
1420
|
-
});
|
|
1421
|
-
expect(dbData.conclusions.length).toBe(2);
|
|
1422
|
-
expect(dbData.recommendations.length).toBe(2);
|
|
1423
|
-
expect(dbData.database_size_bytes).toBeTruthy();
|
|
1424
|
-
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
1425
|
-
});
|
|
1426
|
-
|
|
1427
|
-
test("generateF003 handles a healthy database (no rows)", async () => {
|
|
1428
|
-
const mockClient = createMockClient({ deadTuplesRows: [] });
|
|
1429
|
-
|
|
1430
|
-
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "test-node");
|
|
1431
|
-
const dbData = report.results["test-node"].data["testdb"] as any;
|
|
1432
|
-
expect(dbData.dead_tuples_tables).toEqual([]);
|
|
1433
|
-
expect(dbData.total_count).toBe(0);
|
|
1434
|
-
expect(dbData.flagged_count).toBe(0);
|
|
1435
|
-
expect(dbData.autovacuum_disabled_count).toBe(0);
|
|
1436
|
-
expect(dbData.autovacuum_disabled_flagged_count).toBe(0);
|
|
1437
|
-
expect(dbData.conclusions).toEqual([]);
|
|
1438
|
-
expect(dbData.recommendations).toEqual([]);
|
|
1439
|
-
});
|
|
1440
|
-
});
|
|
1441
|
-
|
|
1442
|
-
// CLI tests
|
|
1443
|
-
describe("CLI tests", () => {
|
|
1444
|
-
test("checkup command exists and shows help", () => {
|
|
1445
|
-
const r = runCli(["checkup", "--help"]);
|
|
1446
|
-
expect(r.status).toBe(0);
|
|
1447
|
-
expect(r.stdout).toMatch(/express mode/i);
|
|
1448
|
-
expect(r.stdout).toMatch(/--check-id/);
|
|
1449
|
-
expect(r.stdout).toMatch(/--node-name/);
|
|
1450
|
-
expect(r.stdout).toMatch(/--output/);
|
|
1451
|
-
expect(r.stdout).toMatch(/upload/);
|
|
1452
|
-
expect(r.stdout).toMatch(/--json/);
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
test("checkup --help shows available check IDs", () => {
|
|
1456
|
-
const r = runCli(["checkup", "--help"]);
|
|
1457
|
-
expect(r.status).toBe(0);
|
|
1458
|
-
expect(r.stdout).toMatch(/A002/);
|
|
1459
|
-
expect(r.stdout).toMatch(/A003/);
|
|
1460
|
-
expect(r.stdout).toMatch(/A004/);
|
|
1461
|
-
expect(r.stdout).toMatch(/A007/);
|
|
1462
|
-
expect(r.stdout).toMatch(/A013/);
|
|
1463
|
-
expect(r.stdout).toMatch(/H001/);
|
|
1464
|
-
expect(r.stdout).toMatch(/H002/);
|
|
1465
|
-
expect(r.stdout).toMatch(/H004/);
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
test("checkup without connection shows help", () => {
|
|
1469
|
-
const r = runCli(["checkup"]);
|
|
1470
|
-
expect(r.status).not.toBe(0);
|
|
1471
|
-
// Should show full help (options + examples), like `checkup --help`
|
|
1472
|
-
expect(r.stdout).toMatch(/generate health check reports/i);
|
|
1473
|
-
expect(r.stdout).toMatch(/--check-id/);
|
|
1474
|
-
expect(r.stdout).toMatch(/available checks/i);
|
|
1475
|
-
expect(r.stdout).toMatch(/A002/);
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
test("checkup --help shows --upload and --no-upload options", () => {
|
|
1479
|
-
const r = runCli(["checkup", "--help"]);
|
|
1480
|
-
expect(r.status).toBe(0);
|
|
1481
|
-
expect(r.stdout).toMatch(/--upload/);
|
|
1482
|
-
expect(r.stdout).toMatch(/--no-upload/);
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
test("checkup --no-upload is recognized as valid option", () => {
|
|
1486
|
-
// Should not produce "unknown option" error for --no-upload
|
|
1487
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1488
|
-
// Connection will fail, but option parsing should succeed
|
|
1489
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1490
|
-
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
test("checkup --upload is recognized as valid option", () => {
|
|
1494
|
-
// Should not produce "unknown option" error for --upload
|
|
1495
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--upload"]);
|
|
1496
|
-
// Connection will fail, but option parsing should succeed
|
|
1497
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1498
|
-
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
|
-
test("checkup --json does not imply --no-upload (decoupled behavior)", () => {
|
|
1502
|
-
// Use empty config dir to ensure no API key is configured
|
|
1503
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1504
|
-
// --json alone should NOT disable upload - when --upload is explicitly requested
|
|
1505
|
-
// with --json, it should require API key (proving upload is not disabled)
|
|
1506
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--json", "--upload"], env);
|
|
1507
|
-
// Should fail with "API key is required" because upload is enabled
|
|
1508
|
-
expect(r.stderr).toMatch(/API key is required/i);
|
|
1509
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
|
-
test("checkup --json --no-upload explicitly disables upload", () => {
|
|
1513
|
-
// Use empty config dir to ensure no API key is configured
|
|
1514
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1515
|
-
// --json with --no-upload should disable upload (no API key error)
|
|
1516
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--json", "--no-upload"], env);
|
|
1517
|
-
// Should NOT show "API key is required" because upload is disabled
|
|
1518
|
-
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1519
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
test("checkup --upload requires API key", () => {
|
|
1523
|
-
// Use empty config dir to ensure no API key is configured
|
|
1524
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1525
|
-
// --upload explicitly requests upload, should fail without API key
|
|
1526
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--upload"], env);
|
|
1527
|
-
expect(r.stderr).toMatch(/API key is required/i);
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
test("checkup --no-upload does not require API key", () => {
|
|
1531
|
-
// Use empty config dir to ensure no API key is configured
|
|
1532
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1533
|
-
// --no-upload disables upload, should not require API key
|
|
1534
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"], env);
|
|
1535
|
-
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1536
|
-
});
|
|
1537
|
-
|
|
1538
|
-
test("checkup --help shows --markdown option", () => {
|
|
1539
|
-
const r = runCli(["checkup", "--help"]);
|
|
1540
|
-
expect(r.status).toBe(0);
|
|
1541
|
-
expect(r.stdout).toMatch(/--markdown/);
|
|
1542
|
-
expect(r.stdout).toMatch(/PostgresAI API/i);
|
|
1543
|
-
expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
|
|
1544
|
-
});
|
|
1545
|
-
|
|
1546
|
-
test("checkup --markdown is recognized as valid option", () => {
|
|
1547
|
-
// Should not produce "unknown option" error for --markdown
|
|
1548
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
|
|
1549
|
-
// Connection will fail, but option parsing should succeed
|
|
1550
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1551
|
-
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
1552
|
-
});
|
|
1553
|
-
|
|
1554
|
-
test("checkup --markdown works without API key", () => {
|
|
1555
|
-
// Use empty config dir to ensure no API key is configured
|
|
1556
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1557
|
-
// --markdown should work even without API key
|
|
1558
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
|
|
1559
|
-
// Connection will fail, but --markdown flag should be recognized
|
|
1560
|
-
expect(r.status).not.toBe(0);
|
|
1561
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1562
|
-
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1563
|
-
expect(r.stderr).toMatch(/full report JSON will still be sent/i);
|
|
1564
|
-
expect(r.stderr).toMatch(/markdown conversion/i);
|
|
1565
|
-
});
|
|
1566
|
-
|
|
1567
|
-
test("checkup with --no-upload and no output flags shows summary", () => {
|
|
1568
|
-
// This test verifies that when running with --no-upload and no output flags,
|
|
1569
|
-
// the user gets a summary of checks.
|
|
1570
|
-
// Note: This will fail to connect, but we can still verify behavior.
|
|
1571
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1572
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"], env);
|
|
1573
|
-
|
|
1574
|
-
// The command will fail due to connection error, but if it succeeded,
|
|
1575
|
-
// it should show the summary. We can't test the success case without a real DB,
|
|
1576
|
-
// but we verify the option parsing is correct (tested above in other tests).
|
|
1577
|
-
// The actual summary output is tested in integration tests.
|
|
1578
|
-
expect(r.status).not.toBe(0); // Will fail due to connection
|
|
1579
|
-
});
|
|
1580
|
-
|
|
1581
|
-
test("checkup rejects --json and --markdown together", () => {
|
|
1582
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1583
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--json", "--markdown", "--no-upload"], env);
|
|
1584
|
-
|
|
1585
|
-
// Should fail with mutual exclusivity error
|
|
1586
|
-
expect(r.status).not.toBe(0);
|
|
1587
|
-
expect(r.stderr).toMatch(/mutually exclusive/i);
|
|
1588
|
-
});
|
|
1589
|
-
|
|
1590
|
-
// Argument parsing tests for check ID / connection string recognition
|
|
1591
|
-
test("checkup with check ID but no connection shows specific error", () => {
|
|
1592
|
-
const r = runCli(["checkup", "H002"]);
|
|
1593
|
-
expect(r.status).not.toBe(0);
|
|
1594
|
-
expect(r.stderr).toMatch(/connection string required/i);
|
|
1595
|
-
expect(r.stderr).toMatch(/H002/);
|
|
1596
|
-
});
|
|
1597
|
-
|
|
1598
|
-
test("checkup recognizes valid check ID patterns", () => {
|
|
1599
|
-
// Valid check IDs: A002, H002, F004, etc.
|
|
1600
|
-
for (const checkId of ["A002", "H002", "F004", "K003", "a002", "h002"]) {
|
|
1601
|
-
const r = runCli(["checkup", checkId]);
|
|
1602
|
-
expect(r.status).not.toBe(0);
|
|
1603
|
-
expect(r.stderr).toMatch(/connection string required/i);
|
|
1604
|
-
}
|
|
1605
|
-
});
|
|
1606
|
-
|
|
1607
|
-
test("checkup does not treat connection string as check ID", () => {
|
|
1608
|
-
// Connection strings should not be parsed as check IDs
|
|
1609
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1610
|
-
// Should not show "connection string required" error
|
|
1611
|
-
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1612
|
-
});
|
|
1613
|
-
|
|
1614
|
-
test("checkup with check ID and connection string works", () => {
|
|
1615
|
-
// pgai checkup H002 postgresql://...
|
|
1616
|
-
const r = runCli(["checkup", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1617
|
-
// Should not show "connection string required" error
|
|
1618
|
-
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1619
|
-
// Connection will fail but argument parsing should succeed
|
|
1620
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1621
|
-
});
|
|
1622
|
-
|
|
1623
|
-
test("checkup with --check-id option works", () => {
|
|
1624
|
-
// pgai checkup --check-id H002 postgresql://...
|
|
1625
|
-
const r = runCli(["checkup", "--check-id", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1626
|
-
// Should not show "connection string required" error
|
|
1627
|
-
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1628
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1629
|
-
});
|
|
1630
|
-
|
|
1631
|
-
// Tests for --output flag behavior (suppresses stdout when specified)
|
|
1632
|
-
test("checkup --output option is recognized", () => {
|
|
1633
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload", "--output", "/tmp/test-output"]);
|
|
1634
|
-
// Connection will fail, but option parsing should succeed
|
|
1635
|
-
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1636
|
-
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
test("checkup --json --output should NOT output JSON to stdout (writes to files only)", () => {
|
|
1640
|
-
// This is a behavioral test - when --output is specified along with --json,
|
|
1641
|
-
// JSON should only be written to files, not to stdout.
|
|
1642
|
-
// We verify by checking the help text describes this behavior
|
|
1643
|
-
const r = runCli(["checkup", "--help"]);
|
|
1644
|
-
expect(r.status).toBe(0);
|
|
1645
|
-
expect(r.stdout).toMatch(/--output/);
|
|
1646
|
-
expect(r.stdout).toMatch(/--json/);
|
|
1647
|
-
});
|
|
1648
|
-
|
|
1649
|
-
test("checkup --output creates directory if it doesn't exist", () => {
|
|
1650
|
-
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1651
|
-
// Use a temp directory that might not exist
|
|
1652
|
-
const tempDir = `/tmp/postgresai-test-output-${Date.now()}`;
|
|
1653
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload", "--json", "--output", tempDir], env);
|
|
1654
|
-
// Connection will fail, but directory creation should be attempted before connection
|
|
1655
|
-
// The error should be about connection, not about directory
|
|
1656
|
-
expect(r.stderr).not.toMatch(/Failed to create output directory/i);
|
|
1657
|
-
});
|
|
1658
|
-
});
|
|
1659
|
-
|
|
1660
|
-
// CLI-level tests for the checkup auth pre-flight: missing/invalid credentials
|
|
1661
|
-
// must surface BEFORE checks run (previously the upload at the end of the run
|
|
1662
|
-
// was the first authenticated call, wasting minutes of work on a 401).
|
|
1663
|
-
describe("checkup auth pre-flight (CLI)", () => {
|
|
1664
|
-
// Dead Postgres port: if the pre-flight correctly stops the run, the CLI
|
|
1665
|
-
// never attempts this connection; if the run continues, the connection
|
|
1666
|
-
// failure mentions this address.
|
|
1667
|
-
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
|
|
1668
|
-
|
|
1669
|
-
test("--no-upload rejects --markdown before database or API work", () => {
|
|
1670
|
-
const env = {
|
|
1671
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
|
|
1672
|
-
PGAI_API_KEY: "configured-token",
|
|
1673
|
-
PGAI_API_BASE_URL: "http://127.0.0.1:1",
|
|
1674
|
-
};
|
|
1675
|
-
const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
|
|
1676
|
-
|
|
1677
|
-
expect(r.status).not.toBe(0);
|
|
1678
|
-
expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
|
|
1679
|
-
expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
|
|
1680
|
-
expect(r.stderr).toMatch(/use --json or --output/i);
|
|
1681
|
-
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1682
|
-
});
|
|
1683
|
-
|
|
1684
|
-
test("no API key: prominent notice, run continues locally", () => {
|
|
1685
|
-
const env = {
|
|
1686
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
|
|
1687
|
-
PGAI_API_KEY: "",
|
|
1688
|
-
};
|
|
1689
|
-
const r = runCli(["checkup", DEAD_DB], env);
|
|
1690
|
-
expect(r.stderr).toMatch(/results will NOT be uploaded/i);
|
|
1691
|
-
expect(r.stderr).toMatch(/auth login/);
|
|
1692
|
-
expect(r.stderr).toMatch(/--no-upload/);
|
|
1693
|
-
// Falls back to local-only mode instead of failing fast
|
|
1694
|
-
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1695
|
-
// Run continued to the database connection stage
|
|
1696
|
-
expect(r.status).not.toBe(0);
|
|
1697
|
-
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
|
|
1698
|
-
});
|
|
1699
|
-
|
|
1700
|
-
test("--no-upload: no notice, pre-flight skipped entirely", () => {
|
|
1701
|
-
const env = {
|
|
1702
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-noupload-${process.pid}`,
|
|
1703
|
-
PGAI_API_KEY: "",
|
|
1704
|
-
};
|
|
1705
|
-
const r = runCli(["checkup", DEAD_DB, "--no-upload"], env);
|
|
1706
|
-
expect(r.stderr).not.toMatch(/results will NOT be uploaded/i);
|
|
1707
|
-
expect(r.stderr).not.toMatch(/could not verify API key/i);
|
|
1708
|
-
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1709
|
-
});
|
|
1710
|
-
|
|
1711
|
-
test("invalid API key (HTTP 401): fails fast before running checks", async () => {
|
|
1712
|
-
const server = Bun.serve({
|
|
1713
|
-
hostname: "127.0.0.1",
|
|
1714
|
-
port: 0,
|
|
1715
|
-
fetch() {
|
|
1716
|
-
return new Response(JSON.stringify({ message: "Invalid token" }), {
|
|
1717
|
-
status: 401,
|
|
1718
|
-
headers: { "Content-Type": "application/json" },
|
|
1719
|
-
});
|
|
1720
|
-
},
|
|
1721
|
-
});
|
|
1722
|
-
try {
|
|
1723
|
-
const env = {
|
|
1724
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-${process.pid}`,
|
|
1725
|
-
PGAI_API_KEY: "bad-token",
|
|
1726
|
-
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
|
|
1727
|
-
};
|
|
1728
|
-
const r = await runCliAsync(["checkup", DEAD_DB, "--project", "preflight-test"], env);
|
|
1729
|
-
expect(r.status).not.toBe(0);
|
|
1730
|
-
expect(r.stderr).toMatch(/rejected by the PostgresAI API \(HTTP 401\)/);
|
|
1731
|
-
expect(r.stderr).toMatch(/auth login/);
|
|
1732
|
-
expect(r.stderr).toMatch(/--no-upload/);
|
|
1733
|
-
// Stopped BEFORE connecting to the database / running checks
|
|
1734
|
-
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1735
|
-
} finally {
|
|
1736
|
-
server.stop(true);
|
|
1737
|
-
}
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
test("--no-upload skips pre-flight even when an invalid key is configured", async () => {
|
|
1741
|
-
const server = Bun.serve({
|
|
1742
|
-
hostname: "127.0.0.1",
|
|
1743
|
-
port: 0,
|
|
1744
|
-
fetch() {
|
|
1745
|
-
return new Response(JSON.stringify({ message: "Invalid token" }), {
|
|
1746
|
-
status: 401,
|
|
1747
|
-
headers: { "Content-Type": "application/json" },
|
|
1748
|
-
});
|
|
1749
|
-
},
|
|
1750
|
-
});
|
|
1751
|
-
try {
|
|
1752
|
-
const env = {
|
|
1753
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-noupload-${process.pid}`,
|
|
1754
|
-
PGAI_API_KEY: "bad-token",
|
|
1755
|
-
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
|
|
1756
|
-
};
|
|
1757
|
-
const r = await runCliAsync(["checkup", DEAD_DB, "--no-upload"], env);
|
|
1758
|
-
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
|
|
1759
|
-
expect(r.stderr).not.toMatch(/could not verify API key/i);
|
|
1760
|
-
// Fails later at the database connection stage instead
|
|
1761
|
-
expect(r.status).not.toBe(0);
|
|
1762
|
-
} finally {
|
|
1763
|
-
server.stop(true);
|
|
1764
|
-
}
|
|
1765
|
-
});
|
|
1766
|
-
|
|
1767
|
-
test("transient pre-flight failure (network error): warns and continues", () => {
|
|
1768
|
-
const env = {
|
|
1769
|
-
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-netfail-${process.pid}`,
|
|
1770
|
-
PGAI_API_KEY: "some-token",
|
|
1771
|
-
PGAI_API_BASE_URL: "http://127.0.0.1:1", // connect refused — transient, not a 401/403
|
|
1772
|
-
};
|
|
1773
|
-
const r = runCli(["checkup", DEAD_DB, "--project", "preflight-test"], env);
|
|
1774
|
-
expect(r.stderr).toMatch(/Warning: could not verify API key/i);
|
|
1775
|
-
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
|
|
1776
|
-
// Run continued to the database connection stage
|
|
1777
|
-
expect(r.status).not.toBe(0);
|
|
1778
|
-
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
|
|
1779
|
-
});
|
|
1780
|
-
});
|
|
1781
|
-
|
|
1782
|
-
// Tests for checkup-api module
|
|
1783
|
-
describe("checkup-api", () => {
|
|
1784
|
-
test("formatRpcErrorForDisplay formats details/hint nicely", () => {
|
|
1785
|
-
const err = new api.RpcError({
|
|
1786
|
-
rpcName: "checkup_report_file_post",
|
|
1787
|
-
statusCode: 402,
|
|
1788
|
-
payloadText: JSON.stringify({
|
|
1789
|
-
hint: "Start an express checkup subscription for the organization or contact support.",
|
|
1790
|
-
details: "Checkup report uploads require an active checkup subscription",
|
|
1791
|
-
}),
|
|
1792
|
-
payloadJson: {
|
|
1793
|
-
hint: "Start an express checkup subscription for the organization or contact support.",
|
|
1794
|
-
details: "Checkup report uploads require an active checkup subscription.",
|
|
1795
|
-
},
|
|
1796
|
-
});
|
|
1797
|
-
const lines = api.formatRpcErrorForDisplay(err);
|
|
1798
|
-
const text = lines.join("\n");
|
|
1799
|
-
expect(text).toMatch(/RPC checkup_report_file_post failed: HTTP 402/);
|
|
1800
|
-
expect(text).toMatch(/Details:/);
|
|
1801
|
-
expect(text).toMatch(/Hint:/);
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
test("withRetry succeeds on first attempt", async () => {
|
|
1805
|
-
let attempts = 0;
|
|
1806
|
-
const result = await api.withRetry(async () => {
|
|
1807
|
-
attempts++;
|
|
1808
|
-
return "success";
|
|
1809
|
-
});
|
|
1810
|
-
expect(result).toBe("success");
|
|
1811
|
-
expect(attempts).toBe(1);
|
|
1812
|
-
});
|
|
1813
|
-
|
|
1814
|
-
test("withRetry retries on retryable errors and succeeds", async () => {
|
|
1815
|
-
let attempts = 0;
|
|
1816
|
-
const result = await api.withRetry(
|
|
1817
|
-
async () => {
|
|
1818
|
-
attempts++;
|
|
1819
|
-
if (attempts < 3) {
|
|
1820
|
-
throw new Error("connection timeout");
|
|
1821
|
-
}
|
|
1822
|
-
return "success after retry";
|
|
1823
|
-
},
|
|
1824
|
-
{ maxAttempts: 3, initialDelayMs: 10 }
|
|
1825
|
-
);
|
|
1826
|
-
expect(result).toBe("success after retry");
|
|
1827
|
-
expect(attempts).toBe(3);
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
test("withRetry calls onRetry callback", async () => {
|
|
1831
|
-
let attempts = 0;
|
|
1832
|
-
const retryLogs: string[] = [];
|
|
1833
|
-
await api.withRetry(
|
|
1834
|
-
async () => {
|
|
1835
|
-
attempts++;
|
|
1836
|
-
if (attempts < 2) {
|
|
1837
|
-
throw new Error("socket hang up");
|
|
1838
|
-
}
|
|
1839
|
-
return "ok";
|
|
1840
|
-
},
|
|
1841
|
-
{ maxAttempts: 3, initialDelayMs: 10 },
|
|
1842
|
-
(attempt, _err, delayMs) => {
|
|
1843
|
-
retryLogs.push(`attempt ${attempt}, delay ${delayMs}ms`);
|
|
1844
|
-
}
|
|
1845
|
-
);
|
|
1846
|
-
expect(retryLogs.length).toBe(1);
|
|
1847
|
-
expect(retryLogs[0]).toMatch(/attempt 1/);
|
|
1848
|
-
});
|
|
1849
|
-
|
|
1850
|
-
test("withRetry does not retry on non-retryable errors", async () => {
|
|
1851
|
-
let attempts = 0;
|
|
1852
|
-
try {
|
|
1853
|
-
await api.withRetry(
|
|
1854
|
-
async () => {
|
|
1855
|
-
attempts++;
|
|
1856
|
-
throw new Error("invalid input");
|
|
1857
|
-
},
|
|
1858
|
-
{ maxAttempts: 3, initialDelayMs: 10 }
|
|
1859
|
-
);
|
|
1860
|
-
} catch (err) {
|
|
1861
|
-
expect((err as Error).message).toBe("invalid input");
|
|
1862
|
-
}
|
|
1863
|
-
expect(attempts).toBe(1);
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
test("withRetry does not retry on 4xx RpcError", async () => {
|
|
1867
|
-
let attempts = 0;
|
|
1868
|
-
try {
|
|
1869
|
-
await api.withRetry(
|
|
1870
|
-
async () => {
|
|
1871
|
-
attempts++;
|
|
1872
|
-
throw new api.RpcError({
|
|
1873
|
-
rpcName: "test",
|
|
1874
|
-
statusCode: 400,
|
|
1875
|
-
payloadText: "bad request",
|
|
1876
|
-
payloadJson: null,
|
|
1877
|
-
});
|
|
1878
|
-
},
|
|
1879
|
-
{ maxAttempts: 3, initialDelayMs: 10 }
|
|
1880
|
-
);
|
|
1881
|
-
} catch (err) {
|
|
1882
|
-
expect(err).toBeInstanceOf(api.RpcError);
|
|
1883
|
-
}
|
|
1884
|
-
expect(attempts).toBe(1);
|
|
1885
|
-
});
|
|
1886
|
-
|
|
1887
|
-
test("withRetry retries on 5xx RpcError", async () => {
|
|
1888
|
-
let attempts = 0;
|
|
1889
|
-
try {
|
|
1890
|
-
await api.withRetry(
|
|
1891
|
-
async () => {
|
|
1892
|
-
attempts++;
|
|
1893
|
-
throw new api.RpcError({
|
|
1894
|
-
rpcName: "test",
|
|
1895
|
-
statusCode: 503,
|
|
1896
|
-
payloadText: "service unavailable",
|
|
1897
|
-
payloadJson: null,
|
|
1898
|
-
});
|
|
1899
|
-
},
|
|
1900
|
-
{ maxAttempts: 2, initialDelayMs: 10 }
|
|
1901
|
-
);
|
|
1902
|
-
} catch (err) {
|
|
1903
|
-
expect(err).toBeInstanceOf(api.RpcError);
|
|
1904
|
-
}
|
|
1905
|
-
expect(attempts).toBe(2);
|
|
1906
|
-
});
|
|
1907
|
-
|
|
1908
|
-
test("withRetry retries on timeout errors", async () => {
|
|
1909
|
-
// Tests that timeout-like error messages are considered retryable
|
|
1910
|
-
let attempts = 0;
|
|
1911
|
-
try {
|
|
1912
|
-
await api.withRetry(
|
|
1913
|
-
async () => {
|
|
1914
|
-
attempts++;
|
|
1915
|
-
throw new Error("RPC test timed out after 30000ms (no response)");
|
|
1916
|
-
},
|
|
1917
|
-
{ maxAttempts: 3, initialDelayMs: 10 }
|
|
1918
|
-
);
|
|
1919
|
-
} catch (err) {
|
|
1920
|
-
expect(err).toBeInstanceOf(Error);
|
|
1921
|
-
expect((err as Error).message).toContain("timed out");
|
|
1922
|
-
}
|
|
1923
|
-
expect(attempts).toBe(3); // Should retry on timeout
|
|
1924
|
-
});
|
|
1925
|
-
|
|
1926
|
-
test("withRetry retries on ECONNRESET errors", async () => {
|
|
1927
|
-
// Tests that connection reset errors are considered retryable
|
|
1928
|
-
let attempts = 0;
|
|
1929
|
-
try {
|
|
1930
|
-
await api.withRetry(
|
|
1931
|
-
async () => {
|
|
1932
|
-
attempts++;
|
|
1933
|
-
const err = new Error("connection reset") as Error & { code: string };
|
|
1934
|
-
err.code = "ECONNRESET";
|
|
1935
|
-
throw err;
|
|
1936
|
-
},
|
|
1937
|
-
{ maxAttempts: 2, initialDelayMs: 10 }
|
|
1938
|
-
);
|
|
1939
|
-
} catch (err) {
|
|
1940
|
-
expect(err).toBeInstanceOf(Error);
|
|
1941
|
-
}
|
|
1942
|
-
expect(attempts).toBe(2); // Should retry on ECONNRESET
|
|
1943
|
-
});
|
|
1944
|
-
|
|
1945
|
-
// Transport selection — pick http/https by URL protocol, but refuse HTTP
|
|
1946
|
-
// to non-loopback hosts unless CHECKUP_ALLOW_HTTP=1 is set (prevents
|
|
1947
|
-
// typo-driven plaintext API-key leaks like http://api.postgres.ai/...).
|
|
1948
|
-
describe("transport selection", () => {
|
|
1949
|
-
test("https URL does not trip the guard (network error expected)", async () => {
|
|
1950
|
-
let caught: Error | null = null;
|
|
1951
|
-
try {
|
|
1952
|
-
await api.createCheckupReport({
|
|
1953
|
-
apiKey: "dummy",
|
|
1954
|
-
apiBaseUrl: "https://127.0.0.1:1/api", // port 1 — connect refused
|
|
1955
|
-
project: "p",
|
|
1956
|
-
});
|
|
1957
|
-
} catch (e) {
|
|
1958
|
-
caught = e as Error;
|
|
1959
|
-
}
|
|
1960
|
-
expect(caught).not.toBeNull();
|
|
1961
|
-
expect(caught!.message).not.toMatch(/Refusing to send API key/);
|
|
1962
|
-
});
|
|
1963
|
-
|
|
1964
|
-
test("http on loopback does not trip the guard (network error expected)", async () => {
|
|
1965
|
-
// IPv6 loopback is written as `[::1]` in URLs; WHATWG URL preserves
|
|
1966
|
-
// the brackets in .hostname, so the guard must strip them before
|
|
1967
|
-
// matching the allowlist.
|
|
1968
|
-
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
|
|
1969
|
-
let caught: Error | null = null;
|
|
1970
|
-
try {
|
|
1971
|
-
await api.createCheckupReport({
|
|
1972
|
-
apiKey: "dummy",
|
|
1973
|
-
apiBaseUrl: `http://${host}:1/api`, // port 1 — connect refused
|
|
1974
|
-
project: "p",
|
|
1975
|
-
});
|
|
1976
|
-
} catch (e) {
|
|
1977
|
-
caught = e as Error;
|
|
1978
|
-
}
|
|
1979
|
-
expect(caught).not.toBeNull();
|
|
1980
|
-
expect(caught!.message).not.toMatch(/Refusing to send API key/);
|
|
1981
|
-
}
|
|
1982
|
-
});
|
|
1983
|
-
|
|
1984
|
-
test("http to non-loopback host is refused by the guard", async () => {
|
|
1985
|
-
const saved = process.env.CHECKUP_ALLOW_HTTP;
|
|
1986
|
-
delete process.env.CHECKUP_ALLOW_HTTP;
|
|
1987
|
-
try {
|
|
1988
|
-
let caught: Error | null = null;
|
|
1989
|
-
try {
|
|
1990
|
-
await api.createCheckupReport({
|
|
1991
|
-
apiKey: "dummy",
|
|
1992
|
-
apiBaseUrl: "http://example.com/api",
|
|
1993
|
-
project: "p",
|
|
1994
|
-
});
|
|
1995
|
-
} catch (e) {
|
|
1996
|
-
caught = e as Error;
|
|
1997
|
-
}
|
|
1998
|
-
expect(caught).not.toBeNull();
|
|
1999
|
-
expect(caught!.message).toMatch(/Refusing to send API key over plaintext HTTP/);
|
|
2000
|
-
expect(caught!.message).toMatch(/example\.com/);
|
|
2001
|
-
} finally {
|
|
2002
|
-
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
|
|
2003
|
-
}
|
|
2004
|
-
});
|
|
2005
|
-
|
|
2006
|
-
test("CHECKUP_ALLOW_HTTP=1 bypasses the guard for non-loopback hosts", async () => {
|
|
2007
|
-
const saved = process.env.CHECKUP_ALLOW_HTTP;
|
|
2008
|
-
process.env.CHECKUP_ALLOW_HTTP = "1";
|
|
2009
|
-
try {
|
|
2010
|
-
let caught: Error | null = null;
|
|
2011
|
-
try {
|
|
2012
|
-
await api.createCheckupReport({
|
|
2013
|
-
apiKey: "dummy",
|
|
2014
|
-
apiBaseUrl: "http://127.0.0.2:1/api", // non-loopback-match hostname, connect refused port
|
|
2015
|
-
project: "p",
|
|
2016
|
-
});
|
|
2017
|
-
} catch (e) {
|
|
2018
|
-
caught = e as Error;
|
|
2019
|
-
}
|
|
2020
|
-
expect(caught).not.toBeNull();
|
|
2021
|
-
expect(caught!.message).not.toMatch(/Refusing to send API key/);
|
|
2022
|
-
} finally {
|
|
2023
|
-
if (saved === undefined) delete process.env.CHECKUP_ALLOW_HTTP;
|
|
2024
|
-
else process.env.CHECKUP_ALLOW_HTTP = saved;
|
|
2025
|
-
}
|
|
2026
|
-
});
|
|
2027
|
-
});
|
|
2028
|
-
|
|
2029
|
-
// Auth pre-flight: verifyApiKey checks the key with a cheap authenticated
|
|
2030
|
-
// GET before checkup runs expensive checks. Only definitive 401/403 is
|
|
2031
|
-
// "invalid"; anything transient must come back "unknown" (warn + continue).
|
|
2032
|
-
describe("verifyApiKey (auth pre-flight)", () => {
|
|
2033
|
-
function serveStatus(status: number, body = "[]") {
|
|
2034
|
-
return Bun.serve({
|
|
2035
|
-
hostname: "127.0.0.1",
|
|
2036
|
-
port: 0,
|
|
2037
|
-
fetch() {
|
|
2038
|
-
return new Response(body, { status, headers: { "Content-Type": "application/json" } });
|
|
2039
|
-
},
|
|
2040
|
-
});
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
test("returns valid on HTTP 200", async () => {
|
|
2044
|
-
const server = serveStatus(200);
|
|
2045
|
-
try {
|
|
2046
|
-
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2047
|
-
expect(r.status).toBe("valid");
|
|
2048
|
-
} finally {
|
|
2049
|
-
server.stop(true);
|
|
2050
|
-
}
|
|
2051
|
-
});
|
|
2052
|
-
|
|
2053
|
-
test("returns invalid on HTTP 401", async () => {
|
|
2054
|
-
const server = serveStatus(401, JSON.stringify({ message: "Invalid token" }));
|
|
2055
|
-
try {
|
|
2056
|
-
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2057
|
-
expect(r.status).toBe("invalid");
|
|
2058
|
-
expect((r as { statusCode: number }).statusCode).toBe(401);
|
|
2059
|
-
} finally {
|
|
2060
|
-
server.stop(true);
|
|
2061
|
-
}
|
|
2062
|
-
});
|
|
2063
|
-
|
|
2064
|
-
test("returns invalid on HTTP 403", async () => {
|
|
2065
|
-
const server = serveStatus(403);
|
|
2066
|
-
try {
|
|
2067
|
-
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2068
|
-
expect(r.status).toBe("invalid");
|
|
2069
|
-
expect((r as { statusCode: number }).statusCode).toBe(403);
|
|
2070
|
-
} finally {
|
|
2071
|
-
server.stop(true);
|
|
2072
|
-
}
|
|
2073
|
-
});
|
|
2074
|
-
|
|
2075
|
-
test("returns unknown on HTTP 500 (not a definitive rejection)", async () => {
|
|
2076
|
-
const server = serveStatus(500);
|
|
2077
|
-
try {
|
|
2078
|
-
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2079
|
-
expect(r.status).toBe("unknown");
|
|
2080
|
-
expect((r as { detail: string }).detail).toMatch(/HTTP 500/);
|
|
2081
|
-
} finally {
|
|
2082
|
-
server.stop(true);
|
|
2083
|
-
}
|
|
2084
|
-
});
|
|
2085
|
-
|
|
2086
|
-
test("returns unknown on connection refused", async () => {
|
|
2087
|
-
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://127.0.0.1:1" }); // port 1 — connect refused
|
|
2088
|
-
expect(r.status).toBe("unknown");
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
test("returns unknown on timeout", async () => {
|
|
2092
|
-
const server = Bun.serve({
|
|
2093
|
-
hostname: "127.0.0.1",
|
|
2094
|
-
port: 0,
|
|
2095
|
-
async fetch() {
|
|
2096
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
2097
|
-
return new Response("[]");
|
|
2098
|
-
},
|
|
2099
|
-
});
|
|
2100
|
-
try {
|
|
2101
|
-
const r = await api.verifyApiKey({
|
|
2102
|
-
apiKey: "k",
|
|
2103
|
-
apiBaseUrl: `http://127.0.0.1:${server.port}`,
|
|
2104
|
-
timeoutMs: 100,
|
|
2105
|
-
});
|
|
2106
|
-
expect(r.status).toBe("unknown");
|
|
2107
|
-
} finally {
|
|
2108
|
-
server.stop(true);
|
|
2109
|
-
}
|
|
2110
|
-
});
|
|
2111
|
-
|
|
2112
|
-
test("does not send the key over plaintext HTTP to non-loopback hosts", async () => {
|
|
2113
|
-
const saved = process.env.CHECKUP_ALLOW_HTTP;
|
|
2114
|
-
delete process.env.CHECKUP_ALLOW_HTTP;
|
|
2115
|
-
try {
|
|
2116
|
-
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://example.com/api" });
|
|
2117
|
-
expect(r.status).toBe("unknown");
|
|
2118
|
-
expect((r as { detail: string }).detail).toMatch(/plaintext HTTP/);
|
|
2119
|
-
} finally {
|
|
2120
|
-
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
|
|
2121
|
-
}
|
|
2122
|
-
});
|
|
2123
|
-
});
|
|
2124
|
-
});
|
|
2125
|
-
|
|
2126
|
-
// Tests for checkup-summary module
|
|
2127
|
-
describe("checkup-summary", () => {
|
|
2128
|
-
const summary = require("../lib/checkup-summary");
|
|
2129
|
-
|
|
2130
|
-
test("generateCheckSummary for F003 with no issues", () => {
|
|
2131
|
-
const report = {
|
|
2132
|
-
results: {
|
|
2133
|
-
node1: {
|
|
2134
|
-
data: {
|
|
2135
|
-
db1: {
|
|
2136
|
-
dead_tuples_tables: [],
|
|
2137
|
-
total_count: 0,
|
|
2138
|
-
flagged_count: 0,
|
|
2139
|
-
autovacuum_disabled_count: 0,
|
|
2140
|
-
autovacuum_disabled_flagged_count: 0,
|
|
2141
|
-
},
|
|
2142
|
-
},
|
|
2143
|
-
},
|
|
2144
|
-
},
|
|
2145
|
-
};
|
|
2146
|
-
const result = summary.generateCheckSummary("F003", report);
|
|
2147
|
-
expect(result.status).toBe("ok");
|
|
2148
|
-
expect(result.message).toMatch(/no significant dead tuple/i);
|
|
2149
|
-
});
|
|
2150
|
-
|
|
2151
|
-
test("generateCheckSummary for F003 ignores tiny disabled-autovacuum tables", () => {
|
|
2152
|
-
const report = {
|
|
2153
|
-
results: {
|
|
2154
|
-
node1: {
|
|
2155
|
-
data: {
|
|
2156
|
-
db1: {
|
|
2157
|
-
dead_tuples_tables: [],
|
|
2158
|
-
total_count: 2,
|
|
2159
|
-
flagged_count: 0,
|
|
2160
|
-
autovacuum_disabled_count: 2,
|
|
2161
|
-
autovacuum_disabled_flagged_count: 0,
|
|
2162
|
-
},
|
|
2163
|
-
},
|
|
2164
|
-
},
|
|
2165
|
-
},
|
|
2166
|
-
};
|
|
2167
|
-
const result = summary.generateCheckSummary("F003", report);
|
|
2168
|
-
expect(result.status).toBe("ok");
|
|
2169
|
-
});
|
|
2170
|
-
|
|
2171
|
-
test("generateCheckSummary for F003 with problems", () => {
|
|
2172
|
-
const report = {
|
|
2173
|
-
results: {
|
|
2174
|
-
node1: {
|
|
2175
|
-
data: {
|
|
2176
|
-
db1: {
|
|
2177
|
-
dead_tuples_tables: [],
|
|
2178
|
-
total_count: 3,
|
|
2179
|
-
flagged_count: 1,
|
|
2180
|
-
autovacuum_disabled_count: 2,
|
|
2181
|
-
autovacuum_disabled_flagged_count: 2,
|
|
2182
|
-
},
|
|
2183
|
-
},
|
|
2184
|
-
},
|
|
2185
|
-
},
|
|
2186
|
-
};
|
|
2187
|
-
const result = summary.generateCheckSummary("F003", report);
|
|
2188
|
-
expect(result.status).toBe("warning");
|
|
2189
|
-
expect(result.message).toBe(
|
|
2190
|
-
"1 table with excessive dead tuples, 2 tables with autovacuum disabled"
|
|
2191
|
-
);
|
|
2192
|
-
});
|
|
2193
|
-
|
|
2194
|
-
for (const checkId of ["F004", "F005"]) {
|
|
2195
|
-
test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
|
|
2196
|
-
const report = {
|
|
2197
|
-
results: {
|
|
2198
|
-
node1: {
|
|
2199
|
-
data: {
|
|
2200
|
-
db1: {
|
|
2201
|
-
status: {
|
|
2202
|
-
ok: false,
|
|
2203
|
-
reason: "missing_schema",
|
|
2204
|
-
error: 'schema "postgres_ai" does not exist',
|
|
2205
|
-
},
|
|
2206
|
-
total_count: 0,
|
|
2207
|
-
},
|
|
2208
|
-
},
|
|
2209
|
-
},
|
|
2210
|
-
},
|
|
2211
|
-
};
|
|
2212
|
-
|
|
2213
|
-
const result = summary.generateCheckSummary(checkId, report);
|
|
2214
|
-
expect(result.status).toBe("warning");
|
|
2215
|
-
expect(result.message).toMatch(/degraded.*missing schema/i);
|
|
2216
|
-
});
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
test("generateCheckSummary for H001 with no issues", () => {
|
|
2220
|
-
const report = {
|
|
2221
|
-
results: {
|
|
2222
|
-
"node1": {
|
|
2223
|
-
data: {
|
|
2224
|
-
"db1": {
|
|
2225
|
-
invalid_indexes: [],
|
|
2226
|
-
total_count: 0,
|
|
2227
|
-
total_size_bytes: 0,
|
|
2228
|
-
total_size_pretty: "0 bytes",
|
|
2229
|
-
database_size_bytes: 1000000,
|
|
2230
|
-
database_size_pretty: "1 MB"
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
const result = summary.generateCheckSummary("H001", report);
|
|
2237
|
-
expect(result.status).toBe("ok");
|
|
2238
|
-
expect(result.message).toMatch(/no invalid/i);
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
|
-
test("generateCheckSummary for H001 with invalid indexes", () => {
|
|
2242
|
-
const report = {
|
|
2243
|
-
results: {
|
|
2244
|
-
"node1": {
|
|
2245
|
-
data: {
|
|
2246
|
-
"db1": {
|
|
2247
|
-
invalid_indexes: [{}, {}, {}],
|
|
2248
|
-
total_count: 3,
|
|
2249
|
-
total_size_bytes: 1024 * 1024 * 245,
|
|
2250
|
-
total_size_pretty: "245 MiB",
|
|
2251
|
-
database_size_bytes: 1000000000,
|
|
2252
|
-
database_size_pretty: "1 GB"
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
};
|
|
2258
|
-
const result = summary.generateCheckSummary("H001", report);
|
|
2259
|
-
expect(result.status).toBe("warning");
|
|
2260
|
-
expect(result.message).toMatch(/3 invalid indexes/i);
|
|
2261
|
-
expect(result.message).toMatch(/245 MiB/i);
|
|
2262
|
-
});
|
|
2263
|
-
|
|
2264
|
-
test("generateCheckSummary for H002 with no issues", () => {
|
|
2265
|
-
const report = {
|
|
2266
|
-
results: {
|
|
2267
|
-
"node1": {
|
|
2268
|
-
data: {
|
|
2269
|
-
"db1": {
|
|
2270
|
-
unused_indexes: [],
|
|
2271
|
-
total_count: 0,
|
|
2272
|
-
total_size_bytes: 0,
|
|
2273
|
-
total_size_pretty: "0 bytes",
|
|
2274
|
-
database_size_bytes: 1000000,
|
|
2275
|
-
database_size_pretty: "1 MB",
|
|
2276
|
-
stats_reset: {}
|
|
2277
|
-
}
|
|
2278
|
-
}
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
};
|
|
2282
|
-
const result = summary.generateCheckSummary("H002", report);
|
|
2283
|
-
expect(result.status).toBe("ok");
|
|
2284
|
-
expect(result.message).toMatch(/all indexes utilized/i);
|
|
2285
|
-
});
|
|
2286
|
-
|
|
2287
|
-
test("generateCheckSummary for H002 with unused indexes", () => {
|
|
2288
|
-
const report = {
|
|
2289
|
-
results: {
|
|
2290
|
-
"node1": {
|
|
2291
|
-
data: {
|
|
2292
|
-
"db1": {
|
|
2293
|
-
unused_indexes: [{}, {}],
|
|
2294
|
-
total_count: 2,
|
|
2295
|
-
total_size_bytes: 1024 * 1024 * 150,
|
|
2296
|
-
total_size_pretty: "150 MiB",
|
|
2297
|
-
database_size_bytes: 1000000000,
|
|
2298
|
-
database_size_pretty: "1 GB",
|
|
2299
|
-
stats_reset: {}
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
};
|
|
2305
|
-
const result = summary.generateCheckSummary("H002", report);
|
|
2306
|
-
expect(result.status).toBe("warning");
|
|
2307
|
-
expect(result.message).toMatch(/2 unused indexes/i);
|
|
2308
|
-
expect(result.message).toMatch(/150 MiB/i);
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
test("generateCheckSummary for H004 with redundant indexes", () => {
|
|
2312
|
-
const report = {
|
|
2313
|
-
results: {
|
|
2314
|
-
"node1": {
|
|
2315
|
-
data: {
|
|
2316
|
-
"db1": {
|
|
2317
|
-
redundant_indexes: [{}, {}, {}, {}],
|
|
2318
|
-
total_count: 4,
|
|
2319
|
-
total_size_bytes: 1024 * 1024 * 1024 * 1.2,
|
|
2320
|
-
total_size_pretty: "1.2 GiB",
|
|
2321
|
-
database_size_bytes: 10000000000,
|
|
2322
|
-
database_size_pretty: "10 GB"
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
};
|
|
2328
|
-
const result = summary.generateCheckSummary("H004", report);
|
|
2329
|
-
expect(result.status).toBe("warning");
|
|
2330
|
-
expect(result.message).toMatch(/4 redundant indexes/i);
|
|
2331
|
-
expect(result.message).toMatch(/1\.2 GiB/i);
|
|
2332
|
-
});
|
|
2333
|
-
|
|
2334
|
-
test("generateCheckSummary for A003 (settings)", () => {
|
|
2335
|
-
const report = {
|
|
2336
|
-
results: {
|
|
2337
|
-
"node1": {
|
|
2338
|
-
data: {
|
|
2339
|
-
"setting1": "value1",
|
|
2340
|
-
"setting2": "value2"
|
|
2341
|
-
}
|
|
2342
|
-
}
|
|
2343
|
-
}
|
|
2344
|
-
};
|
|
2345
|
-
const result = summary.generateCheckSummary("A003", report);
|
|
2346
|
-
expect(result.status).toBe("info");
|
|
2347
|
-
expect(result.message).toBe("2 settings collected");
|
|
2348
|
-
});
|
|
2349
|
-
|
|
2350
|
-
test("generateCheckSummary for A002 with PostgreSQL 17", () => {
|
|
2351
|
-
const report = {
|
|
2352
|
-
results: {
|
|
2353
|
-
"node1": {
|
|
2354
|
-
data: {
|
|
2355
|
-
version: {
|
|
2356
|
-
version: "17.2",
|
|
2357
|
-
server_version_num: "170002",
|
|
2358
|
-
server_major_ver: "17",
|
|
2359
|
-
server_minor_ver: "2"
|
|
2360
|
-
}
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2364
|
-
};
|
|
2365
|
-
const result = summary.generateCheckSummary("A002", report);
|
|
2366
|
-
expect(result.status).toBe("ok");
|
|
2367
|
-
expect(result.message).toBe("PostgreSQL 17");
|
|
2368
|
-
});
|
|
2369
|
-
|
|
2370
|
-
test("generateCheckSummary for A002 with PostgreSQL 15", () => {
|
|
2371
|
-
const report = {
|
|
2372
|
-
results: {
|
|
2373
|
-
"node1": {
|
|
2374
|
-
data: {
|
|
2375
|
-
version: {
|
|
2376
|
-
version: "15.4",
|
|
2377
|
-
server_version_num: "150004",
|
|
2378
|
-
server_major_ver: "15",
|
|
2379
|
-
server_minor_ver: "4"
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
};
|
|
2385
|
-
const result = summary.generateCheckSummary("A002", report);
|
|
2386
|
-
expect(result.status).toBe("info");
|
|
2387
|
-
expect(result.message).toBe("PostgreSQL 15");
|
|
2388
|
-
});
|
|
2389
|
-
|
|
2390
|
-
test("generateCheckSummary for A002 with old PostgreSQL 11", () => {
|
|
2391
|
-
const report = {
|
|
2392
|
-
results: {
|
|
2393
|
-
"node1": {
|
|
2394
|
-
data: {
|
|
2395
|
-
version: {
|
|
2396
|
-
version: "11.8",
|
|
2397
|
-
server_version_num: "110008",
|
|
2398
|
-
server_major_ver: "11",
|
|
2399
|
-
server_minor_ver: "8"
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
}
|
|
2404
|
-
};
|
|
2405
|
-
const result = summary.generateCheckSummary("A002", report);
|
|
2406
|
-
expect(result.status).toBe("warning");
|
|
2407
|
-
expect(result.message).toMatch(/PostgreSQL 11.*consider upgrading/i);
|
|
2408
|
-
});
|
|
2409
|
-
|
|
2410
|
-
test("generateCheckSummary for A013 with version", () => {
|
|
2411
|
-
const report = {
|
|
2412
|
-
results: {
|
|
2413
|
-
"node1": {
|
|
2414
|
-
data: {
|
|
2415
|
-
version: {
|
|
2416
|
-
version: "17.2",
|
|
2417
|
-
server_version_num: "170002",
|
|
2418
|
-
server_major_ver: "17",
|
|
2419
|
-
server_minor_ver: "2"
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
};
|
|
2425
|
-
const result = summary.generateCheckSummary("A013", report);
|
|
2426
|
-
expect(result.status).toBe("info");
|
|
2427
|
-
expect(result.message).toBe("Version 17.2");
|
|
2428
|
-
});
|
|
2429
|
-
|
|
2430
|
-
test("generateCheckSummary handles empty results", () => {
|
|
2431
|
-
const report = { results: {} };
|
|
2432
|
-
const result = summary.generateCheckSummary("H001", report);
|
|
2433
|
-
expect(result.status).toBe("info");
|
|
2434
|
-
expect(result.message).toBe("No data");
|
|
2435
|
-
});
|
|
2436
|
-
|
|
2437
|
-
test("generateCheckSummary handles unknown check ID", () => {
|
|
2438
|
-
const report = {
|
|
2439
|
-
results: {
|
|
2440
|
-
"node1": { data: {} }
|
|
2441
|
-
}
|
|
2442
|
-
};
|
|
2443
|
-
const result = summary.generateCheckSummary("UNKNOWN", report);
|
|
2444
|
-
expect(result.status).toBe("info");
|
|
2445
|
-
expect(result.message).toBe("Check completed");
|
|
2446
|
-
});
|
|
2447
|
-
|
|
2448
|
-
test("generateCheckSummary for D001 (logging settings)", () => {
|
|
2449
|
-
const report = {
|
|
2450
|
-
results: {
|
|
2451
|
-
"node1": {
|
|
2452
|
-
data: {
|
|
2453
|
-
"log_destination": { value: "stderr" },
|
|
2454
|
-
"log_line_prefix": { value: "%m [%p] " }
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
};
|
|
2459
|
-
const result = summary.generateCheckSummary("D001", report);
|
|
2460
|
-
expect(result.status).toBe("info");
|
|
2461
|
-
expect(result.message).toBe("2 logging settings collected");
|
|
2462
|
-
});
|
|
2463
|
-
|
|
2464
|
-
test("generateCheckSummary for D004 (pg_stat_statements)", () => {
|
|
2465
|
-
const report = {
|
|
2466
|
-
results: {
|
|
2467
|
-
"node1": {
|
|
2468
|
-
data: {
|
|
2469
|
-
"pg_stat_statements.max": { value: "5000" },
|
|
2470
|
-
"pg_stat_statements.track": { value: "all" }
|
|
2471
|
-
}
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
};
|
|
2475
|
-
const result = summary.generateCheckSummary("D004", report);
|
|
2476
|
-
expect(result.status).toBe("info");
|
|
2477
|
-
expect(result.message).toBe("2 pg_stat_statements settings collected");
|
|
2478
|
-
});
|
|
2479
|
-
|
|
2480
|
-
test("generateCheckSummary for F001 (autovacuum)", () => {
|
|
2481
|
-
const report = {
|
|
2482
|
-
results: {
|
|
2483
|
-
"node1": {
|
|
2484
|
-
data: {
|
|
2485
|
-
"autovacuum": { value: "on" },
|
|
2486
|
-
"autovacuum_max_workers": { value: "3" }
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
}
|
|
2490
|
-
};
|
|
2491
|
-
const result = summary.generateCheckSummary("F001", report);
|
|
2492
|
-
expect(result.status).toBe("info");
|
|
2493
|
-
expect(result.message).toBe("2 autovacuum settings collected");
|
|
2494
|
-
});
|
|
2495
|
-
|
|
2496
|
-
test("generateCheckSummary for G001 (memory settings)", () => {
|
|
2497
|
-
const report = {
|
|
2498
|
-
results: {
|
|
2499
|
-
"node1": {
|
|
2500
|
-
data: {
|
|
2501
|
-
"shared_buffers": { value: "128MB" },
|
|
2502
|
-
"work_mem": { value: "4MB" }
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
}
|
|
2506
|
-
};
|
|
2507
|
-
const result = summary.generateCheckSummary("G001", report);
|
|
2508
|
-
expect(result.status).toBe("info");
|
|
2509
|
-
expect(result.message).toBe("2 memory settings collected");
|
|
2510
|
-
});
|
|
2511
|
-
|
|
2512
|
-
test("generateCheckSummary for G003 with deadlocks", () => {
|
|
2513
|
-
const report = {
|
|
2514
|
-
results: {
|
|
2515
|
-
"node1": {
|
|
2516
|
-
data: {
|
|
2517
|
-
settings: {
|
|
2518
|
-
"lock_timeout": { value: "0" }
|
|
2519
|
-
},
|
|
2520
|
-
deadlock_stats: {
|
|
2521
|
-
deadlocks: 5,
|
|
2522
|
-
conflicts: 0,
|
|
2523
|
-
stats_reset: "2025-01-01"
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
};
|
|
2529
|
-
const result = summary.generateCheckSummary("G003", report);
|
|
2530
|
-
expect(result.status).toBe("warning");
|
|
2531
|
-
expect(result.message).toBe("5 deadlocks detected");
|
|
2532
|
-
});
|
|
2533
|
-
|
|
2534
|
-
test("generateCheckSummary for G003 without deadlocks", () => {
|
|
2535
|
-
const report = {
|
|
2536
|
-
results: {
|
|
2537
|
-
"node1": {
|
|
2538
|
-
data: {
|
|
2539
|
-
settings: {
|
|
2540
|
-
"lock_timeout": { value: "0" },
|
|
2541
|
-
"statement_timeout": { value: "0" }
|
|
2542
|
-
},
|
|
2543
|
-
deadlock_stats: {
|
|
2544
|
-
deadlocks: 0,
|
|
2545
|
-
conflicts: 0
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
};
|
|
2551
|
-
const result = summary.generateCheckSummary("G003", report);
|
|
2552
|
-
expect(result.status).toBe("info");
|
|
2553
|
-
expect(result.message).toBe("2 timeout/lock settings collected");
|
|
2554
|
-
});
|
|
2555
|
-
|
|
2556
|
-
// Edge cases: empty data
|
|
2557
|
-
test("generateCheckSummary for A003 with no settings", () => {
|
|
2558
|
-
const report = {
|
|
2559
|
-
results: {
|
|
2560
|
-
"node1": {
|
|
2561
|
-
data: {}
|
|
2562
|
-
}
|
|
2563
|
-
}
|
|
2564
|
-
};
|
|
2565
|
-
const result = summary.generateCheckSummary("A003", report);
|
|
2566
|
-
expect(result.status).toBe("info");
|
|
2567
|
-
expect(result.message).toBe("No settings found");
|
|
2568
|
-
});
|
|
2569
|
-
|
|
2570
|
-
test("generateCheckSummary for A004 with no data", () => {
|
|
2571
|
-
const report = {
|
|
2572
|
-
results: {
|
|
2573
|
-
"node1": {}
|
|
2574
|
-
}
|
|
2575
|
-
};
|
|
2576
|
-
const result = summary.generateCheckSummary("A004", report);
|
|
2577
|
-
expect(result.status).toBe("info");
|
|
2578
|
-
expect(result.message).toBe("Cluster information collected");
|
|
2579
|
-
});
|
|
2580
|
-
|
|
2581
|
-
test("generateCheckSummary for A004 with no database_sizes", () => {
|
|
2582
|
-
const report = {
|
|
2583
|
-
results: {
|
|
2584
|
-
"node1": {
|
|
2585
|
-
data: {
|
|
2586
|
-
general_info: {}
|
|
2587
|
-
}
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
};
|
|
2591
|
-
const result = summary.generateCheckSummary("A004", report);
|
|
2592
|
-
expect(result.status).toBe("info");
|
|
2593
|
-
expect(result.message).toBe("Cluster information collected");
|
|
2594
|
-
});
|
|
2595
|
-
|
|
2596
|
-
test("generateCheckSummary for A007 with no altered settings", () => {
|
|
2597
|
-
const report = {
|
|
2598
|
-
results: {
|
|
2599
|
-
"node1": {
|
|
2600
|
-
data: {}
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2603
|
-
};
|
|
2604
|
-
const result = summary.generateCheckSummary("A007", report);
|
|
2605
|
-
expect(result.status).toBe("ok");
|
|
2606
|
-
expect(result.message).toBe("No altered settings");
|
|
2607
|
-
});
|
|
2608
|
-
|
|
2609
|
-
test("generateCheckSummary for A002 with no version data", () => {
|
|
2610
|
-
const report = {
|
|
2611
|
-
results: {
|
|
2612
|
-
"node1": {
|
|
2613
|
-
data: {}
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
|
-
};
|
|
2617
|
-
const result = summary.generateCheckSummary("A002", report);
|
|
2618
|
-
expect(result.status).toBe("info");
|
|
2619
|
-
expect(result.message).toBe("Version checked");
|
|
2620
|
-
});
|
|
2621
|
-
|
|
2622
|
-
test("generateCheckSummary for D001 with no settings", () => {
|
|
2623
|
-
const report = {
|
|
2624
|
-
results: {
|
|
2625
|
-
"node1": {
|
|
2626
|
-
data: {}
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
};
|
|
2630
|
-
const result = summary.generateCheckSummary("D001", report);
|
|
2631
|
-
expect(result.status).toBe("info");
|
|
2632
|
-
expect(result.message).toBe("No logging settings found");
|
|
2633
|
-
});
|
|
2634
|
-
|
|
2635
|
-
test("generateCheckSummary for D004 with no settings", () => {
|
|
2636
|
-
const report = {
|
|
2637
|
-
results: {
|
|
2638
|
-
"node1": {
|
|
2639
|
-
data: {}
|
|
2640
|
-
}
|
|
2641
|
-
}
|
|
2642
|
-
};
|
|
2643
|
-
const result = summary.generateCheckSummary("D004", report);
|
|
2644
|
-
expect(result.status).toBe("info");
|
|
2645
|
-
expect(result.message).toBe("No pg_stat_statements settings found");
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
test("generateCheckSummary for F001 with no settings", () => {
|
|
2649
|
-
const report = {
|
|
2650
|
-
results: {
|
|
2651
|
-
"node1": {
|
|
2652
|
-
data: {}
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
};
|
|
2656
|
-
const result = summary.generateCheckSummary("F001", report);
|
|
2657
|
-
expect(result.status).toBe("info");
|
|
2658
|
-
expect(result.message).toBe("No autovacuum settings found");
|
|
2659
|
-
});
|
|
2660
|
-
|
|
2661
|
-
test("generateCheckSummary for G001 with no settings", () => {
|
|
2662
|
-
const report = {
|
|
2663
|
-
results: {
|
|
2664
|
-
"node1": {
|
|
2665
|
-
data: {}
|
|
2666
|
-
}
|
|
2667
|
-
}
|
|
2668
|
-
};
|
|
2669
|
-
const result = summary.generateCheckSummary("G001", report);
|
|
2670
|
-
expect(result.status).toBe("info");
|
|
2671
|
-
expect(result.message).toBe("No memory settings found");
|
|
2672
|
-
});
|
|
2673
|
-
|
|
2674
|
-
test("generateCheckSummary for G003 with no settings or deadlock_stats", () => {
|
|
2675
|
-
const report = {
|
|
2676
|
-
results: {
|
|
2677
|
-
"node1": {
|
|
2678
|
-
data: {}
|
|
2679
|
-
}
|
|
2680
|
-
}
|
|
2681
|
-
};
|
|
2682
|
-
const result = summary.generateCheckSummary("G003", report);
|
|
2683
|
-
expect(result.status).toBe("info");
|
|
2684
|
-
expect(result.message).toBe("No timeout/lock settings found");
|
|
2685
|
-
});
|
|
2686
|
-
|
|
2687
|
-
test("generateCheckSummary for H001 with no invalid indexes", () => {
|
|
2688
|
-
const report = {
|
|
2689
|
-
results: {
|
|
2690
|
-
"node1": {
|
|
2691
|
-
data: {
|
|
2692
|
-
"db1": {
|
|
2693
|
-
invalid_indexes: [],
|
|
2694
|
-
total_count: 0,
|
|
2695
|
-
total_size_bytes: 0
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
2700
|
-
};
|
|
2701
|
-
const result = summary.generateCheckSummary("H001", report);
|
|
2702
|
-
expect(result.status).toBe("ok");
|
|
2703
|
-
expect(result.message).toBe("No invalid indexes");
|
|
2704
|
-
});
|
|
2705
|
-
|
|
2706
|
-
test("generateCheckSummary for H002 with all indexes utilized", () => {
|
|
2707
|
-
const report = {
|
|
2708
|
-
results: {
|
|
2709
|
-
"node1": {
|
|
2710
|
-
data: {
|
|
2711
|
-
"db1": {
|
|
2712
|
-
unused_indexes: [],
|
|
2713
|
-
total_count: 0,
|
|
2714
|
-
total_size_bytes: 0
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
};
|
|
2720
|
-
const result = summary.generateCheckSummary("H002", report);
|
|
2721
|
-
expect(result.status).toBe("ok");
|
|
2722
|
-
expect(result.message).toBe("All indexes utilized");
|
|
2723
|
-
});
|
|
2724
|
-
|
|
2725
|
-
test("generateCheckSummary for H004 with no redundant indexes", () => {
|
|
2726
|
-
const report = {
|
|
2727
|
-
results: {
|
|
2728
|
-
"node1": {
|
|
2729
|
-
data: {
|
|
2730
|
-
"db1": {
|
|
2731
|
-
redundant_indexes: [],
|
|
2732
|
-
total_count: 0,
|
|
2733
|
-
total_size_bytes: 0
|
|
2734
|
-
}
|
|
2735
|
-
}
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
};
|
|
2739
|
-
const result = summary.generateCheckSummary("H004", report);
|
|
2740
|
-
expect(result.status).toBe("ok");
|
|
2741
|
-
expect(result.message).toBe("No redundant indexes");
|
|
2742
|
-
});
|
|
2743
|
-
});
|
|
2744
|
-
|
|
2745
|
-
// Postgres version compatibility tests (PG13-PG18)
|
|
2746
|
-
describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
2747
|
-
/**
|
|
2748
|
-
* Version-matrix fixture invariants:
|
|
2749
|
-
* - version_num uses Postgres major * 10000 + minor encoding.
|
|
2750
|
-
* - shared_buffers 16384 * 8kB is 128 MiB.
|
|
2751
|
-
* - days_since_reset is derived by production code from the stats_reset metric's
|
|
2752
|
-
* seconds_since_reset field, not from Date.now().
|
|
2753
|
-
* - postmaster uptime, uptime text, and postmaster startup timestamp are derived
|
|
2754
|
-
* from TEST_NOW_EPOCH and POSTMASTER_STARTUP_EPOCH and must stay in sync.
|
|
2755
|
-
*/
|
|
2756
|
-
const createVersionMockData = (major: number, minor: number) => ({
|
|
2757
|
-
versionRows: [
|
|
2758
|
-
{ name: "server_version", setting: `${major}.${minor}` },
|
|
2759
|
-
{ name: "server_version_num", setting: `${major}${String(minor).padStart(4, "0")}` },
|
|
2760
|
-
],
|
|
2761
|
-
settingsRows: [
|
|
2762
|
-
{
|
|
2763
|
-
tag_setting_name: "shared_buffers",
|
|
2764
|
-
tag_setting_value: "16384",
|
|
2765
|
-
tag_unit: "8kB",
|
|
2766
|
-
tag_category: "Resource Usage / Memory",
|
|
2767
|
-
tag_vartype: "integer",
|
|
2768
|
-
is_default: 0,
|
|
2769
|
-
setting_normalized: "134217728",
|
|
2770
|
-
unit_normalized: "bytes",
|
|
2771
|
-
},
|
|
2772
|
-
{
|
|
2773
|
-
tag_setting_name: "autovacuum_vacuum_scale_factor",
|
|
2774
|
-
tag_setting_value: "0.2",
|
|
2775
|
-
tag_unit: "",
|
|
2776
|
-
tag_category: "Autovacuum",
|
|
2777
|
-
tag_vartype: "real",
|
|
2778
|
-
is_default: 0,
|
|
2779
|
-
setting_normalized: null,
|
|
2780
|
-
unit_normalized: null,
|
|
2781
|
-
},
|
|
2782
|
-
{
|
|
2783
|
-
tag_setting_name: "log_min_duration_statement",
|
|
2784
|
-
tag_setting_value: "-1",
|
|
2785
|
-
tag_unit: "ms",
|
|
2786
|
-
tag_category: "Reporting and Logging / When to Log",
|
|
2787
|
-
tag_vartype: "integer",
|
|
2788
|
-
is_default: 1,
|
|
2789
|
-
setting_normalized: null,
|
|
2790
|
-
unit_normalized: null,
|
|
2791
|
-
},
|
|
2792
|
-
{
|
|
2793
|
-
tag_setting_name: "deadlock_timeout",
|
|
2794
|
-
tag_setting_value: "1000",
|
|
2795
|
-
tag_unit: "ms",
|
|
2796
|
-
tag_category: "Lock Management",
|
|
2797
|
-
tag_vartype: "integer",
|
|
2798
|
-
is_default: 1,
|
|
2799
|
-
setting_normalized: null,
|
|
2800
|
-
unit_normalized: null,
|
|
2801
|
-
},
|
|
2802
|
-
],
|
|
2803
|
-
databaseSizesRows: [{ datname: "postgres", size_bytes: "1073741824" }],
|
|
2804
|
-
dbStatsRows: [{
|
|
2805
|
-
numbackends: 5,
|
|
2806
|
-
xact_commit: 100,
|
|
2807
|
-
xact_rollback: 1,
|
|
2808
|
-
blks_read: 100,
|
|
2809
|
-
blks_hit: 900,
|
|
2810
|
-
tup_returned: 500,
|
|
2811
|
-
tup_fetched: 400,
|
|
2812
|
-
tup_inserted: 50,
|
|
2813
|
-
tup_updated: 30,
|
|
2814
|
-
tup_deleted: 10,
|
|
2815
|
-
deadlocks: 0,
|
|
2816
|
-
temp_files: 0,
|
|
2817
|
-
temp_bytes: 0,
|
|
2818
|
-
postmaster_uptime_s: POSTMASTER_UPTIME_SECONDS,
|
|
2819
|
-
}],
|
|
2820
|
-
connectionStatesRows: [{ state: "active", count: 2 }, { state: "idle", count: 3 }],
|
|
2821
|
-
uptimeRows: [{ start_time: new Date(POSTMASTER_STARTUP_EPOCH * 1000), uptime: `${DAYS_SINCE_RESET} days` }],
|
|
2822
|
-
invalidIndexesRows: [],
|
|
2823
|
-
unusedIndexesRows: [],
|
|
2824
|
-
redundantIndexesRows: [],
|
|
2825
|
-
});
|
|
2826
|
-
|
|
2827
|
-
const pgVersions = SUPPORTED_PG_VERSIONS;
|
|
2828
|
-
|
|
2829
|
-
// A003 surfaces the full pg_settings projection. A007 uses value, not setting,
|
|
2830
|
-
// and intentionally omits context/vartype because altered settings expose a smaller shape.
|
|
2831
|
-
const expectedSharedBuffersSetting = {
|
|
2832
|
-
setting: "16384",
|
|
2833
|
-
unit: "8kB",
|
|
2834
|
-
category: "Resource Usage / Memory",
|
|
2835
|
-
context: "",
|
|
2836
|
-
vartype: "integer",
|
|
2837
|
-
pretty_value: "128.00 MiB",
|
|
2838
|
-
};
|
|
2839
|
-
|
|
2840
|
-
const expectedSharedBuffersAlteredSetting = {
|
|
2841
|
-
value: "16384",
|
|
2842
|
-
unit: "8kB",
|
|
2843
|
-
category: "Resource Usage / Memory",
|
|
2844
|
-
pretty_value: "128.00 MiB",
|
|
2845
|
-
};
|
|
2846
|
-
|
|
2847
|
-
const expectedAutovacuumSetting = {
|
|
2848
|
-
setting: "0.2",
|
|
2849
|
-
unit: "",
|
|
2850
|
-
category: "Autovacuum",
|
|
2851
|
-
context: "",
|
|
2852
|
-
vartype: "real",
|
|
2853
|
-
pretty_value: "0.2",
|
|
2854
|
-
};
|
|
2855
|
-
|
|
2856
|
-
const expectedAutovacuumAlteredSetting = {
|
|
2857
|
-
value: "0.2",
|
|
2858
|
-
unit: "",
|
|
2859
|
-
category: "Autovacuum",
|
|
2860
|
-
pretty_value: "0.2",
|
|
2861
|
-
};
|
|
2862
|
-
|
|
2863
|
-
const expectedLogMinDurationSetting = {
|
|
2864
|
-
setting: "-1",
|
|
2865
|
-
unit: "ms",
|
|
2866
|
-
category: "Reporting and Logging / When to Log",
|
|
2867
|
-
context: "",
|
|
2868
|
-
vartype: "integer",
|
|
2869
|
-
pretty_value: "-1",
|
|
2870
|
-
};
|
|
2871
|
-
|
|
2872
|
-
const expectedDeadlockTimeoutSetting = {
|
|
2873
|
-
setting: "1000",
|
|
2874
|
-
unit: "ms",
|
|
2875
|
-
category: "Lock Management",
|
|
2876
|
-
context: "",
|
|
2877
|
-
vartype: "integer",
|
|
2878
|
-
pretty_value: "1000",
|
|
2879
|
-
};
|
|
2880
|
-
|
|
2881
|
-
const expectedDatabaseSizeBytes = 1073741824;
|
|
2882
|
-
|
|
2883
|
-
describe("getPostgresVersion extracts correct version for each PG version", () => {
|
|
2884
|
-
for (const { major, minor, versionNum } of pgVersions) {
|
|
2885
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2886
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2887
|
-
const version = await checkup.getPostgresVersion(mockClient as any);
|
|
2888
|
-
|
|
2889
|
-
expect(version.version).toBe(`${major}.${minor}`);
|
|
2890
|
-
expect(version.server_version_num).toBe(versionNum);
|
|
2891
|
-
expect(version.server_major_ver).toBe(String(major));
|
|
2892
|
-
expect(version.server_minor_ver).toBe(String(minor));
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
|
-
});
|
|
2896
|
-
|
|
2897
|
-
describe("generateA002 (major version) works for each PG version", () => {
|
|
2898
|
-
for (const { major, minor } of pgVersions) {
|
|
2899
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2900
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2901
|
-
const report = await checkup.generateA002(mockClient as any, "test-node");
|
|
2902
|
-
|
|
2903
|
-
expect(report.checkId).toBe("A002");
|
|
2904
|
-
expect(report.checkTitle).toBe("Postgres major version");
|
|
2905
|
-
expect(report.results["test-node"].data.version.version).toBe(`${major}.${minor}`);
|
|
2906
|
-
expect(report.results["test-node"].data.version.server_major_ver).toBe(String(major));
|
|
2907
|
-
expect(report.results["test-node"].data.version.server_minor_ver).toBe(String(minor));
|
|
2908
|
-
});
|
|
2909
|
-
}
|
|
2910
|
-
});
|
|
2911
|
-
|
|
2912
|
-
describe("generateA013 (minor version) works for each PG version", () => {
|
|
2913
|
-
for (const { major, minor } of pgVersions) {
|
|
2914
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2915
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2916
|
-
const report = await checkup.generateA013(mockClient as any, "test-node");
|
|
2917
|
-
|
|
2918
|
-
expect(report.checkId).toBe("A013");
|
|
2919
|
-
expect(report.checkTitle).toBe("Postgres minor version");
|
|
2920
|
-
expect(report.results["test-node"].data.version.server_minor_ver).toBe(String(minor));
|
|
2921
|
-
expect(report.results["test-node"].data.version.version).toBe(`${major}.${minor}`);
|
|
2922
|
-
});
|
|
2923
|
-
}
|
|
2924
|
-
});
|
|
2925
|
-
|
|
2926
|
-
describe("generateA003 (settings) works for each PG version", () => {
|
|
2927
|
-
for (const { major, minor } of pgVersions) {
|
|
2928
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2929
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2930
|
-
const report = await checkup.generateA003(mockClient as any, "test-node");
|
|
2931
|
-
|
|
2932
|
-
expect(report.checkId).toBe("A003");
|
|
2933
|
-
expect(report.checkTitle).toBe("Postgres settings");
|
|
2934
|
-
expect(report.results["test-node"].data.shared_buffers).toEqual(expectedSharedBuffersSetting);
|
|
2935
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
2936
|
-
expect(report.results["test-node"].postgres_version?.server_major_ver).toBe(String(major));
|
|
2937
|
-
});
|
|
2938
|
-
}
|
|
2939
|
-
});
|
|
2940
|
-
|
|
2941
|
-
describe("generateA007 (altered settings) works for each PG version", () => {
|
|
2942
|
-
for (const { major, minor } of pgVersions) {
|
|
2943
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2944
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2945
|
-
const report = await checkup.generateA007(mockClient as any, "test-node");
|
|
2946
|
-
|
|
2947
|
-
expect(report.checkId).toBe("A007");
|
|
2948
|
-
expect(report.checkTitle).toBe("Altered settings");
|
|
2949
|
-
expect(report.results["test-node"].data.shared_buffers).toEqual(expectedSharedBuffersAlteredSetting);
|
|
2950
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
2951
|
-
});
|
|
2952
|
-
}
|
|
2953
|
-
});
|
|
2954
|
-
|
|
2955
|
-
describe("generateA004 (cluster info) works for each PG version", () => {
|
|
2956
|
-
for (const { major, minor } of pgVersions) {
|
|
2957
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2958
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2959
|
-
const report = await checkup.generateA004(mockClient as any, "test-node");
|
|
2960
|
-
|
|
2961
|
-
expect(report.checkId).toBe("A004");
|
|
2962
|
-
expect(report.checkTitle).toBe("Cluster information");
|
|
2963
|
-
const data = report.results["test-node"].data;
|
|
2964
|
-
expect(data.general_info.total_connections.value).toBe("5");
|
|
2965
|
-
expect(data.general_info.cache_hit_ratio.value).toBe("90.00");
|
|
2966
|
-
expect(data.general_info.connections_active.value).toBe("2");
|
|
2967
|
-
expect(data.database_sizes.postgres).toBe(expectedDatabaseSizeBytes);
|
|
2968
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
2969
|
-
});
|
|
2970
|
-
}
|
|
2971
|
-
});
|
|
2972
|
-
|
|
2973
|
-
describe("generateH001 (invalid indexes) works for each PG version", () => {
|
|
2974
|
-
for (const { major, minor } of pgVersions) {
|
|
2975
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2976
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2977
|
-
const report = await checkup.generateH001(mockClient as any, "test-node");
|
|
2978
|
-
|
|
2979
|
-
expect(report.checkId).toBe("H001");
|
|
2980
|
-
expect(report.checkTitle).toBe("Invalid indexes");
|
|
2981
|
-
expect(report.results["test-node"].data.testdb).toEqual({
|
|
2982
|
-
invalid_indexes: [],
|
|
2983
|
-
total_count: 0,
|
|
2984
|
-
total_size_bytes: 0,
|
|
2985
|
-
total_size_pretty: "0 B",
|
|
2986
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
2987
|
-
database_size_pretty: "1.00 GiB",
|
|
2988
|
-
});
|
|
2989
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
2990
|
-
});
|
|
2991
|
-
}
|
|
2992
|
-
});
|
|
2993
|
-
|
|
2994
|
-
describe("generateH002 (unused indexes) works for each PG version", () => {
|
|
2995
|
-
for (const { major, minor } of pgVersions) {
|
|
2996
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
2997
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
2998
|
-
const report = await checkup.generateH002(mockClient as any, "test-node");
|
|
2999
|
-
|
|
3000
|
-
expect(report.checkId).toBe("H002");
|
|
3001
|
-
expect(report.checkTitle).toBe("Unused indexes");
|
|
3002
|
-
expect(report.results["test-node"].data.testdb).toEqual({
|
|
3003
|
-
unused_indexes: [],
|
|
3004
|
-
total_count: 0,
|
|
3005
|
-
total_size_bytes: 0,
|
|
3006
|
-
total_size_pretty: "0 B",
|
|
3007
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3008
|
-
database_size_pretty: "1.00 GiB",
|
|
3009
|
-
stats_reset: {
|
|
3010
|
-
stats_reset_epoch: STATS_RESET_EPOCH,
|
|
3011
|
-
stats_reset_time: STATS_RESET_TIME,
|
|
3012
|
-
days_since_reset: DAYS_SINCE_RESET,
|
|
3013
|
-
postmaster_startup_epoch: STATS_RESET_EPOCH,
|
|
3014
|
-
postmaster_startup_time: POSTMASTER_STARTUP_TIME,
|
|
3015
|
-
},
|
|
3016
|
-
});
|
|
3017
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
3018
|
-
});
|
|
3019
|
-
}
|
|
3020
|
-
});
|
|
3021
|
-
|
|
3022
|
-
describe("generateH004 (redundant indexes) works for each PG version", () => {
|
|
3023
|
-
for (const { major, minor } of pgVersions) {
|
|
3024
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3025
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
3026
|
-
const report = await checkup.generateH004(mockClient as any, "test-node");
|
|
3027
|
-
|
|
3028
|
-
expect(report.checkId).toBe("H004");
|
|
3029
|
-
expect(report.checkTitle).toBe("Redundant indexes");
|
|
3030
|
-
expect(report.results["test-node"].data.testdb).toEqual({
|
|
3031
|
-
redundant_indexes: [],
|
|
3032
|
-
total_count: 0,
|
|
3033
|
-
total_size_bytes: 0,
|
|
3034
|
-
total_size_pretty: "0 B",
|
|
3035
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3036
|
-
database_size_pretty: "1.00 GiB",
|
|
3037
|
-
});
|
|
3038
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
3039
|
-
});
|
|
3040
|
-
}
|
|
3041
|
-
});
|
|
3042
|
-
|
|
3043
|
-
describe("index reports surface non-empty rows for each PG version", () => {
|
|
3044
|
-
for (const { major, minor } of pgVersions) {
|
|
3045
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3046
|
-
const mockClient = createMockClient({
|
|
3047
|
-
...createVersionMockData(major, minor),
|
|
3048
|
-
invalidIndexesRows: [
|
|
3049
|
-
{
|
|
3050
|
-
schema_name: "public",
|
|
3051
|
-
table_name: "orders",
|
|
3052
|
-
index_name: "orders_status_idx_invalid",
|
|
3053
|
-
relation_name: "orders",
|
|
3054
|
-
index_size_bytes: "2097152",
|
|
3055
|
-
index_definition: "CREATE INDEX orders_status_idx_invalid ON public.orders USING btree (status)",
|
|
3056
|
-
supports_fk: false,
|
|
3057
|
-
is_pk: false,
|
|
3058
|
-
is_unique: false,
|
|
3059
|
-
constraint_name: null,
|
|
3060
|
-
table_row_estimate: "50000",
|
|
3061
|
-
has_valid_duplicate: true,
|
|
3062
|
-
valid_index_name: "orders_status_idx",
|
|
3063
|
-
valid_index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)",
|
|
3064
|
-
},
|
|
3065
|
-
],
|
|
3066
|
-
unusedIndexesRows: [
|
|
3067
|
-
{
|
|
3068
|
-
schema_name: "public",
|
|
3069
|
-
table_name: "logs",
|
|
3070
|
-
index_name: "logs_created_idx",
|
|
3071
|
-
index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)",
|
|
3072
|
-
reason: "Never Used Indexes",
|
|
3073
|
-
index_size_bytes: "4194304",
|
|
3074
|
-
idx_scan: "0",
|
|
3075
|
-
idx_is_btree: true,
|
|
3076
|
-
supports_fk: false,
|
|
3077
|
-
},
|
|
3078
|
-
],
|
|
3079
|
-
redundantIndexesRows: [
|
|
3080
|
-
{
|
|
3081
|
-
schema_name: "public",
|
|
3082
|
-
table_name: "orders",
|
|
3083
|
-
index_name: "orders_user_id_idx",
|
|
3084
|
-
relation_name: "orders",
|
|
3085
|
-
access_method: "btree",
|
|
3086
|
-
reason: "public.orders_user_id_created_idx",
|
|
3087
|
-
index_size_bytes: "2097152",
|
|
3088
|
-
table_size_bytes: "16777216",
|
|
3089
|
-
index_usage: "0",
|
|
3090
|
-
supports_fk: false,
|
|
3091
|
-
index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)",
|
|
3092
|
-
redundant_to_json: JSON.stringify([
|
|
3093
|
-
{
|
|
3094
|
-
index_name: "public.orders_user_id_created_idx",
|
|
3095
|
-
index_definition: "CREATE INDEX orders_user_id_created_idx ON public.orders USING btree (user_id, created_at)",
|
|
3096
|
-
index_size_bytes: 1048576,
|
|
3097
|
-
},
|
|
3098
|
-
]),
|
|
3099
|
-
},
|
|
3100
|
-
],
|
|
3101
|
-
});
|
|
3102
|
-
|
|
3103
|
-
const invalidReport = await checkup.generateH001(mockClient as any, "test-node");
|
|
3104
|
-
expect(invalidReport.results["test-node"].data.testdb).toEqual({
|
|
3105
|
-
invalid_indexes: [
|
|
3106
|
-
{
|
|
3107
|
-
schema_name: "public",
|
|
3108
|
-
table_name: "orders",
|
|
3109
|
-
index_name: "orders_status_idx_invalid",
|
|
3110
|
-
relation_name: "orders",
|
|
3111
|
-
index_size_bytes: 2097152,
|
|
3112
|
-
index_size_pretty: "2.00 MiB",
|
|
3113
|
-
index_definition: "CREATE INDEX orders_status_idx_invalid ON public.orders USING btree (status)",
|
|
3114
|
-
supports_fk: false,
|
|
3115
|
-
is_pk: false,
|
|
3116
|
-
is_unique: false,
|
|
3117
|
-
constraint_name: null,
|
|
3118
|
-
table_row_estimate: 50000,
|
|
3119
|
-
has_valid_duplicate: true,
|
|
3120
|
-
valid_duplicate_name: "orders_status_idx",
|
|
3121
|
-
valid_duplicate_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)",
|
|
3122
|
-
},
|
|
3123
|
-
],
|
|
3124
|
-
total_count: 1,
|
|
3125
|
-
total_size_bytes: 2097152,
|
|
3126
|
-
total_size_pretty: "2.00 MiB",
|
|
3127
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3128
|
-
database_size_pretty: "1.00 GiB",
|
|
3129
|
-
});
|
|
3130
|
-
|
|
3131
|
-
const unusedReport = await checkup.generateH002(mockClient as any, "test-node");
|
|
3132
|
-
expect(unusedReport.results["test-node"].data.testdb).toEqual({
|
|
3133
|
-
unused_indexes: [
|
|
3134
|
-
{
|
|
3135
|
-
schema_name: "public",
|
|
3136
|
-
table_name: "logs",
|
|
3137
|
-
index_name: "logs_created_idx",
|
|
3138
|
-
index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)",
|
|
3139
|
-
reason: "Never Used Indexes",
|
|
3140
|
-
idx_scan: 0,
|
|
3141
|
-
index_size_bytes: 4194304,
|
|
3142
|
-
idx_is_btree: true,
|
|
3143
|
-
supports_fk: false,
|
|
3144
|
-
index_size_pretty: "4.00 MiB",
|
|
3145
|
-
},
|
|
3146
|
-
],
|
|
3147
|
-
total_count: 1,
|
|
3148
|
-
total_size_bytes: 4194304,
|
|
3149
|
-
total_size_pretty: "4.00 MiB",
|
|
3150
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3151
|
-
database_size_pretty: "1.00 GiB",
|
|
3152
|
-
stats_reset: {
|
|
3153
|
-
stats_reset_epoch: STATS_RESET_EPOCH,
|
|
3154
|
-
stats_reset_time: STATS_RESET_TIME,
|
|
3155
|
-
days_since_reset: DAYS_SINCE_RESET,
|
|
3156
|
-
postmaster_startup_epoch: STATS_RESET_EPOCH,
|
|
3157
|
-
postmaster_startup_time: POSTMASTER_STARTUP_TIME,
|
|
3158
|
-
},
|
|
3159
|
-
});
|
|
3160
|
-
|
|
3161
|
-
const redundantReport = await checkup.generateH004(mockClient as any, "test-node");
|
|
3162
|
-
expect(redundantReport.results["test-node"].data.testdb).toEqual({
|
|
3163
|
-
redundant_indexes: [
|
|
3164
|
-
{
|
|
3165
|
-
schema_name: "public",
|
|
3166
|
-
table_name: "orders",
|
|
3167
|
-
index_name: "orders_user_id_idx",
|
|
3168
|
-
relation_name: "orders",
|
|
3169
|
-
access_method: "btree",
|
|
3170
|
-
reason: "public.orders_user_id_created_idx",
|
|
3171
|
-
index_size_bytes: 2097152,
|
|
3172
|
-
table_size_bytes: 16777216,
|
|
3173
|
-
index_usage: 0,
|
|
3174
|
-
supports_fk: false,
|
|
3175
|
-
index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)",
|
|
3176
|
-
index_size_pretty: "2.00 MiB",
|
|
3177
|
-
table_size_pretty: "16.00 MiB",
|
|
3178
|
-
redundant_to: [
|
|
3179
|
-
{
|
|
3180
|
-
index_name: "public.orders_user_id_created_idx",
|
|
3181
|
-
index_definition: "CREATE INDEX orders_user_id_created_idx ON public.orders USING btree (user_id, created_at)",
|
|
3182
|
-
index_size_bytes: 1048576,
|
|
3183
|
-
index_size_pretty: "1.00 MiB",
|
|
3184
|
-
},
|
|
3185
|
-
],
|
|
3186
|
-
},
|
|
3187
|
-
],
|
|
3188
|
-
total_count: 1,
|
|
3189
|
-
total_size_bytes: 2097152,
|
|
3190
|
-
total_size_pretty: "2.00 MiB",
|
|
3191
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3192
|
-
database_size_pretty: "1.00 GiB",
|
|
3193
|
-
});
|
|
3194
|
-
});
|
|
3195
|
-
}
|
|
3196
|
-
});
|
|
3197
|
-
|
|
3198
|
-
describe("generateD004 (pg_stat_statements) works for each PG version", () => {
|
|
3199
|
-
for (const { major, minor } of pgVersions) {
|
|
3200
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3201
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
3202
|
-
const report = await checkup.REPORT_GENERATORS.D004(mockClient as any, "test-node");
|
|
3203
|
-
|
|
3204
|
-
expect(report.checkId).toBe("D004");
|
|
3205
|
-
expect(report.checkTitle).toBe("pg_stat_statements and pg_stat_kcache settings");
|
|
3206
|
-
const data = report.results["test-node"].data;
|
|
3207
|
-
expect(data).toEqual({
|
|
3208
|
-
settings: {},
|
|
3209
|
-
pg_stat_statements_status: {
|
|
3210
|
-
extension_available: false,
|
|
3211
|
-
metrics_count: 0,
|
|
3212
|
-
total_calls: 0,
|
|
3213
|
-
sample_queries: [],
|
|
3214
|
-
},
|
|
3215
|
-
pg_stat_kcache_status: {
|
|
3216
|
-
extension_available: false,
|
|
3217
|
-
metrics_count: 0,
|
|
3218
|
-
total_exec_time: 0,
|
|
3219
|
-
total_user_time: 0,
|
|
3220
|
-
total_system_time: 0,
|
|
3221
|
-
sample_queries: [],
|
|
3222
|
-
},
|
|
3223
|
-
});
|
|
3224
|
-
});
|
|
3225
|
-
}
|
|
3226
|
-
|
|
3227
|
-
test("surfaces populated extension metrics", async () => {
|
|
3228
|
-
const mockClient = createMockClient({
|
|
3229
|
-
...createVersionMockData(16, 3),
|
|
3230
|
-
settingsRows: [
|
|
3231
|
-
{
|
|
3232
|
-
tag_setting_name: "pg_stat_statements.max",
|
|
3233
|
-
tag_setting_value: "5000",
|
|
3234
|
-
tag_unit: "",
|
|
3235
|
-
tag_category: "Custom",
|
|
3236
|
-
tag_vartype: "integer",
|
|
3237
|
-
is_default: 0,
|
|
3238
|
-
setting_normalized: null,
|
|
3239
|
-
unit_normalized: null,
|
|
3240
|
-
},
|
|
3241
|
-
{
|
|
3242
|
-
tag_setting_name: "pg_stat_kcache.linux_hz",
|
|
3243
|
-
tag_setting_value: "100",
|
|
3244
|
-
tag_unit: "",
|
|
3245
|
-
tag_category: "Custom",
|
|
3246
|
-
tag_vartype: "integer",
|
|
3247
|
-
is_default: 1,
|
|
3248
|
-
setting_normalized: null,
|
|
3249
|
-
unit_normalized: null,
|
|
3250
|
-
},
|
|
3251
|
-
],
|
|
3252
|
-
pgStatStatementsExtensionRows: [{ exists: 1 }],
|
|
3253
|
-
pgStatStatementsStatsRows: [{ cnt: "2", total_calls: "42" }],
|
|
3254
|
-
pgStatStatementsSampleRows: [
|
|
3255
|
-
{ queryid: "101", user: "app", database: "testdb", calls: "40" },
|
|
3256
|
-
{ queryid: "202", user: "worker", database: "testdb", calls: "2" },
|
|
3257
|
-
],
|
|
3258
|
-
pgStatKcacheExtensionRows: [{ exists: 1 }],
|
|
3259
|
-
pgStatKcacheStatsRows: [{ cnt: "1", total_exec_time: "12.5", total_user_time: "8.5", total_system_time: "4" }],
|
|
3260
|
-
pgStatKcacheSampleRows: [{ queryid: "101", user: "app", exec_total_time: "12.5" }],
|
|
3261
|
-
});
|
|
3262
|
-
|
|
3263
|
-
const report = await checkup.REPORT_GENERATORS.D004(mockClient as any, "test-node");
|
|
3264
|
-
expect(report.results["test-node"].data).toEqual({
|
|
3265
|
-
settings: {
|
|
3266
|
-
"pg_stat_statements.max": {
|
|
3267
|
-
setting: "5000",
|
|
3268
|
-
unit: "",
|
|
3269
|
-
category: "Custom",
|
|
3270
|
-
context: "",
|
|
3271
|
-
vartype: "integer",
|
|
3272
|
-
pretty_value: "5000",
|
|
3273
|
-
},
|
|
3274
|
-
"pg_stat_kcache.linux_hz": {
|
|
3275
|
-
setting: "100",
|
|
3276
|
-
unit: "",
|
|
3277
|
-
category: "Custom",
|
|
3278
|
-
context: "",
|
|
3279
|
-
vartype: "integer",
|
|
3280
|
-
pretty_value: "100",
|
|
3281
|
-
},
|
|
3282
|
-
},
|
|
3283
|
-
pg_stat_statements_status: {
|
|
3284
|
-
extension_available: true,
|
|
3285
|
-
metrics_count: 2,
|
|
3286
|
-
total_calls: 42,
|
|
3287
|
-
sample_queries: [
|
|
3288
|
-
{ queryid: "101", user: "app", database: "testdb", calls: 40 },
|
|
3289
|
-
{ queryid: "202", user: "worker", database: "testdb", calls: 2 },
|
|
3290
|
-
],
|
|
3291
|
-
},
|
|
3292
|
-
pg_stat_kcache_status: {
|
|
3293
|
-
extension_available: true,
|
|
3294
|
-
metrics_count: 1,
|
|
3295
|
-
total_exec_time: 12.5,
|
|
3296
|
-
total_user_time: 8.5,
|
|
3297
|
-
total_system_time: 4,
|
|
3298
|
-
sample_queries: [{ queryid: "101", user: "app", exec_total_time: 12.5 }],
|
|
3299
|
-
},
|
|
3300
|
-
});
|
|
3301
|
-
});
|
|
3302
|
-
});
|
|
3303
|
-
|
|
3304
|
-
describe("generateF001 (autovacuum settings) works for each PG version", () => {
|
|
3305
|
-
for (const { major, minor } of pgVersions) {
|
|
3306
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3307
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
3308
|
-
const report = await checkup.REPORT_GENERATORS.F001(mockClient as any, "test-node");
|
|
3309
|
-
|
|
3310
|
-
expect(report.checkId).toBe("F001");
|
|
3311
|
-
expect(report.checkTitle).toBe("Autovacuum: current settings");
|
|
3312
|
-
expect(report.results["test-node"].data).toEqual({
|
|
3313
|
-
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3314
|
-
});
|
|
3315
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
3316
|
-
});
|
|
3317
|
-
}
|
|
3318
|
-
});
|
|
3319
|
-
|
|
3320
|
-
describe("generateG001 (memory settings) works for each PG version", () => {
|
|
3321
|
-
for (const { major, minor } of pgVersions) {
|
|
3322
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3323
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
3324
|
-
const report = await checkup.REPORT_GENERATORS.G001(mockClient as any, "test-node");
|
|
3325
|
-
|
|
3326
|
-
expect(report.checkId).toBe("G001");
|
|
3327
|
-
expect(report.checkTitle).toBe("Memory-related settings");
|
|
3328
|
-
const data = report.results["test-node"].data;
|
|
3329
|
-
expect(data).toEqual({
|
|
3330
|
-
settings: {
|
|
3331
|
-
shared_buffers: expectedSharedBuffersSetting,
|
|
3332
|
-
},
|
|
3333
|
-
analysis: {
|
|
3334
|
-
estimated_total_memory_usage: {
|
|
3335
|
-
shared_buffers_bytes: 134217728,
|
|
3336
|
-
shared_buffers_pretty: "128.00 MiB",
|
|
3337
|
-
wal_buffers_bytes: 4194304,
|
|
3338
|
-
wal_buffers_pretty: "4.00 MiB",
|
|
3339
|
-
shared_memory_total_bytes: 138412032,
|
|
3340
|
-
shared_memory_total_pretty: "132.00 MiB",
|
|
3341
|
-
work_mem_per_connection_bytes: 4194304,
|
|
3342
|
-
work_mem_per_connection_pretty: "4.00 MiB",
|
|
3343
|
-
max_work_mem_usage_bytes: 419430400,
|
|
3344
|
-
max_work_mem_usage_pretty: "400.00 MiB",
|
|
3345
|
-
maintenance_work_mem_bytes: 67108864,
|
|
3346
|
-
maintenance_work_mem_pretty: "64.00 MiB",
|
|
3347
|
-
effective_cache_size_bytes: 4294967296,
|
|
3348
|
-
effective_cache_size_pretty: "4.00 GiB",
|
|
3349
|
-
},
|
|
3350
|
-
},
|
|
3351
|
-
});
|
|
3352
|
-
expect(report.results["test-node"].postgres_version?.version).toBe(`${major}.${minor}`);
|
|
3353
|
-
});
|
|
3354
|
-
}
|
|
3355
|
-
});
|
|
3356
|
-
|
|
3357
|
-
describe("generateAllReports works for each PG version", () => {
|
|
3358
|
-
for (const { major, minor, versionNum } of pgVersions) {
|
|
3359
|
-
test(`PG ${major}.${minor}`, async () => {
|
|
3360
|
-
const mockClient = createMockClient(createVersionMockData(major, minor));
|
|
3361
|
-
const reports = await checkup.generateAllReports(mockClient as any, "test-node");
|
|
3362
|
-
const expectedVersion = {
|
|
3363
|
-
version: `${major}.${minor}`,
|
|
3364
|
-
server_version_num: versionNum,
|
|
3365
|
-
server_major_ver: String(major),
|
|
3366
|
-
server_minor_ver: String(minor),
|
|
3367
|
-
};
|
|
3368
|
-
|
|
3369
|
-
// Verify all express-mode checks are generated from the same source of truth.
|
|
3370
|
-
const expectedChecks = Object.keys(checkup.REPORT_GENERATORS).sort();
|
|
3371
|
-
expect(Object.keys(reports).sort()).toEqual(expectedChecks);
|
|
3372
|
-
for (const checkId of expectedChecks) {
|
|
3373
|
-
expect(reports[checkId]?.checkId).toBe(checkId);
|
|
3374
|
-
expect(reports[checkId].results["test-node"]).toBeDefined();
|
|
3375
|
-
}
|
|
3376
|
-
|
|
3377
|
-
// Verify every generated report has a concrete payload shape.
|
|
3378
|
-
expect(reports.A002.results["test-node"].data).toEqual({ version: expectedVersion });
|
|
3379
|
-
expect(reports.A003.results["test-node"].data).toEqual({
|
|
3380
|
-
shared_buffers: expectedSharedBuffersSetting,
|
|
3381
|
-
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3382
|
-
log_min_duration_statement: expectedLogMinDurationSetting,
|
|
3383
|
-
deadlock_timeout: expectedDeadlockTimeoutSetting,
|
|
3384
|
-
});
|
|
3385
|
-
expect(reports.A004.results["test-node"].data.database_sizes).toEqual({ postgres: expectedDatabaseSizeBytes });
|
|
3386
|
-
expect(reports.A004.results["test-node"].data.general_info.total_connections.value).toBe("5");
|
|
3387
|
-
expect(reports.A004.results["test-node"].data.general_info.uptime.value).toBe("30 days 0:00:00");
|
|
3388
|
-
expect(reports.A007.results["test-node"].data).toEqual({
|
|
3389
|
-
shared_buffers: expectedSharedBuffersAlteredSetting,
|
|
3390
|
-
autovacuum_vacuum_scale_factor: expectedAutovacuumAlteredSetting,
|
|
3391
|
-
});
|
|
3392
|
-
expect(reports.A013.results["test-node"].data).toEqual({ version: expectedVersion });
|
|
3393
|
-
expect(reports.D001.results["test-node"].data).toEqual({
|
|
3394
|
-
log_min_duration_statement: expectedLogMinDurationSetting,
|
|
3395
|
-
});
|
|
3396
|
-
expect(reports.D004.results["test-node"].data).toEqual({
|
|
3397
|
-
settings: {},
|
|
3398
|
-
pg_stat_statements_status: {
|
|
3399
|
-
extension_available: false,
|
|
3400
|
-
metrics_count: 0,
|
|
3401
|
-
total_calls: 0,
|
|
3402
|
-
sample_queries: [],
|
|
3403
|
-
},
|
|
3404
|
-
pg_stat_kcache_status: {
|
|
3405
|
-
extension_available: false,
|
|
3406
|
-
metrics_count: 0,
|
|
3407
|
-
total_exec_time: 0,
|
|
3408
|
-
total_user_time: 0,
|
|
3409
|
-
total_system_time: 0,
|
|
3410
|
-
sample_queries: [],
|
|
3411
|
-
},
|
|
3412
|
-
});
|
|
3413
|
-
expect(reports.F001.results["test-node"].data).toEqual({
|
|
3414
|
-
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3415
|
-
});
|
|
3416
|
-
expect(reports.F004.results["test-node"].data.testdb).toEqual({
|
|
3417
|
-
status: { ok: true, reason: null, error: null },
|
|
3418
|
-
bloated_tables: [],
|
|
3419
|
-
total_count: 0,
|
|
3420
|
-
total_bloat_size_bytes: 0,
|
|
3421
|
-
total_bloat_size_pretty: "0 B",
|
|
3422
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3423
|
-
database_size_pretty: "1.00 GiB",
|
|
3424
|
-
});
|
|
3425
|
-
expect(reports.F005.results["test-node"].data.testdb).toEqual({
|
|
3426
|
-
status: { ok: true, reason: null, error: null },
|
|
3427
|
-
bloated_indexes: [],
|
|
3428
|
-
total_count: 0,
|
|
3429
|
-
total_bloat_size_bytes: 0,
|
|
3430
|
-
total_bloat_size_pretty: "0 B",
|
|
3431
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3432
|
-
database_size_pretty: "1.00 GiB",
|
|
3433
|
-
});
|
|
3434
|
-
expect(reports.G001.results["test-node"].data.settings).toEqual({
|
|
3435
|
-
shared_buffers: expectedSharedBuffersSetting,
|
|
3436
|
-
});
|
|
3437
|
-
expect(reports.G001.results["test-node"].data.analysis.estimated_total_memory_usage.shared_buffers_bytes).toBe(134217728);
|
|
3438
|
-
expect(reports.G003.results["test-node"].data).toEqual({
|
|
3439
|
-
settings: {
|
|
3440
|
-
deadlock_timeout: expectedDeadlockTimeoutSetting,
|
|
3441
|
-
},
|
|
3442
|
-
deadlock_stats: { deadlocks: 0, conflicts: 0, stats_reset: null },
|
|
3443
|
-
});
|
|
3444
|
-
expect(reports.H001.results["test-node"].data.testdb).toEqual({
|
|
3445
|
-
invalid_indexes: [],
|
|
3446
|
-
total_count: 0,
|
|
3447
|
-
total_size_bytes: 0,
|
|
3448
|
-
total_size_pretty: "0 B",
|
|
3449
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3450
|
-
database_size_pretty: "1.00 GiB",
|
|
3451
|
-
});
|
|
3452
|
-
expect(reports.H002.results["test-node"].data.testdb).toEqual({
|
|
3453
|
-
unused_indexes: [],
|
|
3454
|
-
total_count: 0,
|
|
3455
|
-
total_size_bytes: 0,
|
|
3456
|
-
total_size_pretty: "0 B",
|
|
3457
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3458
|
-
database_size_pretty: "1.00 GiB",
|
|
3459
|
-
stats_reset: {
|
|
3460
|
-
stats_reset_epoch: STATS_RESET_EPOCH,
|
|
3461
|
-
stats_reset_time: STATS_RESET_TIME,
|
|
3462
|
-
days_since_reset: DAYS_SINCE_RESET,
|
|
3463
|
-
postmaster_startup_epoch: STATS_RESET_EPOCH,
|
|
3464
|
-
postmaster_startup_time: POSTMASTER_STARTUP_TIME,
|
|
3465
|
-
},
|
|
3466
|
-
});
|
|
3467
|
-
expect(reports.H004.results["test-node"].data.testdb).toEqual({
|
|
3468
|
-
redundant_indexes: [],
|
|
3469
|
-
total_count: 0,
|
|
3470
|
-
total_size_bytes: 0,
|
|
3471
|
-
total_size_pretty: "0 B",
|
|
3472
|
-
database_size_bytes: expectedDatabaseSizeBytes,
|
|
3473
|
-
database_size_pretty: "1.00 GiB",
|
|
3474
|
-
});
|
|
3475
|
-
|
|
3476
|
-
// Verify postgres_version is set in reports that include it.
|
|
3477
|
-
expect(reports.A003.results["test-node"].postgres_version).toEqual(expectedVersion);
|
|
3478
|
-
expect(reports.A004.results["test-node"].postgres_version).toEqual(expectedVersion);
|
|
3479
|
-
});
|
|
3480
|
-
}
|
|
3481
|
-
});
|
|
3482
|
-
});
|
|
3483
|
-
|
|
3484
|
-
// Tests for version-aware SQL query selection
|
|
3485
|
-
describe("Version-aware SQL query selection (PG13-PG18)", () => {
|
|
3486
|
-
const pgVersions = SUPPORTED_PG_MAJOR_VERSIONS;
|
|
3487
|
-
|
|
3488
|
-
// All metrics registered in metrics.yml.
|
|
3489
|
-
const allMetrics = metricsLoader.listMetricNames();
|
|
3490
|
-
expect(allMetrics.length).toBeGreaterThan(0);
|
|
3491
|
-
const sqlStartPattern = /^\s*(with|select)\b/i;
|
|
3492
|
-
|
|
3493
|
-
describe("All metrics from metrics.yml return valid SQL for each PG version", () => {
|
|
3494
|
-
for (const pgVersion of pgVersions) {
|
|
3495
|
-
describe(`PG${pgVersion}`, () => {
|
|
3496
|
-
for (const metric of allMetrics) {
|
|
3497
|
-
test(`${metric}`, () => {
|
|
3498
|
-
const sql = metricsLoader.getMetricSql(metric, pgVersion);
|
|
3499
|
-
expect(typeof sql).toBe("string");
|
|
3500
|
-
expect(sql.length).toBeGreaterThan(0);
|
|
3501
|
-
|
|
3502
|
-
const trimmedSql = sql.trim();
|
|
3503
|
-
if (trimmedSql.startsWith(";")) {
|
|
3504
|
-
expect(metric).toBe("pg_stat_io");
|
|
3505
|
-
expect(pgVersion).toBeLessThan(16);
|
|
3506
|
-
expect(trimmedSql).toMatch(/pg_stat_io only available/i);
|
|
3507
|
-
return;
|
|
3508
|
-
}
|
|
3509
|
-
|
|
3510
|
-
expect(trimmedSql).toMatch(sqlStartPattern);
|
|
3511
|
-
expect(trimmedSql.toLowerCase()).toMatch(/\bfrom\b/);
|
|
3512
|
-
expect(trimmedSql).not.toMatch(/\{\{.*\}\}/);
|
|
3513
|
-
expect(trimmedSql).not.toMatch(/\$\{.*\}/);
|
|
3514
|
-
});
|
|
3515
|
-
}
|
|
3516
|
-
});
|
|
3517
|
-
}
|
|
3518
|
-
});
|
|
3519
|
-
|
|
3520
|
-
describe("getMetricSql rejects invalid version inputs", () => {
|
|
3521
|
-
const invalidVersions = [0, -1, Number.NaN];
|
|
3522
|
-
|
|
3523
|
-
for (const pgVersion of invalidVersions) {
|
|
3524
|
-
test(`settings rejects Postgres ${String(pgVersion)}`, () => {
|
|
3525
|
-
expect(() => metricsLoader.getMetricSql("settings", pgVersion)).toThrow(/No compatible SQL version/);
|
|
3526
|
-
});
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
test("rejects versions older than the oldest keyed SQL", () => {
|
|
3530
|
-
for (const metric of allMetrics) {
|
|
3531
|
-
expect(() => metricsLoader.getMetricSql(metric, 10)).toThrow(/No compatible SQL version/);
|
|
3532
|
-
}
|
|
3533
|
-
});
|
|
3534
|
-
|
|
3535
|
-
test("rejects unknown metric names", () => {
|
|
3536
|
-
expect(() => metricsLoader.getMetricSql("not_a_metric", 16)).toThrow(/Metric "not_a_metric" not found/);
|
|
3537
|
-
});
|
|
3538
|
-
});
|
|
3539
|
-
|
|
3540
|
-
describe("getMetricSql selects the nearest compatible version", () => {
|
|
3541
|
-
test("uses exact and previous keyed SQL for mid-range versions", () => {
|
|
3542
|
-
const definition = metricsLoader.getMetricDefinition("db_stats")!;
|
|
3543
|
-
expect(typeof definition.sqls["11"]).toBe("string");
|
|
3544
|
-
expect(typeof definition.sqls["12"]).toBe("string");
|
|
3545
|
-
expect(typeof definition.sqls["14"]).toBe("string");
|
|
3546
|
-
expect(typeof definition.sqls["15"]).toBe("string");
|
|
3547
|
-
|
|
3548
|
-
expect(metricsLoader.getMetricSql("db_stats", 12)).toBe(definition.sqls["12"]);
|
|
3549
|
-
expect(metricsLoader.getMetricSql("db_stats", 13)).toBe(definition.sqls["12"]);
|
|
3550
|
-
expect(metricsLoader.getMetricSql("db_stats", 14)).toBe(definition.sqls["14"]);
|
|
3551
|
-
expect(metricsLoader.getMetricSql("db_stats", 19)).toBe(definition.sqls["15"]);
|
|
3552
|
-
expect(definition.sqls["12"]).not.toBe(definition.sqls["11"]);
|
|
3553
|
-
expect(definition.sqls["15"]).not.toBe(definition.sqls["14"]);
|
|
3554
|
-
});
|
|
3555
|
-
|
|
3556
|
-
test("uses a metric's oldest SQL when no newer key exists below the requested version", () => {
|
|
3557
|
-
const definition = metricsLoader.getMetricDefinition("settings")!;
|
|
3558
|
-
expect(typeof definition.sqls["11"]).toBe("string");
|
|
3559
|
-
expect(metricsLoader.getMetricSql("settings", 12)).toBe(definition.sqls["11"]);
|
|
3560
|
-
expect(metricsLoader.getMetricSql("settings", 19)).toBe(definition.sqls["11"]);
|
|
3561
|
-
});
|
|
3562
|
-
});
|
|
3563
|
-
|
|
3564
|
-
describe("getMetricDefinition returns metadata for all metrics", () => {
|
|
3565
|
-
for (const metric of allMetrics) {
|
|
3566
|
-
test(`${metric} has definition with versioned SQL`, () => {
|
|
3567
|
-
const definition = metricsLoader.getMetricDefinition(metric);
|
|
3568
|
-
expect(definition).toBeTruthy();
|
|
3569
|
-
expect(definition?.sqls).toBeTruthy();
|
|
3570
|
-
expect(typeof definition?.sqls).toBe("object");
|
|
3571
|
-
const entries = Object.entries(definition!.sqls);
|
|
3572
|
-
expect(entries.length).toBeGreaterThan(0);
|
|
3573
|
-
for (const [versionKey, sql] of entries) {
|
|
3574
|
-
const version = Number(versionKey);
|
|
3575
|
-
const trimmedSql = sql.trim();
|
|
3576
|
-
expect(Number.isInteger(version)).toBe(true);
|
|
3577
|
-
expect(version).toBeGreaterThan(0);
|
|
3578
|
-
expect(typeof sql).toBe("string");
|
|
3579
|
-
expect(sql.length).toBeGreaterThan(0);
|
|
3580
|
-
if (trimmedSql.startsWith(";")) {
|
|
3581
|
-
expect(metric).toBe("pg_stat_io");
|
|
3582
|
-
expect(version).toBe(11);
|
|
3583
|
-
expect(trimmedSql).toMatch(/pg_stat_io only available/i);
|
|
3584
|
-
continue;
|
|
3585
|
-
}
|
|
3586
|
-
expect(trimmedSql).toMatch(sqlStartPattern);
|
|
3587
|
-
}
|
|
3588
|
-
});
|
|
3589
|
-
}
|
|
3590
|
-
});
|
|
3591
|
-
|
|
3592
|
-
test("listMetricNames returns all expected core metrics", () => {
|
|
3593
|
-
const names = metricsLoader.listMetricNames();
|
|
3594
|
-
expect(Array.isArray(names)).toBe(true);
|
|
3595
|
-
expect(new Set(names).size).toBe(names.length);
|
|
3596
|
-
const coreMetrics = [
|
|
3597
|
-
"settings",
|
|
3598
|
-
"db_stats",
|
|
3599
|
-
"db_size",
|
|
3600
|
-
"stats_reset",
|
|
3601
|
-
"pg_invalid_indexes",
|
|
3602
|
-
"unused_indexes",
|
|
3603
|
-
"redundant_indexes",
|
|
3604
|
-
];
|
|
3605
|
-
expect(names.length).toBeGreaterThanOrEqual(coreMetrics.length);
|
|
3606
|
-
for (const metric of coreMetrics) {
|
|
3607
|
-
expect(names).toContain(metric);
|
|
3608
|
-
}
|
|
3609
|
-
});
|
|
3610
|
-
|
|
3611
|
-
test("METRIC_NAMES maps every express report metric", () => {
|
|
3612
|
-
expect(metricsLoader.METRIC_NAMES).toEqual({
|
|
3613
|
-
H001: "pg_invalid_indexes",
|
|
3614
|
-
H002: "unused_indexes",
|
|
3615
|
-
H004: "redundant_indexes",
|
|
3616
|
-
F003: "pg_dead_tuples",
|
|
3617
|
-
F004: "pg_table_bloat",
|
|
3618
|
-
F005: "pg_btree_bloat",
|
|
3619
|
-
settings: "settings",
|
|
3620
|
-
dbStats: "db_stats",
|
|
3621
|
-
dbSize: "db_size",
|
|
3622
|
-
statsReset: "stats_reset",
|
|
3623
|
-
I001: "pg_stat_io",
|
|
3624
|
-
});
|
|
3625
|
-
});
|
|
3626
|
-
});
|