postgresai 0.15.0-rc.8 → 0.16.0-dev.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.
@@ -42,6 +42,24 @@ function runCli(args: string[], env: Record<string, string> = {}) {
42
42
  };
43
43
  }
44
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
+
45
63
  // Unit tests for parseVersionNum
46
64
  describe("parseVersionNum", () => {
47
65
  test("parses PG 16.3 version number", () => {
@@ -403,7 +421,11 @@ describe("Report generators with mock client", () => {
403
421
  expect(pg16MetricSql).toContain("sum(coalesce(extends, 0) * op_bytes)");
404
422
  expect(pg18MetricSql).toContain("sum(coalesce(read_bytes, 0))");
405
423
  expect(pg18MetricSql).toContain("sum(coalesce(extend_bytes, 0))");
406
- expect(pg18MetricSql).toContain("sum(coalesce(writebacks, 0) * coalesce(op_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");
407
429
  });
408
430
 
409
431
  test("generateI001 returns unavailable on PostgreSQL 16 when ioStats are empty", async () => {
@@ -1237,6 +1259,186 @@ describe("H004 - Redundant indexes", () => {
1237
1259
  // Top-level structure tests removed - covered by schema-validation.test.ts
1238
1260
  });
1239
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
+
1240
1442
  // CLI tests
1241
1443
  describe("CLI tests", () => {
1242
1444
  test("checkup command exists and shows help", () => {
@@ -1452,6 +1654,113 @@ describe("CLI tests", () => {
1452
1654
  });
1453
1655
  });
1454
1656
 
1657
+ // CLI-level tests for the checkup auth pre-flight: missing/invalid credentials
1658
+ // must surface BEFORE checks run (previously the upload at the end of the run
1659
+ // was the first authenticated call, wasting minutes of work on a 401).
1660
+ describe("checkup auth pre-flight (CLI)", () => {
1661
+ // Dead Postgres port: if the pre-flight correctly stops the run, the CLI
1662
+ // never attempts this connection; if the run continues, the connection
1663
+ // failure mentions this address.
1664
+ const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1665
+
1666
+ test("no API key: prominent notice, run continues locally", () => {
1667
+ const env = {
1668
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
1669
+ PGAI_API_KEY: "",
1670
+ };
1671
+ const r = runCli(["checkup", DEAD_DB], env);
1672
+ expect(r.stderr).toMatch(/results will NOT be uploaded/i);
1673
+ expect(r.stderr).toMatch(/auth login/);
1674
+ expect(r.stderr).toMatch(/--no-upload/);
1675
+ // Falls back to local-only mode instead of failing fast
1676
+ expect(r.stderr).not.toMatch(/API key is required/i);
1677
+ // Run continued to the database connection stage
1678
+ expect(r.status).not.toBe(0);
1679
+ expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1680
+ });
1681
+
1682
+ test("--no-upload: no notice, pre-flight skipped entirely", () => {
1683
+ const env = {
1684
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-noupload-${process.pid}`,
1685
+ PGAI_API_KEY: "",
1686
+ };
1687
+ const r = runCli(["checkup", DEAD_DB, "--no-upload"], env);
1688
+ expect(r.stderr).not.toMatch(/results will NOT be uploaded/i);
1689
+ expect(r.stderr).not.toMatch(/could not verify API key/i);
1690
+ expect(r.stderr).not.toMatch(/API key is required/i);
1691
+ });
1692
+
1693
+ test("invalid API key (HTTP 401): fails fast before running checks", async () => {
1694
+ const server = Bun.serve({
1695
+ hostname: "127.0.0.1",
1696
+ port: 0,
1697
+ fetch() {
1698
+ return new Response(JSON.stringify({ message: "Invalid token" }), {
1699
+ status: 401,
1700
+ headers: { "Content-Type": "application/json" },
1701
+ });
1702
+ },
1703
+ });
1704
+ try {
1705
+ const env = {
1706
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-${process.pid}`,
1707
+ PGAI_API_KEY: "bad-token",
1708
+ PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1709
+ };
1710
+ const r = await runCliAsync(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1711
+ expect(r.status).not.toBe(0);
1712
+ expect(r.stderr).toMatch(/rejected by the PostgresAI API \(HTTP 401\)/);
1713
+ expect(r.stderr).toMatch(/auth login/);
1714
+ expect(r.stderr).toMatch(/--no-upload/);
1715
+ // Stopped BEFORE connecting to the database / running checks
1716
+ expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1717
+ } finally {
1718
+ server.stop(true);
1719
+ }
1720
+ });
1721
+
1722
+ test("--no-upload skips pre-flight even when an invalid key is configured", async () => {
1723
+ const server = Bun.serve({
1724
+ hostname: "127.0.0.1",
1725
+ port: 0,
1726
+ fetch() {
1727
+ return new Response(JSON.stringify({ message: "Invalid token" }), {
1728
+ status: 401,
1729
+ headers: { "Content-Type": "application/json" },
1730
+ });
1731
+ },
1732
+ });
1733
+ try {
1734
+ const env = {
1735
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-noupload-${process.pid}`,
1736
+ PGAI_API_KEY: "bad-token",
1737
+ PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1738
+ };
1739
+ const r = await runCliAsync(["checkup", DEAD_DB, "--no-upload"], env);
1740
+ expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1741
+ expect(r.stderr).not.toMatch(/could not verify API key/i);
1742
+ // Fails later at the database connection stage instead
1743
+ expect(r.status).not.toBe(0);
1744
+ } finally {
1745
+ server.stop(true);
1746
+ }
1747
+ });
1748
+
1749
+ test("transient pre-flight failure (network error): warns and continues", () => {
1750
+ const env = {
1751
+ XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-netfail-${process.pid}`,
1752
+ PGAI_API_KEY: "some-token",
1753
+ PGAI_API_BASE_URL: "http://127.0.0.1:1", // connect refused — transient, not a 401/403
1754
+ };
1755
+ const r = runCli(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1756
+ expect(r.stderr).toMatch(/Warning: could not verify API key/i);
1757
+ expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1758
+ // Run continued to the database connection stage
1759
+ expect(r.status).not.toBe(0);
1760
+ expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1761
+ });
1762
+ });
1763
+
1455
1764
  // Tests for checkup-api module
1456
1765
  describe("checkup-api", () => {
1457
1766
  test("formatRpcErrorForDisplay formats details/hint nicely", () => {
@@ -1698,12 +2007,172 @@ describe("checkup-api", () => {
1698
2007
  }
1699
2008
  });
1700
2009
  });
2010
+
2011
+ // Auth pre-flight: verifyApiKey checks the key with a cheap authenticated
2012
+ // GET before checkup runs expensive checks. Only definitive 401/403 is
2013
+ // "invalid"; anything transient must come back "unknown" (warn + continue).
2014
+ describe("verifyApiKey (auth pre-flight)", () => {
2015
+ function serveStatus(status: number, body = "[]") {
2016
+ return Bun.serve({
2017
+ hostname: "127.0.0.1",
2018
+ port: 0,
2019
+ fetch() {
2020
+ return new Response(body, { status, headers: { "Content-Type": "application/json" } });
2021
+ },
2022
+ });
2023
+ }
2024
+
2025
+ test("returns valid on HTTP 200", async () => {
2026
+ const server = serveStatus(200);
2027
+ try {
2028
+ const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2029
+ expect(r.status).toBe("valid");
2030
+ } finally {
2031
+ server.stop(true);
2032
+ }
2033
+ });
2034
+
2035
+ test("returns invalid on HTTP 401", async () => {
2036
+ const server = serveStatus(401, JSON.stringify({ message: "Invalid token" }));
2037
+ try {
2038
+ const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2039
+ expect(r.status).toBe("invalid");
2040
+ expect((r as { statusCode: number }).statusCode).toBe(401);
2041
+ } finally {
2042
+ server.stop(true);
2043
+ }
2044
+ });
2045
+
2046
+ test("returns invalid on HTTP 403", async () => {
2047
+ const server = serveStatus(403);
2048
+ try {
2049
+ const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2050
+ expect(r.status).toBe("invalid");
2051
+ expect((r as { statusCode: number }).statusCode).toBe(403);
2052
+ } finally {
2053
+ server.stop(true);
2054
+ }
2055
+ });
2056
+
2057
+ test("returns unknown on HTTP 500 (not a definitive rejection)", async () => {
2058
+ const server = serveStatus(500);
2059
+ try {
2060
+ const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2061
+ expect(r.status).toBe("unknown");
2062
+ expect((r as { detail: string }).detail).toMatch(/HTTP 500/);
2063
+ } finally {
2064
+ server.stop(true);
2065
+ }
2066
+ });
2067
+
2068
+ test("returns unknown on connection refused", async () => {
2069
+ const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://127.0.0.1:1" }); // port 1 — connect refused
2070
+ expect(r.status).toBe("unknown");
2071
+ });
2072
+
2073
+ test("returns unknown on timeout", async () => {
2074
+ const server = Bun.serve({
2075
+ hostname: "127.0.0.1",
2076
+ port: 0,
2077
+ async fetch() {
2078
+ await new Promise((resolve) => setTimeout(resolve, 5000));
2079
+ return new Response("[]");
2080
+ },
2081
+ });
2082
+ try {
2083
+ const r = await api.verifyApiKey({
2084
+ apiKey: "k",
2085
+ apiBaseUrl: `http://127.0.0.1:${server.port}`,
2086
+ timeoutMs: 100,
2087
+ });
2088
+ expect(r.status).toBe("unknown");
2089
+ } finally {
2090
+ server.stop(true);
2091
+ }
2092
+ });
2093
+
2094
+ test("does not send the key over plaintext HTTP to non-loopback hosts", async () => {
2095
+ const saved = process.env.CHECKUP_ALLOW_HTTP;
2096
+ delete process.env.CHECKUP_ALLOW_HTTP;
2097
+ try {
2098
+ const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://example.com/api" });
2099
+ expect(r.status).toBe("unknown");
2100
+ expect((r as { detail: string }).detail).toMatch(/plaintext HTTP/);
2101
+ } finally {
2102
+ if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
2103
+ }
2104
+ });
2105
+ });
1701
2106
  });
1702
2107
 
1703
2108
  // Tests for checkup-summary module
1704
2109
  describe("checkup-summary", () => {
1705
2110
  const summary = require("../lib/checkup-summary");
1706
2111
 
2112
+ test("generateCheckSummary for F003 with no issues", () => {
2113
+ const report = {
2114
+ results: {
2115
+ node1: {
2116
+ data: {
2117
+ db1: {
2118
+ dead_tuples_tables: [],
2119
+ total_count: 0,
2120
+ flagged_count: 0,
2121
+ autovacuum_disabled_count: 0,
2122
+ autovacuum_disabled_flagged_count: 0,
2123
+ },
2124
+ },
2125
+ },
2126
+ },
2127
+ };
2128
+ const result = summary.generateCheckSummary("F003", report);
2129
+ expect(result.status).toBe("ok");
2130
+ expect(result.message).toMatch(/no significant dead tuple/i);
2131
+ });
2132
+
2133
+ test("generateCheckSummary for F003 ignores tiny disabled-autovacuum tables", () => {
2134
+ const report = {
2135
+ results: {
2136
+ node1: {
2137
+ data: {
2138
+ db1: {
2139
+ dead_tuples_tables: [],
2140
+ total_count: 2,
2141
+ flagged_count: 0,
2142
+ autovacuum_disabled_count: 2,
2143
+ autovacuum_disabled_flagged_count: 0,
2144
+ },
2145
+ },
2146
+ },
2147
+ },
2148
+ };
2149
+ const result = summary.generateCheckSummary("F003", report);
2150
+ expect(result.status).toBe("ok");
2151
+ });
2152
+
2153
+ test("generateCheckSummary for F003 with problems", () => {
2154
+ const report = {
2155
+ results: {
2156
+ node1: {
2157
+ data: {
2158
+ db1: {
2159
+ dead_tuples_tables: [],
2160
+ total_count: 3,
2161
+ flagged_count: 1,
2162
+ autovacuum_disabled_count: 2,
2163
+ autovacuum_disabled_flagged_count: 2,
2164
+ },
2165
+ },
2166
+ },
2167
+ },
2168
+ };
2169
+ const result = summary.generateCheckSummary("F003", report);
2170
+ expect(result.status).toBe("warning");
2171
+ expect(result.message).toBe(
2172
+ "1 table with excessive dead tuples, 2 tables with autovacuum disabled"
2173
+ );
2174
+ });
2175
+
1707
2176
  test("generateCheckSummary for H001 with no issues", () => {
1708
2177
  const report = {
1709
2178
  results: {
@@ -3099,6 +3568,7 @@ describe("Version-aware SQL query selection (PG13-PG18)", () => {
3099
3568
  H001: "pg_invalid_indexes",
3100
3569
  H002: "unused_indexes",
3101
3570
  H004: "redundant_indexes",
3571
+ F003: "pg_dead_tuples",
3102
3572
  F004: "pg_table_bloat",
3103
3573
  F005: "pg_btree_bloat",
3104
3574
  settings: "settings",
@@ -238,6 +238,8 @@ describe("MCP Server", () => {
238
238
 
239
239
  expect(response.isError).toBe(true);
240
240
  expect(getResponseText(response)).toContain("401");
241
+ // Agent-facing remediation: invalid key must point at re-auth, not dead-end
242
+ expect(getResponseText(response)).toContain("Run 'postgresai auth'");
241
243
 
242
244
  readConfigSpy.mockRestore();
243
245
  });
@@ -1678,6 +1680,8 @@ describe("MCP Server", () => {
1678
1680
 
1679
1681
  expect(response.isError).toBe(true);
1680
1682
  expect(getResponseText(response)).toContain("401");
1683
+ // Agent-facing remediation: invalid key must point at re-auth, not dead-end
1684
+ expect(getResponseText(response)).toContain("Run 'postgresai auth'");
1681
1685
 
1682
1686
  readConfigSpy.mockRestore();
1683
1687
  });