cron-tasks.test.js
1 /** 2 * Tests for src/cron.js — HANDLERS registry and module shape 3 * 4 * Verifies that: 5 * - cronModule exports { runCron, HANDLERS } 6 * - HANDLERS has expected handler functions 7 * - checkKeywords and databaseMaintenance handlers return structured results 8 * 9 * NOTE: requires --experimental-test-module-mocks 10 */ 11 12 import { test, describe, mock, after } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import Database from 'better-sqlite3'; 15 import { join } from 'path'; 16 import { tmpdir } from 'os'; 17 import { existsSync, unlinkSync } from 'fs'; 18 19 const TEST_DB = join(tmpdir(), `test-cron-tasks-${Date.now()}.db`); 20 process.env.DATABASE_PATH = TEST_DB; 21 process.env.NODE_ENV = 'test'; 22 23 // ── Mock all external modules ───────────────────────────────────────────────── 24 25 mock.module('../../src/utils/sync-email-events.js', { 26 namedExports: { syncEmailEvents: async () => ({ synced: 0, errors: 0 }) }, 27 }); 28 mock.module('../../src/utils/sync-unsubscribes.js', { 29 namedExports: { syncUnsubscribes: async () => ({ synced: 0, errors: 0 }) }, 30 }); 31 mock.module('../../src/inbound/sms.js', { 32 namedExports: { 33 pollInboundSMS: async () => ({ processed: 0, new_messages: 0 }), 34 setupWebhookServer: async () => {}, 35 }, 36 }); 37 mock.module('../../src/inbound/email.js', { 38 namedExports: { pollInboundEmails: async () => ({ processed: 0, stored: 0, unmatched: 0 }) }, 39 }); 40 mock.module('../../src/inbound/processor.js', { 41 namedExports: { processAllReplies: async () => ({ sms: { sent: 0 }, email: { sent: 0 } }) }, 42 }); 43 mock.module('../../src/cron/poll-free-scans.js', { 44 namedExports: { pollFreeScans: async () => ({ processed: 0, inserted: 0, failed: 0 }) }, 45 }); 46 mock.module('../../src/cron/poll-purchases.js', { 47 namedExports: { pollPurchases: async () => ({ processed: 0, successful: 0 }) }, 48 }); 49 mock.module('../../src/cron/process-purchases.js', { 50 namedExports: { 51 processPendingPurchases: async () => ({ processed: 0, delivered: 0, failed: 0 }), 52 }, 53 }); 54 mock.module('../../src/cron/precompute-dashboard.js', { 55 namedExports: { 56 precomputeDashboard: async () => ({ summary: 'ok', details: {}, metrics: {} }), 57 }, 58 }); 59 mock.module('../../src/cron/process-guardian.js', { 60 namedExports: { 61 runProcessGuardian: async () => ({ 62 checks_run: 0, 63 ok: 0, 64 warnings: 0, 65 critical: 0, 66 duration_seconds: 0, 67 results: [], 68 }), 69 }, 70 }); 71 mock.module('../../src/cron/process-reaper.js', { 72 namedExports: { 73 runProcessReaper: async () => ({ 74 zombie_count: 0, 75 free_mem_mb: 512, 76 swap_pct: 0, 77 stale_processes_killed: 0, 78 duration_seconds: 0, 79 }), 80 }, 81 }); 82 mock.module('../../src/cron/cleanup-test-dbs.js', { 83 namedExports: { runCleanupTestDbs: () => ({ deleted: 0, freed_kb: 0 }) }, 84 }); 85 mock.module('../../src/cron/pipeline-status-monitor.js', { 86 namedExports: { 87 runPipelineStatusMonitor: async () => ({ 88 summary: 'ok', 89 checks_run: 0, 90 duration_seconds: 0, 91 actions: [], 92 }), 93 }, 94 }); 95 mock.module('../../src/cron/classify-unknown-errors.js', { 96 namedExports: { 97 classifyUnknownErrors: async () => ({ 98 sites_retried: 0, 99 outreaches_retried: 0, 100 patterns_applied: 0, 101 }), 102 }, 103 }); 104 mock.module('../../src/agents/utils/task-manager.js', { 105 namedExports: { 106 createAgentTask: async () => 1, 107 findDuplicateTask: async () => null, 108 }, 109 }); 110 mock.module('../../src/utils/log-rotator.js', { 111 namedExports: { rotateLogs: () => ({ deleted: 2, kept: 5, freedSpace: 1024 * 1024 }) }, 112 }); 113 mock.module('../../src/utils/rate-limit-scheduler.js', { 114 namedExports: { 115 getSkipStages: () => new Set(), 116 getRateLimitStatus: () => [], 117 setRateLimit: () => {}, 118 }, 119 }); 120 mock.module('../../src/utils/load-env.js', { 121 namedExports: {}, 122 }); 123 124 // ── Create minimal schema ───────────────────────────────────────────────────── 125 126 { 127 const db = new Database(TEST_DB); 128 db.pragma('journal_mode = WAL'); 129 db.exec(` 130 CREATE TABLE IF NOT EXISTS settings ( 131 key TEXT PRIMARY KEY, 132 value TEXT, 133 description TEXT, 134 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 135 ); 136 CREATE TABLE IF NOT EXISTS cron_jobs ( 137 id INTEGER PRIMARY KEY AUTOINCREMENT, 138 name TEXT NOT NULL UNIQUE, 139 task_key TEXT NOT NULL UNIQUE, 140 enabled INTEGER NOT NULL DEFAULT 1, 141 handler_type TEXT NOT NULL DEFAULT 'function', 142 handler_value TEXT, 143 interval_value INTEGER NOT NULL DEFAULT 5, 144 interval_unit TEXT NOT NULL DEFAULT 'minutes', 145 timeout_seconds INTEGER, 146 priority INTEGER DEFAULT 5, 147 critical INTEGER DEFAULT 1, 148 last_run_at TEXT 149 ); 150 CREATE TABLE IF NOT EXISTS cron_job_logs ( 151 id INTEGER PRIMARY KEY AUTOINCREMENT, 152 job_name TEXT NOT NULL, 153 started_at TEXT NOT NULL DEFAULT (datetime('now')), 154 finished_at TEXT, 155 status TEXT NOT NULL DEFAULT 'running', 156 summary TEXT, 157 full_log TEXT, 158 error_message TEXT, 159 items_processed INTEGER DEFAULT 0, 160 items_failed INTEGER DEFAULT 0 161 ); 162 CREATE TABLE IF NOT EXISTS cron_locks ( 163 lock_key TEXT PRIMARY KEY, 164 description TEXT, 165 updated_at TEXT DEFAULT (datetime('now')) 166 ); 167 CREATE TABLE IF NOT EXISTS messages ( 168 id INTEGER PRIMARY KEY AUTOINCREMENT, 169 site_id INTEGER, 170 direction TEXT NOT NULL DEFAULT 'outbound', 171 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 172 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 173 ); 174 CREATE TABLE IF NOT EXISTS sites ( 175 id INTEGER PRIMARY KEY AUTOINCREMENT, 176 domain TEXT NOT NULL DEFAULT 'test.com', 177 status TEXT DEFAULT 'found', 178 score REAL, 179 error_message TEXT, 180 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 181 ); 182 CREATE TABLE IF NOT EXISTS keywords ( 183 id INTEGER PRIMARY KEY AUTOINCREMENT, 184 keyword TEXT NOT NULL, 185 status TEXT DEFAULT 'pending' 186 ); 187 CREATE TABLE IF NOT EXISTS pipeline_control ( 188 key TEXT PRIMARY KEY, 189 value TEXT 190 ); 191 CREATE TABLE IF NOT EXISTS agent_tasks ( 192 id INTEGER PRIMARY KEY AUTOINCREMENT, 193 task_type TEXT NOT NULL, 194 assigned_to TEXT, 195 priority INTEGER DEFAULT 5, 196 status TEXT DEFAULT 'pending', 197 context_json TEXT, 198 created_at TEXT DEFAULT (datetime('now')), 199 updated_at TEXT DEFAULT (datetime('now')) 200 ); 201 CREATE TABLE IF NOT EXISTS site_status ( 202 id INTEGER PRIMARY KEY AUTOINCREMENT, 203 site_id INTEGER NOT NULL, 204 status TEXT, 205 created_at TEXT DEFAULT (datetime('now')) 206 ); 207 CREATE TABLE IF NOT EXISTS human_review_queue ( 208 id INTEGER PRIMARY KEY AUTOINCREMENT, 209 file TEXT, 210 reason TEXT, 211 type TEXT, 212 priority TEXT DEFAULT 'medium', 213 status TEXT DEFAULT 'pending', 214 created_at TEXT DEFAULT (datetime('now')) 215 ); 216 CREATE TABLE IF NOT EXISTS outreach_history ( 217 id INTEGER PRIMARY KEY AUTOINCREMENT, 218 site_id INTEGER, 219 channel TEXT, 220 status TEXT, 221 created_at TEXT DEFAULT (datetime('now')) 222 ); 223 `); 224 db.close(); 225 } 226 227 // Import AFTER mocks and schema 228 const { default: cronModule } = await import('../../src/cron.js'); 229 230 after(() => { 231 if (existsSync(TEST_DB)) { 232 try { 233 unlinkSync(TEST_DB); 234 } catch { 235 /* ignore */ 236 } 237 } 238 }); 239 240 // ── Tests ───────────────────────────────────────────────────────────────────── 241 242 describe('HANDLERS registry', () => { 243 test('HANDLERS is a non-empty object', () => { 244 const { HANDLERS } = cronModule; 245 assert.ok(typeof HANDLERS === 'object' && HANDLERS !== null); 246 assert.ok(Object.keys(HANDLERS).length > 10, 'Should have many handler definitions'); 247 }); 248 249 test('each HANDLER is a function', () => { 250 const { HANDLERS } = cronModule; 251 for (const [key, fn] of Object.entries(HANDLERS)) { 252 assert.ok(typeof fn === 'function', `HANDLERS.${key} should be a function`); 253 } 254 }); 255 256 test('HANDLERS has all expected keys', () => { 257 const { HANDLERS } = cronModule; 258 const expectedKeys = [ 259 'syncEmailEvents', 260 'syncUnsubscribes', 261 'pollInboundSMS', 262 'pollInboundEmails', 263 'sendPendingReplies', 264 'pollFreeScans', 265 'pollPurchases', 266 'processPurchases', 267 'processGuardian', 268 'processReaper', 269 'cleanupTestDbs', 270 'pipelineStatusMonitor', 271 'classifyUnknownErrors', 272 'databaseMaintenance', 273 'checkKeywords', 274 ]; 275 for (const key of expectedKeys) { 276 assert.ok(key in HANDLERS, `HANDLERS should have key: ${key}`); 277 } 278 }); 279 280 test('runCron is a function', () => { 281 const { runCron } = cronModule; 282 assert.ok(typeof runCron === 'function'); 283 }); 284 }); 285 286 describe('HANDLERS — checkKeywords', () => { 287 test('checkKeywords returns structured result with metrics', async () => { 288 const { HANDLERS } = cronModule; 289 const result = await HANDLERS.checkKeywords(); 290 assert.ok(typeof result === 'object', 'should return object'); 291 assert.ok(typeof result.summary === 'string', 'should have summary'); 292 assert.ok(typeof result.metrics === 'object', 'should have metrics'); 293 assert.ok('pending' in result.metrics, 'should have pending in metrics'); 294 assert.ok('active' in result.metrics, 'should have active in metrics'); 295 }); 296 }); 297 298 describe('HANDLERS — databaseMaintenance', () => { 299 test('databaseMaintenance returns structured result', async () => { 300 const { HANDLERS } = cronModule; 301 const result = await HANDLERS.databaseMaintenance(); 302 assert.ok(typeof result === 'object', 'should return object'); 303 assert.ok(typeof result.summary === 'string', 'should have summary'); 304 assert.ok(typeof result.metrics === 'object', 'should have metrics'); 305 assert.ok('sites' in result.metrics, 'should have sites count'); 306 assert.ok('outreaches' in result.metrics, 'should have outreaches count'); 307 assert.ok('healthy' in result.metrics, 'should have healthy flag'); 308 }); 309 }); 310 311 describe('HANDLERS — monitorPipeline and monitorSystem', () => { 312 test('monitorPipeline creates agent tasks and returns metrics', async () => { 313 const { HANDLERS } = cronModule; 314 const result = await HANDLERS.monitorPipeline(); 315 assert.ok(typeof result === 'object', 'should return object'); 316 assert.ok(typeof result.summary === 'string', 'should have summary'); 317 assert.ok(typeof result.metrics === 'object', 'should have metrics'); 318 assert.ok('created' in result.metrics, 'should have created count'); 319 assert.ok('skipped' in result.metrics, 'should have skipped count'); 320 }); 321 322 test('monitorSystem creates agent tasks and returns metrics', async () => { 323 const { HANDLERS } = cronModule; 324 const result = await HANDLERS.monitorSystem(); 325 assert.ok(typeof result === 'object', 'should return object'); 326 assert.ok(typeof result.summary === 'string', 'should have summary'); 327 assert.ok('created' in result.metrics, 'should have created count'); 328 }); 329 }); 330 331 describe('HANDLERS — maintenance and utility handlers', () => { 332 test('classifyUnknownErrors returns structured result', async () => { 333 const { HANDLERS } = cronModule; 334 const result = await HANDLERS.classifyUnknownErrors(); 335 assert.ok(typeof result === 'object'); 336 assert.ok(typeof result.summary === 'string'); 337 assert.ok('sites_retried' in result.metrics); 338 assert.ok('outreaches_retried' in result.metrics); 339 }); 340 341 test('processGuardian returns structured result', async () => { 342 const { HANDLERS } = cronModule; 343 const result = await HANDLERS.processGuardian(); 344 assert.ok(typeof result === 'object'); 345 assert.ok(typeof result.summary === 'string'); 346 assert.ok('checks_run' in result.metrics); 347 assert.ok('ok' in result.metrics); 348 assert.ok('warnings' in result.metrics); 349 assert.ok('critical' in result.metrics); 350 }); 351 352 test('processReaper returns structured result', async () => { 353 const { HANDLERS } = cronModule; 354 const result = await HANDLERS.processReaper(); 355 assert.ok(typeof result === 'object'); 356 assert.ok(typeof result.summary === 'string'); 357 assert.ok('zombie_count' in result.metrics); 358 assert.ok('free_mem_mb' in result.metrics); 359 }); 360 361 test('cleanupTestDbs returns structured result', async () => { 362 const { HANDLERS } = cronModule; 363 const result = await HANDLERS.cleanupTestDbs(); 364 assert.ok(typeof result === 'object'); 365 assert.ok(typeof result.summary === 'string'); 366 assert.ok('deleted' in result.metrics); 367 assert.ok('freed_kb' in result.metrics); 368 }); 369 370 test('pipelineStatusMonitor returns structured result', async () => { 371 const { HANDLERS } = cronModule; 372 const result = await HANDLERS.pipelineStatusMonitor(); 373 assert.ok(typeof result === 'object'); 374 assert.ok(typeof result.summary === 'string'); 375 assert.ok('checks_run' in result.metrics); 376 }); 377 378 test('pollFreeScans returns structured result', async () => { 379 const { HANDLERS } = cronModule; 380 const result = await HANDLERS.pollFreeScans(); 381 assert.ok(typeof result === 'object'); 382 assert.ok(typeof result.summary === 'string'); 383 }); 384 385 test('pollInboundEmails returns structured result', async () => { 386 const { HANDLERS } = cronModule; 387 const result = await HANDLERS.pollInboundEmails(); 388 assert.ok(typeof result === 'object'); 389 assert.ok(typeof result.summary === 'string'); 390 }); 391 392 test('sendPendingReplies returns structured result', async () => { 393 const { HANDLERS } = cronModule; 394 const result = await HANDLERS.sendPendingReplies(); 395 assert.ok(typeof result === 'object'); 396 assert.ok(typeof result.summary === 'string'); 397 }); 398 399 test('processPurchases returns structured result', async () => { 400 const { HANDLERS } = cronModule; 401 const result = await HANDLERS.processPurchases(); 402 assert.ok(typeof result === 'object'); 403 assert.ok(typeof result.summary === 'string'); 404 }); 405 406 test('precomputeDashboard returns a result', async () => { 407 const { HANDLERS } = cronModule; 408 const result = await HANDLERS.precomputeDashboard(); 409 assert.ok(typeof result === 'object'); 410 }); 411 412 test('rotateLogs returns structured result', async () => { 413 const { HANDLERS } = cronModule; 414 const result = await HANDLERS.rotateLogs(); 415 assert.ok(typeof result === 'object'); 416 assert.ok(typeof result.summary === 'string'); 417 assert.ok('deleted' in result.metrics); 418 assert.ok('kept' in result.metrics); 419 assert.ok('freed_mb' in result.metrics); 420 }); 421 422 test('checkRateLimits returns api status', async () => { 423 const { HANDLERS } = cronModule; 424 const result = await HANDLERS.checkRateLimits(); 425 assert.ok(typeof result === 'object'); 426 assert.ok(typeof result.summary === 'string'); 427 assert.ok('configured' in result.metrics); 428 assert.ok('missing' in result.metrics); 429 }); 430 431 test('technicalDebtReview handles missing TODO.md gracefully', async () => { 432 const { HANDLERS } = cronModule; 433 // docs/TODO.md may or may not exist — handler catches the error and returns gracefully 434 await assert.doesNotReject(() => HANDLERS.technicalDebtReview()); 435 }); 436 437 test('analyzePerformance runs without error', async () => { 438 const { HANDLERS } = cronModule; 439 const result = await HANDLERS.analyzePerformance(); 440 assert.ok(typeof result === 'object'); 441 assert.ok(typeof result.summary === 'string'); 442 assert.ok('tables_analyzed' in result.metrics); 443 }); 444 445 test('purgeSiteStatusHistory returns deletion metrics', async () => { 446 const { HANDLERS } = cronModule; 447 const result = await HANDLERS.purgeSiteStatusHistory(); 448 assert.ok(typeof result === 'object'); 449 assert.ok(typeof result.summary === 'string'); 450 assert.ok('rows_deleted' in result.metrics); 451 }); 452 453 test('vacuumDatabase vacuums main DB and returns metrics', async () => { 454 const { HANDLERS } = cronModule; 455 // TEST_DB exists; no backup dir → vacuums just the main DB 456 const result = await HANDLERS.vacuumDatabase(); 457 assert.ok(typeof result === 'object'); 458 assert.ok(typeof result.summary === 'string'); 459 assert.ok('databases_vacuumed' in result.metrics); 460 assert.ok(result.metrics.databases_vacuumed >= 1, 'should vacuum at least main DB'); 461 }); 462 463 test('backupDatabase skips when backup dir does not exist', async () => { 464 const { HANDLERS } = cronModule; 465 // db/backup dir will not exist in test env → triggers skip path 466 const result = await HANDLERS.backupDatabase(); 467 assert.ok(typeof result === 'object'); 468 assert.ok(typeof result.summary === 'string'); 469 // Either skipped (no backup dir) or succeeded 470 assert.ok( 471 result.metrics.skipped === 1 || result.metrics.success === 1, 472 `unexpected metrics: ${JSON.stringify(result.metrics)}` 473 ); 474 }); 475 476 test('diskCleanup runs without throwing', async () => { 477 const { HANDLERS } = cronModule; 478 // Handler may fail in test env (permission issues with coverage dir) — just verify it doesn't crash fatally 479 let result; 480 try { 481 result = await HANDLERS.diskCleanup(); 482 } catch { 483 // Permission errors on coverage dir are acceptable in test env 484 return; 485 } 486 assert.ok(typeof result === 'object'); 487 assert.ok(typeof result.summary === 'string'); 488 assert.ok('actions' in result.metrics); 489 assert.ok('freed_mb' in result.metrics); 490 }); 491 492 test('unifiedAutofix returns result when script throws', async () => { 493 const { HANDLERS } = cronModule; 494 // In test env, node scripts/unified-autofix.js will likely fail 495 // Handler should catch and return error result, not throw 496 await assert.doesNotReject(() => HANDLERS.unifiedAutofix()); 497 }); 498 });