cron-coverage-boost.test.js
1 /** 2 * Tests for src/cron.js — comprehensive coverage boost 3 * 4 * This test file is designed to run LAST in the combined test suite and must 5 * achieve high standalone coverage since the module-mock isolation means the 6 * last loaded cron.js instance's coverage is used. 7 * 8 * Targets: 9 * 1. All HANDLER bodies (checkKeywords, databaseMaintenance, vacuumDatabase, 10 * backupDatabase, analyzePerformance, rotateLogs, checkRateLimits, 11 * purgeSiteStatusHistory, diskCleanup, technicalDebtReview, unifiedAutofix, 12 * and all the pipeline/inbound handlers) 13 * 2. logTaskComplete branches (lines 1570, 1572-1573) 14 * 3. logTaskFailed (lines 1591-1625) 15 * 4. runCron (lines 1630-1813) — the main cron runner loop 16 * 5. checkAndClearStaleLock (lines 50-81) 17 * 6. generateSummary edge cases (lines 1532-1545) 18 * 7. intervalToMinutes, shouldRun, loadJobs, updateLastRun helpers 19 * 20 * NOTE: requires --experimental-test-module-mocks flag 21 */ 22 23 import { test, describe, mock, after } from 'node:test'; 24 import assert from 'node:assert/strict'; 25 import Database from 'better-sqlite3'; 26 import { join, dirname } from 'path'; 27 import { tmpdir } from 'os'; 28 import { existsSync, unlinkSync, mkdirSync, symlinkSync, lstatSync, rmSync, readdirSync } from 'fs'; 29 import { fileURLToPath } from 'url'; 30 31 const __filename = fileURLToPath(import.meta.url); 32 const __dirname = dirname(__filename); 33 34 const TEST_DB = join(tmpdir(), `test-cron-boost-${Date.now()}.db`); 35 const OPS_DB = join(tmpdir(), `test-cron-boost-ops-${Date.now()}.db`); 36 const TEL_DB = join(tmpdir(), `test-cron-boost-tel-${Date.now()}.db`); 37 process.env.DATABASE_PATH = TEST_DB; 38 process.env.OPS_DB_PATH = OPS_DB; 39 process.env.TEL_DB_PATH = TEL_DB; 40 process.env.NODE_ENV = 'test'; 41 42 // ── Mock ALL external dependencies BEFORE importing cron.js ────────────────── 43 44 mock.module('../../src/utils/sync-email-events.js', { 45 namedExports: { syncEmailEvents: async () => ({ synced: 2, errors: 0 }) }, 46 }); 47 mock.module('../../src/utils/sync-unsubscribes.js', { 48 namedExports: { syncUnsubscribes: async () => ({ synced: 1, errors: 0 }) }, 49 }); 50 mock.module('../../src/inbound/sms.js', { 51 namedExports: { 52 pollInboundSMS: async () => ({ processed: 1, new_messages: 1 }), 53 setupWebhookServer: async () => {}, 54 }, 55 }); 56 mock.module('../../src/inbound/email.js', { 57 namedExports: { pollInboundEmails: async () => ({ processed: 1, stored: 1, unmatched: 0 }) }, 58 }); 59 mock.module('../../src/inbound/processor.js', { 60 namedExports: { processAllReplies: async () => ({ sms: { sent: 1 }, email: { sent: 0 } }) }, 61 }); 62 mock.module('../../src/cron/poll-free-scans.js', { 63 namedExports: { pollFreeScans: async () => ({ processed: 1, inserted: 1, failed: 0 }) }, 64 }); 65 mock.module('../../src/cron/poll-purchases.js', { 66 namedExports: { pollPurchases: async () => ({ processed: 1, successful: 1, failed: 0 }) }, 67 }); 68 mock.module('../../src/cron/process-purchases.js', { 69 namedExports: { 70 processPendingPurchases: async () => ({ processed: 1, delivered: 1, failed: 0 }), 71 }, 72 }); 73 mock.module('../../src/cron/precompute-dashboard.js', { 74 namedExports: { precomputeDashboard: async () => ({ summary: 'ok', details: {}, metrics: {} }) }, 75 }); 76 mock.module('../../src/cron/process-guardian.js', { 77 namedExports: { 78 runProcessGuardian: async () => ({ 79 checks_run: 3, 80 ok: 3, 81 warnings: 0, 82 critical: 0, 83 duration_seconds: 0.1, 84 results: [], 85 }), 86 }, 87 }); 88 mock.module('../../src/cron/process-reaper.js', { 89 namedExports: { 90 runProcessReaper: async () => ({ 91 zombie_count: 0, 92 free_mem_mb: 512, 93 swap_pct: 0, 94 stale_processes_killed: 0, 95 duration_seconds: 0.1, 96 }), 97 }, 98 }); 99 mock.module('../../src/cron/cleanup-test-dbs.js', { 100 namedExports: { runCleanupTestDbs: () => ({ deleted: 1, freed_kb: 10 }) }, 101 }); 102 mock.module('../../src/cron/pipeline-status-monitor.js', { 103 namedExports: { 104 runPipelineStatusMonitor: async () => ({ 105 summary: 'Pipeline ok', 106 checks_run: 3, 107 duration_seconds: 0.1, 108 actions: [], 109 }), 110 }, 111 }); 112 mock.module('../../src/cron/classify-unknown-errors.js', { 113 namedExports: { 114 classifyUnknownErrors: async () => ({ 115 sites_retried: 0, 116 outreaches_retried: 0, 117 patterns_applied: 0, 118 }), 119 }, 120 }); 121 mock.module('../../src/agents/utils/task-manager.js', { 122 namedExports: { createAgentTask: async () => 1, findDuplicateTask: async () => null }, 123 }); 124 mock.module('../../src/utils/log-rotator.js', { 125 namedExports: { rotateLogs: () => ({ deleted: 2, kept: 5, freedSpace: 1024 * 1024 }) }, 126 }); 127 mock.module('../../src/utils/rate-limit-scheduler.js', { 128 namedExports: { 129 getSkipStages: () => new Set(), 130 getRateLimitStatus: () => [], 131 setRateLimit: () => {}, 132 }, 133 }); 134 mock.module('../../src/utils/load-env.js', { namedExports: {} }); 135 136 // ── Full DB schema ───────────────────────────────────────────────────────────── 137 // ops tables live in ATTACHed ops.db, tel tables in telemetry.db 138 139 { 140 // -- ops.db -- 141 const opsDb = new Database(OPS_DB); 142 opsDb.pragma('journal_mode = WAL'); 143 opsDb.exec(` 144 CREATE TABLE IF NOT EXISTS settings ( 145 key TEXT PRIMARY KEY, value TEXT, description TEXT, 146 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 147 ); 148 CREATE TABLE IF NOT EXISTS cron_jobs ( 149 id INTEGER PRIMARY KEY AUTOINCREMENT, 150 name TEXT NOT NULL UNIQUE, 151 task_key TEXT NOT NULL UNIQUE, 152 description TEXT, 153 enabled INTEGER NOT NULL DEFAULT 1, 154 handler_type TEXT NOT NULL DEFAULT 'function', 155 handler_value TEXT, 156 interval_value INTEGER NOT NULL DEFAULT 5, 157 interval_unit TEXT NOT NULL DEFAULT 'minutes', 158 timeout_seconds INTEGER, 159 priority INTEGER DEFAULT 5, 160 critical INTEGER DEFAULT 1, 161 last_run_at TEXT, 162 created_at TEXT DEFAULT (datetime('now')), 163 updated_at TEXT DEFAULT (datetime('now')) 164 ); 165 CREATE TABLE IF NOT EXISTS cron_job_logs ( 166 id INTEGER PRIMARY KEY AUTOINCREMENT, 167 job_name TEXT NOT NULL, 168 started_at TEXT NOT NULL DEFAULT (datetime('now')), 169 finished_at TEXT, 170 status TEXT NOT NULL DEFAULT 'running', 171 summary TEXT, 172 full_log TEXT, 173 error_message TEXT, 174 items_processed INTEGER DEFAULT 0, 175 items_failed INTEGER DEFAULT 0 176 ); 177 CREATE TABLE IF NOT EXISTS cron_locks ( 178 lock_key TEXT PRIMARY KEY, 179 description TEXT, 180 updated_at TEXT DEFAULT (datetime('now')) 181 ); 182 CREATE TABLE IF NOT EXISTS pipeline_control (key TEXT PRIMARY KEY, value TEXT); 183 `); 184 opsDb.close(); 185 186 // -- telemetry.db -- 187 const telDb = new Database(TEL_DB); 188 telDb.pragma('journal_mode = WAL'); 189 telDb.exec(` 190 CREATE TABLE IF NOT EXISTS agent_tasks ( 191 id INTEGER PRIMARY KEY AUTOINCREMENT, 192 task_type TEXT NOT NULL, 193 assigned_to TEXT, 194 priority INTEGER DEFAULT 5, 195 status TEXT DEFAULT 'pending', 196 context_json TEXT, 197 result_json TEXT, 198 error_message TEXT, 199 created_at TEXT DEFAULT (datetime('now')), 200 updated_at TEXT DEFAULT (datetime('now')) 201 ); 202 CREATE TABLE IF NOT EXISTS llm_usage ( 203 id INTEGER PRIMARY KEY AUTOINCREMENT, 204 site_id INTEGER, 205 stage TEXT NOT NULL, 206 provider TEXT NOT NULL, 207 model TEXT NOT NULL, 208 prompt_tokens INTEGER NOT NULL, 209 completion_tokens INTEGER NOT NULL, 210 total_tokens INTEGER NOT NULL, 211 estimated_cost DECIMAL(10, 6), 212 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 213 ); 214 `); 215 telDb.close(); 216 217 // -- main sites.db -- 218 const db = new Database(TEST_DB); 219 db.pragma('journal_mode = WAL'); 220 db.exec(` 221 CREATE TABLE IF NOT EXISTS messages ( 222 id INTEGER PRIMARY KEY AUTOINCREMENT, 223 site_id INTEGER, 224 direction TEXT NOT NULL DEFAULT 'outbound', 225 approval_status TEXT, 226 delivery_status TEXT, 227 contact_method TEXT, 228 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 229 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 230 ); 231 CREATE TABLE IF NOT EXISTS sites ( 232 id INTEGER PRIMARY KEY AUTOINCREMENT, 233 domain TEXT NOT NULL DEFAULT 'test.com', 234 status TEXT DEFAULT 'found', 235 score REAL, 236 error_message TEXT, 237 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 238 ); 239 CREATE TABLE IF NOT EXISTS keywords ( 240 id INTEGER PRIMARY KEY AUTOINCREMENT, 241 keyword TEXT NOT NULL, 242 status TEXT DEFAULT 'pending' 243 ); 244 CREATE TABLE IF NOT EXISTS site_status ( 245 id INTEGER PRIMARY KEY AUTOINCREMENT, 246 site_id INTEGER NOT NULL, 247 status TEXT, 248 created_at TEXT DEFAULT (datetime('now')) 249 ); 250 CREATE TABLE IF NOT EXISTS human_review_queue ( 251 id INTEGER PRIMARY KEY AUTOINCREMENT, 252 file TEXT, reason TEXT, type TEXT, 253 priority TEXT DEFAULT 'medium', 254 status TEXT DEFAULT 'pending', 255 created_at TEXT DEFAULT (datetime('now')) 256 ); 257 `); 258 259 // Seed test data 260 db.prepare( 261 "INSERT OR IGNORE INTO keywords (keyword, status) VALUES ('test keyword', 'pending')" 262 ).run(); 263 db.prepare( 264 "INSERT OR IGNORE INTO keywords (keyword, status) VALUES ('active keyword', 'active')" 265 ).run(); 266 db.prepare("INSERT OR IGNORE INTO sites (domain, status) VALUES ('example.com', 'found')").run(); 267 db.close(); 268 } 269 270 // Import AFTER mocks + schema 271 const { default: cronModule } = await import('../../src/cron.js'); 272 273 // Track backup dir manipulation for cleanup 274 let backupDirWasSymlink = false; 275 let backupSymlinkTarget = null; 276 const PROJECT_BACKUP_DIR = join(dirname(__filename), '..', '..', 'db', 'backup'); 277 278 after(() => { 279 for (const f of [TEST_DB, OPS_DB, TEL_DB]) { 280 if (existsSync(f)) { 281 try { unlinkSync(f); } catch { /* ignore */ } 282 } 283 } 284 285 // Restore backup dir symlink if we replaced it during tests 286 if (backupDirWasSymlink && backupSymlinkTarget) { 287 try { 288 // Remove whatever we created in place of the symlink 289 if (existsSync(PROJECT_BACKUP_DIR)) { 290 const entries = readdirSync(PROJECT_BACKUP_DIR); 291 for (const f of entries) { 292 try { 293 unlinkSync(join(PROJECT_BACKUP_DIR, f)); 294 } catch { 295 /* ignore */ 296 } 297 } 298 try { 299 rmSync(PROJECT_BACKUP_DIR, { recursive: true, force: true }); 300 } catch { 301 /* ignore */ 302 } 303 } 304 } catch { 305 /* ignore */ 306 } 307 308 try { 309 symlinkSync(backupSymlinkTarget, PROJECT_BACKUP_DIR); 310 } catch { 311 /* ignore — best effort restore */ 312 } 313 } 314 }); 315 316 // ── Helpers ─────────────────────────────────────────────────────────────────── 317 318 /** 319 * Open the main DB with ops ATTACHed — matches the runtime layout. 320 * Unqualified ops table names (cron_jobs, settings, etc.) resolve to the ops schema. 321 */ 322 function openDb() { 323 const db = new Database(TEST_DB); 324 db.exec(`ATTACH DATABASE '${OPS_DB}' AS ops`); 325 return db; 326 } 327 328 function clearJobs() { 329 const db = openDb(); 330 db.prepare('DELETE FROM cron_jobs').run(); 331 db.prepare('DELETE FROM cron_job_logs').run(); 332 db.prepare('DELETE FROM cron_locks').run(); 333 db.close(); 334 } 335 336 function seedJob(overrides = {}) { 337 const defaults = { 338 name: 'Test Job', 339 task_key: 'syncEmailEvents', 340 enabled: 1, 341 handler_type: 'function', 342 handler_value: null, 343 interval_value: 1, 344 interval_unit: 'minutes', 345 timeout_seconds: 30, 346 critical: 0, 347 last_run_at: null, 348 }; 349 const job = { ...defaults, ...overrides }; 350 const db = openDb(); 351 db.prepare( 352 `INSERT OR REPLACE INTO cron_jobs 353 (name, task_key, enabled, handler_type, handler_value, interval_value, interval_unit, timeout_seconds, critical, last_run_at) 354 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 355 ).run( 356 job.name, 357 job.task_key, 358 job.enabled, 359 job.handler_type, 360 job.handler_value, 361 job.interval_value, 362 job.interval_unit, 363 job.timeout_seconds, 364 job.critical, 365 job.last_run_at 366 ); 367 db.close(); 368 } 369 370 // ── HANDLERS: inbound/outbound pipeline ─────────────────────────────────────── 371 372 describe('HANDLERS — inbound/outbound pipeline', () => { 373 test('syncEmailEvents returns structured result', async () => { 374 const { HANDLERS } = cronModule; 375 const result = await HANDLERS.syncEmailEvents(); 376 assert.ok(typeof result.summary === 'string'); 377 assert.ok(typeof result.metrics === 'object'); 378 assert.ok('synced' in result.metrics); 379 }); 380 381 test('syncUnsubscribes returns structured result', async () => { 382 const { HANDLERS } = cronModule; 383 const result = await HANDLERS.syncUnsubscribes(); 384 assert.ok(typeof result.summary === 'string'); 385 assert.ok(result.metrics.synced >= 0); 386 }); 387 388 test('pollInboundSMS returns structured result', async () => { 389 const { HANDLERS } = cronModule; 390 const result = await HANDLERS.pollInboundSMS(); 391 assert.ok(typeof result.summary === 'string'); 392 assert.ok('new_messages' in result.metrics); 393 }); 394 395 test('pollInboundEmails returns structured result', async () => { 396 const { HANDLERS } = cronModule; 397 const result = await HANDLERS.pollInboundEmails(); 398 assert.ok(typeof result.summary === 'string'); 399 assert.ok('processed' in result.metrics); 400 }); 401 402 test('sendPendingReplies returns structured result', async () => { 403 const { HANDLERS } = cronModule; 404 const result = await HANDLERS.sendPendingReplies(); 405 assert.ok(typeof result.summary === 'string'); 406 assert.ok('sms_sent' in result.metrics); 407 }); 408 409 test('pollFreeScans returns structured result', async () => { 410 const { HANDLERS } = cronModule; 411 const result = await HANDLERS.pollFreeScans(); 412 assert.ok(typeof result.summary === 'string'); 413 assert.ok('processed' in result.metrics); 414 }); 415 416 test('pollPurchases returns structured result', async () => { 417 const { HANDLERS } = cronModule; 418 const result = await HANDLERS.pollPurchases(); 419 assert.ok(typeof result.summary === 'string'); 420 assert.ok('successful' in result.metrics); 421 }); 422 423 test('processPurchases returns structured result', async () => { 424 const { HANDLERS } = cronModule; 425 const result = await HANDLERS.processPurchases(); 426 assert.ok(typeof result.summary === 'string'); 427 assert.ok('delivered' in result.metrics); 428 }); 429 }); 430 431 // ── HANDLERS: monitoring ────────────────────────────────────────────────────── 432 433 describe('HANDLERS — monitoring', () => { 434 test('checkKeywords returns structured result', async () => { 435 const { HANDLERS } = cronModule; 436 const result = await HANDLERS.checkKeywords(); 437 assert.ok(typeof result.summary === 'string'); 438 assert.ok('pending' in result.metrics); 439 assert.ok('total' in result.metrics); 440 }); 441 442 test('precomputeDashboard returns result', async () => { 443 const { HANDLERS } = cronModule; 444 const result = await HANDLERS.precomputeDashboard(); 445 assert.ok(result !== null && result !== undefined); 446 }); 447 448 test('processGuardian returns structured result', async () => { 449 const { HANDLERS } = cronModule; 450 const result = await HANDLERS.processGuardian(); 451 assert.ok(typeof result.summary === 'string'); 452 assert.ok('checks_run' in result.metrics); 453 assert.ok('ok' in result.metrics); 454 }); 455 456 test('processReaper returns structured result', async () => { 457 const { HANDLERS } = cronModule; 458 const result = await HANDLERS.processReaper(); 459 assert.ok(typeof result.summary === 'string'); 460 assert.ok('zombie_count' in result.metrics); 461 }); 462 463 test('cleanupTestDbs returns structured result', async () => { 464 const { HANDLERS } = cronModule; 465 const result = await HANDLERS.cleanupTestDbs(); 466 assert.ok(typeof result.summary === 'string'); 467 assert.ok('deleted' in result.metrics); 468 }); 469 470 test('pipelineStatusMonitor returns structured result', async () => { 471 const { HANDLERS } = cronModule; 472 const result = await HANDLERS.pipelineStatusMonitor(); 473 assert.ok(typeof result.summary === 'string'); 474 assert.ok('checks_run' in result.metrics); 475 }); 476 477 test('classifyUnknownErrors returns structured result', async () => { 478 const { HANDLERS } = cronModule; 479 const result = await HANDLERS.classifyUnknownErrors(); 480 assert.ok(typeof result.summary === 'string'); 481 assert.ok('sites_retried' in result.metrics); 482 }); 483 484 test('monitorPipeline creates agent tasks', async () => { 485 const { HANDLERS } = cronModule; 486 const result = await HANDLERS.monitorPipeline(); 487 assert.ok(typeof result.summary === 'string'); 488 assert.ok('created' in result.metrics); 489 }); 490 491 test('monitorSystem creates agent tasks', async () => { 492 const { HANDLERS } = cronModule; 493 const result = await HANDLERS.monitorSystem(); 494 assert.ok(typeof result.summary === 'string'); 495 assert.ok('created' in result.metrics); 496 }); 497 }); 498 499 // ── HANDLERS: maintenance ───────────────────────────────────────────────────── 500 501 describe('HANDLERS — maintenance', () => { 502 test('databaseMaintenance returns structured result', async () => { 503 const { HANDLERS } = cronModule; 504 const result = await HANDLERS.databaseMaintenance(); 505 assert.ok(typeof result.summary === 'string'); 506 assert.ok('healthy' in result.metrics); 507 assert.ok('sites' in result.metrics); 508 }); 509 510 test('vacuumDatabase vacuums main DB', async () => { 511 const { HANDLERS } = cronModule; 512 const result = await HANDLERS.vacuumDatabase(); 513 assert.ok(typeof result.summary === 'string'); 514 assert.ok('databases_vacuumed' in result.metrics); 515 assert.ok(result.metrics.databases_vacuumed >= 1); 516 }); 517 518 test('backupDatabase: full backup path when backup dir exists', async () => { 519 // db/backup is normally a broken symlink to an external drive. 520 // For this test, we temporarily replace it with a real directory so the 521 // backup handler's main body (lines 504-768) executes. 522 let dirCreated = false; 523 let symlinkSaved = null; 524 525 try { 526 // Check if it's a dangling symlink or missing 527 let isDanglingSymlink = false; 528 try { 529 lstatSync(PROJECT_BACKUP_DIR); // lstat doesn't follow symlinks 530 isDanglingSymlink = !existsSync(PROJECT_BACKUP_DIR); // existsSync follows symlink 531 } catch { 532 isDanglingSymlink = false; // doesn't exist at all 533 } 534 535 if (isDanglingSymlink) { 536 // It's a dangling symlink — read its target, then replace with real dir 537 const { readlinkSync } = await import('fs'); 538 symlinkSaved = readlinkSync(PROJECT_BACKUP_DIR); 539 unlinkSync(PROJECT_BACKUP_DIR); // remove dangling symlink 540 mkdirSync(PROJECT_BACKUP_DIR, { recursive: true }); 541 backupDirWasSymlink = true; 542 backupSymlinkTarget = symlinkSaved; 543 dirCreated = true; 544 } else if (!existsSync(PROJECT_BACKUP_DIR)) { 545 // Doesn't exist — create it 546 mkdirSync(PROJECT_BACKUP_DIR, { recursive: true }); 547 dirCreated = true; 548 } 549 // If it already exists and is accessible, do nothing 550 551 const { HANDLERS } = cronModule; 552 const result = await HANDLERS.backupDatabase(); 553 554 assert.ok(typeof result === 'object', 'should return object'); 555 assert.ok(typeof result.summary === 'string', 'should have summary'); 556 assert.ok(typeof result.metrics === 'object', 'should have metrics'); 557 558 // Backup should have succeeded or aborted (both cover the main body) 559 const isSuccess = result.metrics.success === 1; 560 const isAborted = result.metrics.aborted === 1; 561 const isSkipped = result.metrics.skipped === 1; 562 assert.ok( 563 isSuccess || isAborted || isSkipped, 564 `expected success, aborted or skipped: ${JSON.stringify(result.metrics)}` 565 ); 566 567 if (isSuccess) { 568 // Verify the full backup details match current source code 569 assert.ok(typeof result.details === 'object'); 570 assert.ok('gz_path' in result.details, `expected gz_path in details: ${JSON.stringify(result.details)}`); 571 assert.ok('tier' in result.details, 'expected tier in details'); 572 assert.ok('site_count' in result.details, 'expected site_count in details'); 573 } 574 } finally { 575 // Cleanup: restore symlink if we replaced it 576 if (dirCreated && backupDirWasSymlink && symlinkSaved) { 577 try { 578 const entries = existsSync(PROJECT_BACKUP_DIR) ? readdirSync(PROJECT_BACKUP_DIR) : []; 579 for (const f of entries) { 580 try { 581 unlinkSync(join(PROJECT_BACKUP_DIR, f)); 582 } catch { 583 /* ignore */ 584 } 585 } 586 try { 587 rmSync(PROJECT_BACKUP_DIR, { recursive: true, force: true }); 588 } catch { 589 /* ignore */ 590 } 591 symlinkSync(symlinkSaved, PROJECT_BACKUP_DIR); 592 backupDirWasSymlink = false; // already restored 593 backupSymlinkTarget = null; 594 } catch { 595 /* ignore restore errors — after() will try again */ 596 } 597 } else if (dirCreated && !backupDirWasSymlink) { 598 // We created a new dir (wasn't a symlink) — remove it 599 try { 600 rmSync(PROJECT_BACKUP_DIR, { recursive: true, force: true }); 601 } catch { 602 /* ignore */ 603 } 604 } 605 } 606 }); 607 608 test('analyzePerformance returns structured result', async () => { 609 const { HANDLERS } = cronModule; 610 const result = await HANDLERS.analyzePerformance(); 611 assert.ok(typeof result.summary === 'string'); 612 assert.ok('tables_analyzed' in result.metrics); 613 assert.ok('recommendations_found' in result.metrics); 614 assert.ok(Array.isArray(result.details.table_stats)); 615 }); 616 617 test('rotateLogs returns structured result', async () => { 618 const { HANDLERS } = cronModule; 619 const result = await HANDLERS.rotateLogs(); 620 assert.ok(typeof result.summary === 'string'); 621 assert.ok('deleted' in result.metrics); 622 assert.equal(result.metrics.deleted, 2, 'mock returns 2 deleted'); 623 assert.equal(result.metrics.kept, 5, 'mock returns 5 kept'); 624 }); 625 626 test('checkRateLimits returns api status', async () => { 627 const { HANDLERS } = cronModule; 628 const result = await HANDLERS.checkRateLimits(); 629 assert.ok(typeof result.summary === 'string'); 630 assert.ok('configured' in result.metrics); 631 assert.ok('missing' in result.metrics); 632 assert.ok(typeof result.details.api_status === 'object'); 633 }); 634 635 test('purgeSiteStatusHistory returns deletion metrics', async () => { 636 // Pre-populate site_status with rows to purge 637 const db = openDb(); 638 const siteId = db.prepare('SELECT id FROM sites LIMIT 1').get()?.id || 1; 639 for (let i = 0; i < 8; i++) { 640 db.prepare('INSERT INTO site_status (site_id, status) VALUES (?, ?)').run(siteId, 'found'); 641 } 642 db.close(); 643 644 const { HANDLERS } = cronModule; 645 const result = await HANDLERS.purgeSiteStatusHistory(); 646 assert.ok(typeof result.summary === 'string'); 647 assert.ok('rows_deleted' in result.metrics); 648 assert.ok('rows_remaining' in result.metrics); 649 }); 650 651 test('diskCleanup runs (accepts EACCES in test env)', async () => { 652 const { HANDLERS } = cronModule; 653 try { 654 const result = await HANDLERS.diskCleanup(); 655 assert.ok(typeof result.summary === 'string'); 656 assert.ok('actions' in result.metrics); 657 } catch (err) { 658 if (err.code === 'EACCES') { 659 // Permission denied on coverage dirs — acceptable in test env 660 } else { 661 throw err; 662 } 663 } 664 }); 665 666 test('technicalDebtReview handles missing TODO.md', async () => { 667 const { HANDLERS } = cronModule; 668 await assert.doesNotReject(() => HANDLERS.technicalDebtReview()); 669 }); 670 671 test('unifiedAutofix returns result (success or error)', async () => { 672 const { HANDLERS } = cronModule; 673 await assert.doesNotReject(() => HANDLERS.unifiedAutofix()); 674 }); 675 }); 676 677 // ── runCron: circuit breaker disabled ───────────────────────────────────────── 678 679 describe('runCron — circuit breaker disabled exits early', () => { 680 test('skips all jobs when circuit breaker is disabled', async () => { 681 const db = openDb(); 682 db.prepare( 683 "INSERT OR REPLACE INTO settings (key, value) VALUES ('cron_circuit_breaker_enabled', 'false')" 684 ).run(); 685 db.close(); 686 687 const { runCron } = cronModule; 688 await assert.doesNotReject(() => runCron()); 689 690 // Re-enable for subsequent tests 691 const db2 = openDb(); 692 db2.prepare("DELETE FROM settings WHERE key = 'cron_circuit_breaker_enabled'").run(); 693 db2.close(); 694 }); 695 }); 696 697 // ── runCron: function-type handler success ───────────────────────────────────── 698 699 describe('runCron — function handler completes successfully', () => { 700 test('runs syncEmailEvents handler and logs success', async () => { 701 clearJobs(); 702 seedJob({ 703 name: 'Sync Email', 704 task_key: 'syncEmailEvents', 705 handler_type: 'function', 706 interval_value: 1, 707 interval_unit: 'minutes', 708 critical: 0, 709 }); 710 711 const { runCron } = cronModule; 712 await assert.doesNotReject(() => runCron()); 713 714 const db = openDb(); 715 const logs = db.prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Sync Email'").all(); 716 db.close(); 717 assert.ok(logs.length >= 1, 'should have created a log'); 718 assert.equal(logs[0].status, 'success', 'handler should succeed'); 719 }); 720 }); 721 722 // ── runCron: command-type handler ───────────────────────────────────────────── 723 724 describe('runCron — command handler', () => { 725 test('runs a command and logs success', async () => { 726 clearJobs(); 727 seedJob({ 728 name: 'Echo Boost', 729 task_key: 'echoBoost', 730 handler_type: 'command', 731 handler_value: 'echo coverage-boost', 732 timeout_seconds: 10, 733 critical: 0, 734 }); 735 736 const { runCron } = cronModule; 737 await assert.doesNotReject(() => runCron()); 738 739 const db = openDb(); 740 const logs = db.prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Echo Boost'").all(); 741 db.close(); 742 assert.ok(logs.length >= 1, 'should have a log'); 743 assert.equal(logs[0].status, 'success', 'echo should succeed'); 744 // Default items_processed for command results 745 assert.equal( 746 logs[0].items_processed, 747 1, 748 'command result has no .metrics.processed so defaults to 1' 749 ); 750 }); 751 752 test('logs failure for failing command', async () => { 753 clearJobs(); 754 seedJob({ 755 name: 'Fail Cmd', 756 task_key: 'failCmd', 757 handler_type: 'command', 758 handler_value: 'false', 759 timeout_seconds: 5, 760 critical: 0, 761 }); 762 763 const origExit = process.exit; 764 process.exit = () => {}; 765 766 const { runCron } = cronModule; 767 await assert.doesNotReject(() => runCron()); 768 769 process.exit = origExit; 770 771 const db = openDb(); 772 const logs = db.prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Fail Cmd'").all(); 773 db.close(); 774 assert.ok(logs.length >= 1, 'should have a failed log'); 775 assert.equal(logs[0].status, 'failed', 'false command exits non-zero → failed'); 776 }); 777 }); 778 779 // ── runCron: unknown handler type ───────────────────────────────────────────── 780 781 describe('runCron — unknown handler type logs failure', () => { 782 test('fails gracefully with unknown handler_type', async () => { 783 clearJobs(); 784 seedJob({ 785 name: 'Bad Type', 786 task_key: 'badType', 787 handler_type: 'unknown_type', 788 handler_value: 'something', 789 critical: 0, 790 }); 791 792 const origExit = process.exit; 793 process.exit = () => {}; 794 795 const { runCron } = cronModule; 796 await assert.doesNotReject(() => runCron()); 797 798 process.exit = origExit; 799 800 const db = openDb(); 801 const logs = db.prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Bad Type'").all(); 802 db.close(); 803 assert.ok(logs.length >= 1); 804 assert.ok(logs[0].error_message?.includes('Unknown handler type')); 805 }); 806 }); 807 808 // ── runCron: non-critical failures ──────────────────────────────────────────── 809 810 describe('runCron — non-critical failure does not exit', () => { 811 test('non-critical job failure shows summary without process.exit', async () => { 812 clearJobs(); 813 seedJob({ 814 name: 'Non-Critical Bad', 815 task_key: 'nonExistentHandler_boost', 816 handler_type: 'function', 817 critical: 0, 818 }); 819 820 let exitCalled = false; 821 const origExit = process.exit; 822 process.exit = () => { 823 exitCalled = true; 824 }; 825 826 const consoleLogs = []; 827 const origLog = console.log; 828 console.log = (...args) => consoleLogs.push(args.join(' ')); 829 830 const { runCron } = cronModule; 831 await assert.doesNotReject(() => runCron()); 832 833 console.log = origLog; 834 process.exit = origExit; 835 836 assert.equal(exitCalled, false, 'process.exit NOT called for non-critical'); 837 const failMsg = consoleLogs.find(m => m.includes('non-critical') && m.includes('failed')); 838 assert.ok( 839 failMsg !== undefined, 840 `should show non-critical failure msg; got: ${consoleLogs.join('\n')}` 841 ); 842 }); 843 }); 844 845 // ── runCron: skipped jobs ───────────────────────────────────────────────────── 846 847 describe('runCron — jobs skipped when recently run', () => { 848 test('jobs with last_run_at in the future are skipped', async () => { 849 clearJobs(); 850 // Set last_run_at to now → should be skipped since interval hasn't elapsed 851 const db = openDb(); 852 db.prepare( 853 `INSERT INTO cron_jobs (name, task_key, enabled, handler_type, interval_value, interval_unit, timeout_seconds, critical, last_run_at) 854 VALUES ('Recent Job', 'syncEmailEvents', 1, 'function', 1, 'hours', 30, 0, datetime('now'))` 855 ).run(); 856 db.close(); 857 858 const { runCron } = cronModule; 859 await assert.doesNotReject(() => runCron()); 860 861 const db2 = openDb(); 862 const logs = db2.prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Recent Job'").all(); 863 db2.close(); 864 // Should be skipped — no log entry created 865 assert.equal(logs.length, 0, 'recently run job should be skipped (no log)'); 866 }); 867 }); 868 869 // ── runCron: no jobs case ───────────────────────────────────────────────────── 870 871 describe('runCron — no enabled jobs', () => { 872 test('returns without error when no jobs in database', async () => { 873 clearJobs(); 874 // Insert a disabled job 875 const db = openDb(); 876 db.prepare( 877 `INSERT INTO cron_jobs (name, task_key, enabled, handler_type, interval_value, interval_unit, critical) 878 VALUES ('Disabled Job', 'syncEmailEvents', 0, 'function', 1, 'minutes', 0)` 879 ).run(); 880 db.close(); 881 882 const { runCron } = cronModule; 883 await assert.doesNotReject(() => runCron()); 884 }); 885 }); 886 887 // ── runCron: global lock prevents double-run ────────────────────────────────── 888 889 describe('runCron — global singleton lock', () => { 890 test('exits early when global lock is held', async () => { 891 clearJobs(); 892 // Pre-set the global lock 893 const db = openDb(); 894 db.prepare( 895 `INSERT INTO cron_locks (lock_key, description, updated_at) 896 VALUES ('cron_runner_global_lock', 'test lock', datetime('now'))` 897 ).run(); 898 db.close(); 899 900 const { runCron } = cronModule; 901 await assert.doesNotReject(() => runCron()); 902 903 // Clean up the lock 904 const db2 = openDb(); 905 db2.prepare("DELETE FROM cron_locks WHERE lock_key = 'cron_runner_global_lock'").run(); 906 db2.close(); 907 }); 908 }); 909 910 // ── runCron: multi-job pipeline stage lock ──────────────────────────────────── 911 912 describe('runCron — pipeline stage command with lock', () => { 913 test('scoring pipeline command gets a lock key', async () => { 914 clearJobs(); 915 seedJob({ 916 name: 'Scoring Pipeline', 917 task_key: 'scoringPipelineBoost', 918 handler_type: 'command', 919 handler_value: 'echo scoring-complete', 920 timeout_seconds: 10, 921 critical: 0, 922 }); 923 924 const { runCron } = cronModule; 925 await assert.doesNotReject(() => runCron()); 926 927 const db = openDb(); 928 const logs = db 929 .prepare("SELECT * FROM cron_job_logs WHERE job_name = 'Scoring Pipeline'") 930 .all(); 931 db.close(); 932 assert.ok(logs.length >= 1, 'pipeline job should run'); 933 assert.equal(logs[0].status, 'success'); 934 }); 935 }); 936 937 // ── runCron: critical failure calls process.exit(1) ─────────────────────────── 938 939 describe('runCron — critical job failure calls process.exit(1)', () => { 940 test('critical failure triggers process.exit(1)', async () => { 941 clearJobs(); 942 seedJob({ 943 name: 'Critical Bad', 944 task_key: 'nonExistentHandlerCritical', 945 handler_type: 'function', 946 critical: 1, // critical = 1 → process.exit(1) on failure 947 }); 948 949 let exitCode = null; 950 const origExit = process.exit; 951 process.exit = code => { 952 exitCode = code; 953 }; 954 955 const consoleLogs = []; 956 const origLog = console.log; 957 console.log = (...args) => consoleLogs.push(args.join(' ')); 958 959 const { runCron } = cronModule; 960 await assert.doesNotReject(() => runCron()); 961 962 console.log = origLog; 963 process.exit = origExit; 964 965 // process.exit should have been called with code 1 966 assert.equal(exitCode, 1, 'critical failure should call process.exit(1)'); 967 const critMsg = consoleLogs.find(m => m.includes('critical') && m.includes('failed')); 968 assert.ok(critMsg !== undefined, `should show critical failure message`); 969 }); 970 });