analyze-performance.test.js
1 /** 2 * Tests for analyzePerformance cron handler 3 * Verifies detection of missing indexes, fragmentation, and human_review_queue integration 4 */ 5 6 import { test } from 'node:test'; 7 import assert from 'node:assert'; 8 import Database from 'better-sqlite3'; 9 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 10 11 /** 12 * Create a minimal in-memory DB with the tables analyzePerformance queries 13 */ 14 function createTestDb() { 15 const db = new Database(':memory:'); 16 17 db.exec(` 18 CREATE TABLE sites ( 19 id INTEGER PRIMARY KEY AUTOINCREMENT, 20 domain TEXT, landing_page_url TEXT, keyword TEXT, 21 screenshot_path TEXT, status TEXT DEFAULT 'found', 22 error_message TEXT, score REAL, grade TEXT, 23 country_code TEXT, recapture_at DATETIME, 24 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 25 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 26 rescored_at DATETIME 27 ); 28 29 CREATE TABLE messages ( 30 id INTEGER PRIMARY KEY AUTOINCREMENT, 31 site_id INTEGER, 32 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 33 message_body TEXT, 34 contact_method TEXT, contact_uri TEXT, 35 approval_status TEXT DEFAULT 'pending', 36 delivery_status TEXT, 37 read_at TEXT, 38 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, 39 message_type TEXT DEFAULT 'outreach', 40 raw_payload TEXT 41 ); 42 CREATE TABLE llm_usage ( 43 id INTEGER PRIMARY KEY AUTOINCREMENT, 44 site_id INTEGER, stage TEXT, provider TEXT, model TEXT, 45 prompt_tokens INTEGER, completion_tokens INTEGER, total_tokens INTEGER, 46 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 47 ); 48 49 CREATE TABLE cron_job_logs ( 50 id INTEGER PRIMARY KEY AUTOINCREMENT, 51 job_name TEXT, started_at DATETIME, status TEXT, 52 summary TEXT, full_log TEXT, items_processed INTEGER DEFAULT 0, 53 items_failed INTEGER DEFAULT 0, error_message TEXT, 54 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 55 ); 56 57 CREATE TABLE config ( 58 key TEXT PRIMARY KEY, value TEXT, description TEXT, 59 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 60 ); 61 62 CREATE TABLE keywords ( 63 id INTEGER PRIMARY KEY AUTOINCREMENT, 64 keyword TEXT, country_code TEXT, status TEXT DEFAULT 'active', 65 search_volume INTEGER DEFAULT 0 66 ); 67 `); 68 69 return db; 70 } 71 72 /** 73 * Simulate analyzePerformance logic against a given db 74 * (We test the logic directly rather than importing cron which has many deps) 75 */ 76 function analyzePerformance(db) { 77 // Ensure human_review_queue table exists 78 db.exec(` 79 CREATE TABLE IF NOT EXISTS human_review_queue ( 80 id INTEGER PRIMARY KEY AUTOINCREMENT, 81 file TEXT, reason TEXT, type TEXT, 82 priority TEXT DEFAULT 'medium', 83 status TEXT DEFAULT 'pending', 84 created_at TEXT DEFAULT (datetime('now')), 85 reviewed_at TEXT, reviewed_by TEXT, notes TEXT 86 ) 87 `); 88 89 // Gather tables 90 const tables = db 91 .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") 92 .all() 93 .map(t => t.name); 94 95 const tableStats = []; 96 for (const table of tables) { 97 try { 98 const row = db.prepare(`SELECT COUNT(*) as rows FROM "${table}"`).get(); 99 tableStats.push({ table_name: table, rows: row.rows }); 100 } catch { 101 // skip 102 } 103 } 104 105 // Gather indexes 106 const indexStats = db 107 .prepare("SELECT name, tbl_name, sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL") 108 .all(); 109 110 // Build indexed column map 111 const indexedColumns = {}; 112 for (const idx of indexStats) { 113 if (!indexedColumns[idx.tbl_name]) indexedColumns[idx.tbl_name] = []; 114 const colMatch = idx.sql.match(/\(([^)]+)\)/); 115 if (colMatch) { 116 const cols = colMatch[1] 117 .split(',') 118 .map(c => c.trim().replace(/ (ASC|DESC|COLLATE \w+)/gi, '')); 119 indexedColumns[idx.tbl_name].push(cols); 120 } 121 } 122 123 const recommendations = []; 124 125 const commonQueries = [ 126 { 127 sql: "SELECT * FROM sites WHERE status = 'prog_scored' AND error_message IS NOT NULL LIMIT 1", 128 table: 'sites', 129 cols: ['status', 'error_message'], 130 desc: 'Retry failed scoring', 131 }, 132 { 133 sql: "SELECT * FROM messages WHERE direction = 'inbound' AND read_at IS NULL LIMIT 1", 134 table: 'messages', 135 cols: ['direction', 'read_at'], 136 desc: 'Unread inbound conversations', 137 }, 138 ]; 139 140 for (const { sql, table, cols, desc } of commonQueries) { 141 try { 142 const plan = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(); 143 const scanRow = plan.find(r => r.detail && r.detail.match(/\bSCAN\b/)); 144 145 if (scanRow) { 146 const tableIndexes = indexedColumns[table] || []; 147 const alreadyCovered = tableIndexes.some(idxCols => { 148 return cols.every((c, i) => idxCols[i] && idxCols[i].toLowerCase() === c.toLowerCase()); 149 }); 150 151 if (!alreadyCovered) { 152 const idxName = `idx_${table}_${cols.join('_')}`; 153 const idxSql = `CREATE INDEX IF NOT EXISTS ${idxName} ON ${table}(${cols.join(', ')});`; 154 recommendations.push({ 155 file: `${table}:${cols.join(',')}`, 156 priority: 'medium', 157 reason: `Table scan on ${table} for: ${desc}\nSuggested: ${idxSql}`, 158 }); 159 } 160 } 161 } catch { 162 // skip 163 } 164 } 165 166 // Store in queue (deduplicated) 167 let newCount = 0; 168 const insertStmt = db.prepare( 169 "INSERT INTO human_review_queue (file, reason, type, priority) VALUES (?, ?, 'performance', ?)" 170 ); 171 const checkStmt = db.prepare( 172 "SELECT COUNT(*) as count FROM human_review_queue WHERE type = 'performance' AND file = ? AND status = 'pending'" 173 ); 174 175 for (const rec of recommendations) { 176 const existing = checkStmt.get(rec.file); 177 if (existing.count === 0) { 178 insertStmt.run(rec.file, rec.reason, rec.priority); 179 newCount++; 180 } 181 } 182 183 return { tableStats, indexStats, recommendations, newCount }; 184 } 185 186 test('analyzePerformance detects table scans on unindexed columns', () => { 187 const db = createTestDb(); 188 189 // No indexes on sites or conversations - should detect table scans 190 const result = analyzePerformance(db); 191 192 // Should find recommendations for missing indexes 193 assert.ok( 194 result.recommendations.length > 0, 195 'Should find at least one missing index recommendation' 196 ); 197 198 const siteRec = result.recommendations.find(r => r.file.includes('sites:')); 199 assert.ok(siteRec, 'Should recommend index on sites table'); 200 assert.ok(siteRec.reason.includes('Table scan'), 'Reason should mention table scan'); 201 assert.ok( 202 siteRec.reason.includes('CREATE INDEX'), 203 'Reason should include CREATE INDEX suggestion' 204 ); 205 206 db.close(); 207 }); 208 209 test('analyzePerformance does not flag columns that already have indexes', () => { 210 const db = createTestDb(); 211 212 // Add the indexes that would be recommended 213 db.exec(` 214 CREATE INDEX idx_sites_status ON sites(status); 215 CREATE INDEX idx_sites_status_error_message ON sites(status, error_message); 216 CREATE INDEX idx_messages_direction ON messages(direction); 217 CREATE INDEX idx_messages_direction_read_at ON messages(direction, read_at); 218 `); 219 220 const result = analyzePerformance(db); 221 222 // Should NOT find recommendations for already-indexed columns 223 const siteRec = result.recommendations.find(r => r.file === 'sites:status,error_message'); 224 assert.strictEqual(siteRec, undefined, 'Should not recommend index that already exists'); 225 226 const convRec = result.recommendations.find(r => r.file === 'messages:direction,read_at'); 227 assert.strictEqual(convRec, undefined, 'Should not recommend index that already exists'); 228 229 db.close(); 230 }); 231 232 test('analyzePerformance stores recommendations in human_review_queue', () => { 233 const db = createTestDb(); 234 235 const result = analyzePerformance(db); 236 237 // Check queue 238 const queueItems = db 239 .prepare("SELECT * FROM human_review_queue WHERE type = 'performance'") 240 .all(); 241 242 assert.strictEqual( 243 queueItems.length, 244 result.newCount, 245 'Queue count should match new recommendations' 246 ); 247 assert.ok(queueItems.length > 0, 'Should have items in queue'); 248 249 for (const item of queueItems) { 250 assert.strictEqual(item.type, 'performance'); 251 assert.strictEqual(item.status, 'pending'); 252 assert.ok(item.file, 'Should have a file identifier'); 253 assert.ok(item.reason, 'Should have a reason'); 254 } 255 256 db.close(); 257 }); 258 259 test('analyzePerformance deduplicates recommendations on repeated runs', () => { 260 const db = createTestDb(); 261 262 // Run twice 263 const result1 = analyzePerformance(db); 264 const result2 = analyzePerformance(db); 265 266 // Second run should find 0 new recommendations 267 assert.strictEqual(result2.newCount, 0, 'Second run should not add duplicate recommendations'); 268 269 // Queue should still have the same count as first run 270 const queueItems = db 271 .prepare("SELECT * FROM human_review_queue WHERE type = 'performance' AND status = 'pending'") 272 .all(); 273 assert.strictEqual(queueItems.length, result1.newCount, 'Queue should not grow on repeated runs'); 274 275 db.close(); 276 }); 277 278 test('analyzePerformance creates new recommendations after previous ones are reviewed', () => { 279 const db = createTestDb(); 280 281 // First run 282 const result1 = analyzePerformance(db); 283 assert.ok(result1.newCount > 0, 'Should find recommendations'); 284 285 // Mark all as reviewed 286 db.prepare("UPDATE human_review_queue SET status = 'reviewed' WHERE type = 'performance'").run(); 287 288 // Run again - should create new recommendations since old ones are reviewed 289 const result2 = analyzePerformance(db); 290 assert.ok( 291 result2.newCount > 0, 292 'Should create new recommendations after previous ones are reviewed' 293 ); 294 295 db.close(); 296 }); 297 298 test('analyzePerformance counts tables correctly', () => { 299 const db = createTestDb(); 300 301 const result = analyzePerformance(db); 302 303 // Should count all tables we created 304 assert.ok(result.tableStats.length >= 7, 'Should analyze at least 7 tables'); 305 306 const siteStats = result.tableStats.find(t => t.table_name === 'sites'); 307 assert.ok(siteStats, 'Should include sites table'); 308 assert.strictEqual(siteStats.rows, 0, 'Empty table should have 0 rows'); 309 310 db.close(); 311 }); 312 313 test('analyzePerformance counts indexes correctly', () => { 314 const db = createTestDb(); 315 316 // Add some indexes 317 db.exec(` 318 CREATE INDEX idx_sites_status ON sites(status); 319 CREATE INDEX idx_sites_domain ON sites(domain); 320 `); 321 322 const result = analyzePerformance(db); 323 324 assert.ok(result.indexStats.length >= 2, 'Should count at least 2 user-defined indexes'); 325 326 db.close(); 327 });