/ __quarantined_tests__ / cron / cron-coverage-boost.test.js
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  });