Skip to content

Commit 6c0275b

Browse files
committed
test(cli): add integration tests for express mode schema compatibility
- Validates CLI-generated reports against JSON schemas from reporter/schemas/ - Tests all 11 check types (A002, A003, A004, A007, A013, D004, F001, G001, H001, H002, H004) - Ensures compatibility between express and full (monitoring) modes - Uses real PostgreSQL via pg_tmp for realistic testing
1 parent bc6f5cb commit 6c0275b

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* Integration tests for checkup command (express mode)
3+
* Validates that CLI-generated reports match JSON schemas used by the Python reporter.
4+
* This ensures compatibility between "express" and "full" (monitoring) modes.
5+
*/
6+
import { describe, test, expect, afterAll, beforeAll } from "bun:test";
7+
import * as fs from "fs";
8+
import * as os from "os";
9+
import * as path from "path";
10+
import * as net from "net";
11+
import { Client } from "pg";
12+
import { resolve } from "path";
13+
import { readFileSync } from "fs";
14+
import Ajv2020 from "ajv/dist/2020";
15+
16+
import * as checkup from "../lib/checkup";
17+
18+
const ajv = new Ajv2020({ allErrors: true, strict: false });
19+
const schemasDir = resolve(import.meta.dir, "../../reporter/schemas");
20+
21+
function findOnPath(cmd: string): string | null {
22+
const result = Bun.spawnSync(["sh", "-c", `command -v ${cmd}`]);
23+
if (result.exitCode === 0) {
24+
return new TextDecoder().decode(result.stdout).trim();
25+
}
26+
return null;
27+
}
28+
29+
function findPgBin(cmd: string): string | null {
30+
const p = findOnPath(cmd);
31+
if (p) return p;
32+
const probe = Bun.spawnSync([
33+
"sh",
34+
"-c",
35+
`ls -1 /usr/lib/postgresql/*/bin/${cmd} 2>/dev/null | head -n 1 || true`,
36+
]);
37+
const out = new TextDecoder().decode(probe.stdout).trim();
38+
if (out) return out;
39+
return null;
40+
}
41+
42+
function havePostgresBinaries(): boolean {
43+
return !!(findPgBin("initdb") && findPgBin("postgres"));
44+
}
45+
46+
function isRunningAsRoot(): boolean {
47+
return process.getuid?.() === 0;
48+
}
49+
50+
async function getFreePort(): Promise<number> {
51+
return new Promise((resolve, reject) => {
52+
const srv = net.createServer();
53+
srv.listen(0, "127.0.0.1", () => {
54+
const addr = srv.address() as net.AddressInfo;
55+
srv.close((err) => {
56+
if (err) return reject(err);
57+
resolve(addr.port);
58+
});
59+
});
60+
srv.on("error", reject);
61+
});
62+
}
63+
64+
async function waitFor<T>(
65+
fn: () => Promise<T>,
66+
{ timeoutMs = 10000, intervalMs = 100 } = {}
67+
): Promise<T> {
68+
const start = Date.now();
69+
while (true) {
70+
try {
71+
return await fn();
72+
} catch (e) {
73+
if (Date.now() - start > timeoutMs) throw e;
74+
await new Promise((r) => setTimeout(r, intervalMs));
75+
}
76+
}
77+
}
78+
79+
interface TempPostgres {
80+
port: number;
81+
socketDir: string;
82+
cleanup: () => Promise<void>;
83+
connect: (database?: string) => Promise<Client>;
84+
}
85+
86+
async function createTempPostgres(): Promise<TempPostgres> {
87+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "postgresai-checkup-"));
88+
const dataDir = path.join(tmpRoot, "data");
89+
const socketDir = path.join(tmpRoot, "sock");
90+
fs.mkdirSync(socketDir, { recursive: true });
91+
92+
const initdb = findPgBin("initdb");
93+
const postgresBin = findPgBin("postgres");
94+
if (!initdb || !postgresBin) {
95+
throw new Error("PostgreSQL binaries not found");
96+
}
97+
98+
const init = Bun.spawnSync([initdb, "-D", dataDir, "-U", "postgres", "-A", "trust"]);
99+
if (init.exitCode !== 0) {
100+
throw new Error(new TextDecoder().decode(init.stderr) || new TextDecoder().decode(init.stdout));
101+
}
102+
103+
const hbaPath = path.join(dataDir, "pg_hba.conf");
104+
fs.appendFileSync(hbaPath, "\nlocal all all trust\n", "utf8");
105+
106+
const port = await getFreePort();
107+
const postgresProc = Bun.spawn(
108+
[postgresBin, "-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)],
109+
{ stdio: ["ignore", "pipe", "pipe"] }
110+
);
111+
112+
const cleanup = async () => {
113+
postgresProc.kill("SIGTERM");
114+
try {
115+
await waitFor(
116+
async () => {
117+
if (postgresProc.exitCode === null) throw new Error("still running");
118+
},
119+
{ timeoutMs: 5000, intervalMs: 100 }
120+
);
121+
} catch {
122+
postgresProc.kill("SIGKILL");
123+
}
124+
fs.rmSync(tmpRoot, { recursive: true, force: true });
125+
};
126+
127+
const connect = async (database = "postgres"): Promise<Client> => {
128+
const c = new Client({ host: socketDir, port, user: "postgres", database });
129+
await c.connect();
130+
return c;
131+
};
132+
133+
// Wait for Postgres to start
134+
await waitFor(async () => {
135+
const c = await connect();
136+
await c.end();
137+
});
138+
139+
return { port, socketDir, cleanup, connect };
140+
}
141+
142+
function validateAgainstSchema(report: any, checkId: string): void {
143+
const schemaPath = resolve(schemasDir, `${checkId}.schema.json`);
144+
if (!fs.existsSync(schemaPath)) {
145+
throw new Error(`Schema not found: ${schemaPath}`);
146+
}
147+
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
148+
const validate = ajv.compile(schema);
149+
const valid = validate(report);
150+
if (!valid) {
151+
const errors = validate.errors?.map(e => `${e.instancePath}: ${e.message}`).join(", ");
152+
throw new Error(`${checkId} schema validation failed: ${errors}`);
153+
}
154+
}
155+
156+
// Skip tests if PostgreSQL binaries are not available
157+
const skipReason = !havePostgresBinaries()
158+
? "PostgreSQL binaries not available"
159+
: isRunningAsRoot()
160+
? "Cannot run as root (PostgreSQL refuses)"
161+
: null;
162+
163+
describe.skipIf(!!skipReason)("checkup integration: express mode schema compatibility", () => {
164+
let pg: TempPostgres;
165+
let client: Client;
166+
167+
beforeAll(async () => {
168+
pg = await createTempPostgres();
169+
client = await pg.connect();
170+
});
171+
172+
afterAll(async () => {
173+
if (client) await client.end();
174+
if (pg) await pg.cleanup();
175+
});
176+
177+
// Test all checks supported by express mode
178+
const expressChecks = Object.keys(checkup.CHECK_INFO);
179+
180+
for (const checkId of expressChecks) {
181+
test(`${checkId} report validates against shared schema`, async () => {
182+
const generator = checkup.REPORT_GENERATORS[checkId];
183+
expect(generator).toBeDefined();
184+
185+
const report = await generator(client, "test-node");
186+
187+
// Validate basic report structure (matching schema requirements)
188+
expect(report).toHaveProperty("checkId", checkId);
189+
expect(report).toHaveProperty("checkTitle");
190+
expect(report).toHaveProperty("timestamptz");
191+
expect(report).toHaveProperty("nodes");
192+
expect(report).toHaveProperty("results");
193+
expect(report.results).toHaveProperty("test-node");
194+
195+
// Validate against JSON schema (same schema used by Python reporter)
196+
validateAgainstSchema(report, checkId);
197+
});
198+
}
199+
200+
test("generateAllReports produces valid reports for all checks", async () => {
201+
const reports = await checkup.generateAllReports(client, "test-node");
202+
203+
expect(Object.keys(reports).length).toBe(expressChecks.length);
204+
205+
for (const [checkId, report] of Object.entries(reports)) {
206+
validateAgainstSchema(report, checkId);
207+
}
208+
});
209+
210+
test("report structure matches Python reporter format", async () => {
211+
// Generate A003 (settings) report and verify structure matches what Python produces
212+
const report = await checkup.generateA003(client, "test-node");
213+
214+
// Check required fields match Python reporter output structure (per schema)
215+
expect(report).toHaveProperty("checkId", "A003");
216+
expect(report).toHaveProperty("checkTitle", "Postgres settings");
217+
expect(report).toHaveProperty("timestamptz");
218+
expect(report).toHaveProperty("nodes");
219+
expect(report.nodes).toHaveProperty("primary");
220+
expect(report.nodes).toHaveProperty("standbys");
221+
expect(report).toHaveProperty("results");
222+
223+
// Results should have node-specific data
224+
const nodeResult = report.results["test-node"];
225+
expect(nodeResult).toHaveProperty("data");
226+
227+
// A003 should have settings as keyed object
228+
expect(typeof nodeResult.data).toBe("object");
229+
230+
// Check postgres_version if present
231+
if (nodeResult.postgres_version) {
232+
expect(nodeResult.postgres_version).toHaveProperty("version");
233+
expect(nodeResult.postgres_version).toHaveProperty("server_version_num");
234+
expect(nodeResult.postgres_version).toHaveProperty("server_major_ver");
235+
expect(nodeResult.postgres_version).toHaveProperty("server_minor_ver");
236+
}
237+
});
238+
239+
test("H001 (invalid indexes) has correct data structure", async () => {
240+
const report = await checkup.generateH001(client, "test-node");
241+
validateAgainstSchema(report, "H001");
242+
243+
const nodeResult = report.results["test-node"];
244+
expect(nodeResult).toHaveProperty("data");
245+
// data should be an object with indexes (may be empty on fresh DB)
246+
expect(typeof nodeResult.data).toBe("object");
247+
});
248+
249+
test("H002 (unused indexes) has correct data structure", async () => {
250+
const report = await checkup.generateH002(client, "test-node");
251+
validateAgainstSchema(report, "H002");
252+
253+
const nodeResult = report.results["test-node"];
254+
expect(nodeResult).toHaveProperty("data");
255+
expect(typeof nodeResult.data).toBe("object");
256+
});
257+
258+
test("H004 (redundant indexes) has correct data structure", async () => {
259+
const report = await checkup.generateH004(client, "test-node");
260+
validateAgainstSchema(report, "H004");
261+
262+
const nodeResult = report.results["test-node"];
263+
expect(nodeResult).toHaveProperty("data");
264+
expect(typeof nodeResult.data).toBe("object");
265+
});
266+
});

0 commit comments

Comments
 (0)