|
1 | 1 | /** |
2 | | - * JSON Schema validation tests for H001, H002, H004 express checkup reports. |
3 | | - * These tests validate that the generated reports match the schemas in reporter/schemas/. |
| 2 | + * JSON Schema validation tests for express checkup reports. |
| 3 | + * Validates that generated reports match schemas in reporter/schemas/. |
4 | 4 | */ |
5 | 5 | import { describe, test, expect } from "bun:test"; |
6 | 6 | import { resolve } from "path"; |
7 | 7 | import { readFileSync } from "fs"; |
8 | 8 | import Ajv2020 from "ajv/dist/2020"; |
9 | 9 |
|
10 | 10 | import * as checkup from "../lib/checkup"; |
| 11 | +import { createMockClient } from "./test-utils"; |
11 | 12 |
|
12 | 13 | const ajv = new Ajv2020({ allErrors: true, strict: false }); |
13 | 14 | const schemasDir = resolve(import.meta.dir, "../../reporter/schemas"); |
14 | 15 |
|
15 | | -function loadSchema(checkId: string): object { |
| 16 | +function validateAgainstSchema(report: any, checkId: string): void { |
16 | 17 | const schemaPath = resolve(schemasDir, `${checkId}.schema.json`); |
17 | | - return JSON.parse(readFileSync(schemaPath, "utf8")); |
18 | | -} |
19 | | - |
20 | | -function validateReport(report: any, checkId: string): { valid: boolean; errors: string[] } { |
21 | | - const schema = loadSchema(checkId); |
| 18 | + const schema = JSON.parse(readFileSync(schemaPath, "utf8")); |
22 | 19 | const validate = ajv.compile(schema); |
23 | 20 | const valid = validate(report); |
24 | | - const errors = validate.errors?.map(e => `${e.instancePath}: ${e.message}`) || []; |
25 | | - return { valid: !!valid, errors }; |
| 21 | + if (!valid) { |
| 22 | + const errors = validate.errors?.map(e => `${e.instancePath}: ${e.message}`).join(", "); |
| 23 | + throw new Error(`${checkId} schema validation failed: ${errors}`); |
| 24 | + } |
26 | 25 | } |
27 | 26 |
|
28 | | -// Mock client for testing |
29 | | -function createMockClient(options: { |
30 | | - versionRows?: any[]; |
31 | | - settingsRows?: any[]; |
32 | | - invalidIndexesRows?: any[]; |
33 | | - unusedIndexesRows?: any[]; |
34 | | - redundantIndexesRows?: any[]; |
35 | | -} = {}) { |
36 | | - const { |
37 | | - versionRows = [ |
38 | | - { name: "server_version", setting: "16.3" }, |
39 | | - { name: "server_version_num", setting: "160003" }, |
40 | | - ], |
41 | | - settingsRows = [ |
42 | | - { tag_setting_name: "shared_buffers", tag_setting_value: "128MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 1, setting_normalized: null, unit_normalized: null }, |
43 | | - { tag_setting_name: "work_mem", tag_setting_value: "4MB", tag_unit: "", tag_category: "Resource Usage / Memory", tag_vartype: "string", is_default: 1, setting_normalized: null, unit_normalized: null }, |
44 | | - { tag_setting_name: "autovacuum", tag_setting_value: "on", tag_unit: "", tag_category: "Autovacuum", tag_vartype: "bool", is_default: 1, setting_normalized: null, unit_normalized: null }, |
45 | | - { tag_setting_name: "pg_stat_statements.max", tag_setting_value: "5000", tag_unit: "", tag_category: "Custom", tag_vartype: "integer", is_default: 0, setting_normalized: null, unit_normalized: null }, |
46 | | - ], |
47 | | - invalidIndexesRows = [], |
48 | | - unusedIndexesRows = [], |
49 | | - redundantIndexesRows = [], |
50 | | - } = options; |
51 | | - |
52 | | - return { |
53 | | - query: async (sql: string) => { |
54 | | - // Version query (simple inline - used by getPostgresVersion) |
55 | | - if (sql.includes("server_version") && sql.includes("server_version_num") && sql.includes("pg_settings") && !sql.includes("tag_setting_name")) { |
56 | | - return { rows: versionRows }; |
57 | | - } |
58 | | - // Settings metric query (from metrics.yml - has tag_setting_name, tag_setting_value) |
59 | | - if (sql.includes("tag_setting_name") && sql.includes("tag_setting_value") && sql.includes("pg_settings")) { |
60 | | - return { rows: settingsRows }; |
61 | | - } |
62 | | - // db_size metric (current database size from metrics.yml) |
63 | | - if (sql.includes("pg_database_size(current_database())") && sql.includes("size_b")) { |
64 | | - return { rows: [{ tag_datname: "testdb", size_b: "1073741824" }] }; |
65 | | - } |
66 | | - // Stats reset metric (from metrics.yml) |
67 | | - if (sql.includes("stats_reset") && sql.includes("pg_stat_database") && sql.includes("seconds_since_reset")) { |
68 | | - return { rows: [{ |
69 | | - tag_database_name: "testdb", |
70 | | - stats_reset_epoch: "1704067200", |
71 | | - seconds_since_reset: "2592000" |
72 | | - }] }; |
73 | | - } |
74 | | - // Postmaster startup time (simple inline - used by getStatsReset) |
75 | | - if (sql.includes("pg_postmaster_start_time") && sql.includes("postmaster_startup_epoch")) { |
76 | | - return { rows: [{ |
77 | | - postmaster_startup_epoch: "1704067200", |
78 | | - postmaster_startup_time: "2024-01-01 00:00:00+00" |
79 | | - }] }; |
80 | | - } |
81 | | - // Invalid indexes (H001) - from metrics.yml |
82 | | - if (sql.includes("indisvalid = false") && sql.includes("fk_indexes")) { |
83 | | - return { rows: invalidIndexesRows }; |
84 | | - } |
85 | | - // Unused indexes (H002) - from metrics.yml |
86 | | - if (sql.includes("Never Used Indexes") && sql.includes("idx_scan = 0")) { |
87 | | - return { rows: unusedIndexesRows }; |
88 | | - } |
89 | | - // Redundant indexes (H004) - from metrics.yml |
90 | | - if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) { |
91 | | - return { rows: redundantIndexesRows }; |
92 | | - } |
93 | | - // D004: pg_stat_statements extension check |
94 | | - if (sql.includes("pg_extension") && sql.includes("pg_stat_statements")) { |
95 | | - return { rows: [] }; // Extension not installed |
96 | | - } |
97 | | - // D004: pg_stat_kcache extension check |
98 | | - if (sql.includes("pg_extension") && sql.includes("pg_stat_kcache")) { |
99 | | - return { rows: [] }; // Extension not installed |
100 | | - } |
101 | | - // G001: Memory settings query |
102 | | - if (sql.includes("pg_size_bytes") && sql.includes("shared_buffers") && sql.includes("work_mem")) { |
103 | | - return { rows: [{ |
104 | | - shared_buffers_bytes: "134217728", |
105 | | - wal_buffers_bytes: "4194304", |
106 | | - work_mem_bytes: "4194304", |
107 | | - maintenance_work_mem_bytes: "67108864", |
108 | | - effective_cache_size_bytes: "4294967296", |
109 | | - max_connections: 100, |
110 | | - }] }; |
111 | | - } |
112 | | - throw new Error(`Unexpected query: ${sql}`); |
113 | | - }, |
114 | | - }; |
115 | | -} |
116 | | - |
117 | | -describe("H001 schema validation", () => { |
118 | | - test("H001 report with empty data validates against schema", async () => { |
119 | | - const mockClient = createMockClient({ invalidIndexesRows: [] }); |
120 | | - const report = await checkup.generateH001(mockClient as any, "node-01"); |
121 | | - |
122 | | - const result = validateReport(report, "H001"); |
123 | | - if (!result.valid) { |
124 | | - console.error("H001 validation errors:", result.errors); |
125 | | - } |
126 | | - expect(result.valid).toBe(true); |
127 | | - }); |
128 | | - |
129 | | - test("H001 report with data validates against schema", async () => { |
130 | | - const mockClient = createMockClient({ |
| 27 | +// Test data for index reports |
| 28 | +const indexTestData = { |
| 29 | + H001: { |
| 30 | + emptyRows: { invalidIndexesRows: [] }, |
| 31 | + dataRows: { |
131 | 32 | invalidIndexesRows: [ |
132 | | - { |
133 | | - schema_name: "public", |
134 | | - table_name: "users", |
135 | | - index_name: "users_email_idx", |
136 | | - relation_name: "users", |
137 | | - index_size_bytes: "1048576", |
138 | | - supports_fk: false |
139 | | - }, |
| 33 | + { schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false }, |
140 | 34 | ], |
141 | | - }); |
142 | | - const report = await checkup.generateH001(mockClient as any, "node-01"); |
143 | | - |
144 | | - const result = validateReport(report, "H001"); |
145 | | - if (!result.valid) { |
146 | | - console.error("H001 validation errors:", result.errors); |
147 | | - } |
148 | | - expect(result.valid).toBe(true); |
149 | | - }); |
150 | | -}); |
151 | | - |
152 | | -describe("H002 schema validation", () => { |
153 | | - test("H002 report with empty data validates against schema", async () => { |
154 | | - const mockClient = createMockClient({ unusedIndexesRows: [] }); |
155 | | - const report = await checkup.generateH002(mockClient as any, "node-01"); |
156 | | - |
157 | | - const result = validateReport(report, "H002"); |
158 | | - if (!result.valid) { |
159 | | - console.error("H002 validation errors:", result.errors); |
160 | | - } |
161 | | - expect(result.valid).toBe(true); |
162 | | - }); |
163 | | - |
164 | | - test("H002 report with data validates against schema", async () => { |
165 | | - const mockClient = createMockClient({ |
| 35 | + }, |
| 36 | + }, |
| 37 | + H002: { |
| 38 | + emptyRows: { unusedIndexesRows: [] }, |
| 39 | + dataRows: { |
166 | 40 | unusedIndexesRows: [ |
167 | | - { |
168 | | - schema_name: "public", |
169 | | - table_name: "logs", |
170 | | - index_name: "logs_created_idx", |
171 | | - index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)", |
172 | | - reason: "Never Used Indexes", |
173 | | - idx_scan: "0", |
174 | | - index_size_bytes: "8388608", |
175 | | - idx_is_btree: true, |
176 | | - supports_fk: false |
177 | | - }, |
| 41 | + { schema_name: "public", table_name: "logs", index_name: "logs_created_idx", index_definition: "CREATE INDEX logs_created_idx ON public.logs USING btree (created_at)", reason: "Never Used Indexes", idx_scan: "0", index_size_bytes: "8388608", idx_is_btree: true, supports_fk: false }, |
178 | 42 | ], |
179 | | - }); |
180 | | - const report = await checkup.generateH002(mockClient as any, "node-01"); |
181 | | - |
182 | | - const result = validateReport(report, "H002"); |
183 | | - if (!result.valid) { |
184 | | - console.error("H002 validation errors:", result.errors); |
185 | | - } |
186 | | - expect(result.valid).toBe(true); |
187 | | - }); |
188 | | -}); |
189 | | - |
190 | | -describe("H004 schema validation", () => { |
191 | | - test("H004 report with empty data validates against schema", async () => { |
192 | | - const mockClient = createMockClient({ redundantIndexesRows: [] }); |
193 | | - const report = await checkup.generateH004(mockClient as any, "node-01"); |
194 | | - |
195 | | - const result = validateReport(report, "H004"); |
196 | | - if (!result.valid) { |
197 | | - console.error("H004 validation errors:", result.errors); |
198 | | - } |
199 | | - expect(result.valid).toBe(true); |
200 | | - }); |
201 | | - |
202 | | - test("H004 report with data validates against schema", async () => { |
203 | | - const mockClient = createMockClient({ |
| 43 | + }, |
| 44 | + }, |
| 45 | + H004: { |
| 46 | + emptyRows: { redundantIndexesRows: [] }, |
| 47 | + dataRows: { |
204 | 48 | redundantIndexesRows: [ |
205 | | - { |
206 | | - schema_name: "public", |
207 | | - table_name: "orders", |
208 | | - index_name: "orders_user_id_idx", |
209 | | - relation_name: "orders", |
210 | | - access_method: "btree", |
211 | | - reason: "public.orders_user_id_created_idx", |
212 | | - index_size_bytes: "2097152", |
213 | | - table_size_bytes: "16777216", |
214 | | - index_usage: "0", |
215 | | - supports_fk: false, |
216 | | - index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)", |
217 | | - redundant_to_json: JSON.stringify([ |
218 | | - { 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 } |
219 | | - ]) |
220 | | - }, |
| 49 | + { schema_name: "public", table_name: "orders", index_name: "orders_user_id_idx", relation_name: "orders", access_method: "btree", reason: "public.orders_user_id_created_idx", index_size_bytes: "2097152", table_size_bytes: "16777216", index_usage: "0", supports_fk: false, index_definition: "CREATE INDEX orders_user_id_idx ON public.orders USING btree (user_id)", redundant_to_json: JSON.stringify([{ index_name: "public.orders_user_id_created_idx", index_definition: "CREATE INDEX ...", index_size_bytes: 1048576 }]) }, |
221 | 50 | ], |
| 51 | + }, |
| 52 | + }, |
| 53 | +}; |
| 54 | + |
| 55 | +describe("Schema validation", () => { |
| 56 | + // Index health checks (H001, H002, H004) - test empty and with data |
| 57 | + for (const [checkId, testData] of Object.entries(indexTestData)) { |
| 58 | + const generator = checkup.REPORT_GENERATORS[checkId]; |
| 59 | + |
| 60 | + test(`${checkId} validates with empty data`, async () => { |
| 61 | + const mockClient = createMockClient(testData.emptyRows); |
| 62 | + const report = await generator(mockClient as any, "node-01"); |
| 63 | + validateAgainstSchema(report, checkId); |
222 | 64 | }); |
223 | | - const report = await checkup.generateH004(mockClient as any, "node-01"); |
224 | | - |
225 | | - const result = validateReport(report, "H004"); |
226 | | - if (!result.valid) { |
227 | | - console.error("H004 validation errors:", result.errors); |
228 | | - } |
229 | | - expect(result.valid).toBe(true); |
230 | | - }); |
231 | | -}); |
232 | 65 |
|
233 | | -describe("D004 schema validation", () => { |
234 | | - test("D004 report validates against schema (extensions not installed)", async () => { |
235 | | - const mockClient = createMockClient(); |
236 | | - const report = await checkup.REPORT_GENERATORS.D004(mockClient as any, "node-01"); |
237 | | - |
238 | | - const result = validateReport(report, "D004"); |
239 | | - if (!result.valid) { |
240 | | - console.error("D004 validation errors:", result.errors); |
241 | | - } |
242 | | - expect(result.valid).toBe(true); |
243 | | - }); |
244 | | -}); |
245 | | - |
246 | | -describe("F001 schema validation", () => { |
247 | | - test("F001 report validates against schema", async () => { |
248 | | - const mockClient = createMockClient(); |
249 | | - const report = await checkup.REPORT_GENERATORS.F001(mockClient as any, "node-01"); |
250 | | - |
251 | | - const result = validateReport(report, "F001"); |
252 | | - if (!result.valid) { |
253 | | - console.error("F001 validation errors:", result.errors); |
254 | | - } |
255 | | - expect(result.valid).toBe(true); |
256 | | - }); |
257 | | -}); |
258 | | - |
259 | | -describe("G001 schema validation", () => { |
260 | | - test("G001 report validates against schema", async () => { |
261 | | - const mockClient = createMockClient(); |
262 | | - const report = await checkup.REPORT_GENERATORS.G001(mockClient as any, "node-01"); |
263 | | - |
264 | | - const result = validateReport(report, "G001"); |
265 | | - if (!result.valid) { |
266 | | - console.error("G001 validation errors:", result.errors); |
267 | | - } |
268 | | - expect(result.valid).toBe(true); |
269 | | - }); |
| 66 | + test(`${checkId} validates with sample data`, async () => { |
| 67 | + const mockClient = createMockClient(testData.dataRows); |
| 68 | + const report = await generator(mockClient as any, "node-01"); |
| 69 | + validateAgainstSchema(report, checkId); |
| 70 | + }); |
| 71 | + } |
| 72 | + |
| 73 | + // Settings reports (D004, F001, G001) - single test each |
| 74 | + for (const checkId of ["D004", "F001", "G001"]) { |
| 75 | + test(`${checkId} validates against schema`, async () => { |
| 76 | + const mockClient = createMockClient(); |
| 77 | + const report = await checkup.REPORT_GENERATORS[checkId](mockClient as any, "node-01"); |
| 78 | + validateAgainstSchema(report, checkId); |
| 79 | + }); |
| 80 | + } |
270 | 81 | }); |
271 | | - |
0 commit comments