/ tests / cron / cron-runcron.test.js
cron-runcron.test.js
  1  /**
  2   * Tests for src/cron.js — runCron execution path (database-driven v2)
  3   *
  4   * Covers:
  5   * - runCron: circuit breaker check, global lock, loadJobs, shouldRun, handler dispatch
  6   * - checkAndClearStaleLock: fresh lock (skip), stale lock (clear), no lock (proceed)
  7   * - intervalToMinutes: minutes/hours/days/weeks conversion
  8   * - generateSummary: null result, .summary, .manual, legacy fields
  9   * - logTaskStart, logTaskComplete, logTaskFailed
 10   * - executeCommand: command-type job dispatch
 11   *
 12   * Uses mock.module() to stub all external modules so handler bodies don't make real API calls.
 13   *
 14   * NOTE: requires --experimental-test-module-mocks
 15   */
 16  
 17  import { test, describe, mock, after } from 'node:test';
 18  import assert from 'node:assert/strict';
 19  import Database from 'better-sqlite3';
 20  import { createPgMock } from '../helpers/pg-mock.js';
 21  
 22  process.env.NODE_ENV = 'test';
 23  process.env.LOGS_DIR = '/tmp/test-logs';
 24  
 25  // ── Shared in-memory SQLite db for all tests ──────────────────────────────────
 26  const db = new Database(':memory:');
 27  db.exec(`
 28    CREATE TABLE IF NOT EXISTS sites (
 29      id INTEGER PRIMARY KEY AUTOINCREMENT,
 30      domain TEXT NOT NULL DEFAULT 'test.com',
 31      status TEXT DEFAULT 'found',
 32      score REAL,
 33      error_message TEXT,
 34      updated_at TEXT DEFAULT (datetime('now'))
 35    );
 36    CREATE TABLE IF NOT EXISTS messages (
 37      id INTEGER PRIMARY KEY AUTOINCREMENT,
 38      site_id INTEGER,
 39      direction TEXT NOT NULL DEFAULT 'outbound',
 40      approval_status TEXT,
 41      delivery_status TEXT,
 42      read_at TEXT,
 43      created_at TEXT DEFAULT (datetime('now')),
 44      updated_at TEXT DEFAULT (datetime('now')),
 45      message_type TEXT DEFAULT 'outreach',
 46      raw_payload TEXT
 47    );
 48    CREATE TABLE IF NOT EXISTS keywords (
 49      id INTEGER PRIMARY KEY AUTOINCREMENT,
 50      keyword TEXT NOT NULL,
 51      status TEXT DEFAULT 'pending'
 52    );
 53    CREATE TABLE IF NOT EXISTS settings (
 54      key TEXT PRIMARY KEY, value TEXT, description TEXT,
 55      updated_at TEXT DEFAULT (datetime('now'))
 56    );
 57    CREATE TABLE IF NOT EXISTS cron_jobs (
 58      id INTEGER PRIMARY KEY AUTOINCREMENT,
 59      name TEXT NOT NULL UNIQUE,
 60      task_key TEXT NOT NULL UNIQUE,
 61      description TEXT,
 62      enabled INTEGER NOT NULL DEFAULT 1,
 63      handler_type TEXT NOT NULL DEFAULT 'function',
 64      handler_value TEXT,
 65      interval_value INTEGER NOT NULL DEFAULT 5,
 66      interval_unit TEXT NOT NULL DEFAULT 'minutes',
 67      timeout_seconds INTEGER,
 68      priority INTEGER DEFAULT 5,
 69      critical INTEGER DEFAULT 1,
 70      last_run_at TEXT,
 71      created_at TEXT DEFAULT (datetime('now')),
 72      updated_at TEXT DEFAULT (datetime('now'))
 73    );
 74    CREATE TABLE IF NOT EXISTS cron_job_logs (
 75      id INTEGER PRIMARY KEY AUTOINCREMENT,
 76      job_name TEXT NOT NULL,
 77      started_at TEXT NOT NULL DEFAULT (datetime('now')),
 78      finished_at TEXT,
 79      status TEXT NOT NULL DEFAULT 'running',
 80      summary TEXT,
 81      full_log TEXT,
 82      error_message TEXT,
 83      items_processed INTEGER DEFAULT 0,
 84      items_failed INTEGER DEFAULT 0
 85    );
 86    CREATE TABLE IF NOT EXISTS cron_locks (
 87      lock_key TEXT PRIMARY KEY,
 88      description TEXT,
 89      updated_at TEXT DEFAULT (datetime('now'))
 90    );
 91    CREATE TABLE IF NOT EXISTS pipeline_control (key TEXT PRIMARY KEY, value TEXT);
 92    CREATE TABLE IF NOT EXISTS agent_tasks (
 93      id INTEGER PRIMARY KEY AUTOINCREMENT,
 94      task_type TEXT NOT NULL, assigned_to TEXT,
 95      priority INTEGER DEFAULT 5, status TEXT DEFAULT 'pending',
 96      context_json TEXT, result_json TEXT, error_message TEXT,
 97      created_at TEXT DEFAULT (datetime('now')),
 98      updated_at TEXT DEFAULT (datetime('now'))
 99    );
100    CREATE TABLE IF NOT EXISTS llm_usage (
101      id INTEGER PRIMARY KEY AUTOINCREMENT,
102      site_id INTEGER, stage TEXT NOT NULL,
103      provider TEXT NOT NULL DEFAULT 'openrouter',
104      model TEXT NOT NULL DEFAULT 'test-model',
105      prompt_tokens INTEGER NOT NULL DEFAULT 100,
106      completion_tokens INTEGER NOT NULL DEFAULT 50,
107      total_tokens INTEGER NOT NULL DEFAULT 150,
108      estimated_cost REAL DEFAULT 0.001,
109      created_at TEXT DEFAULT (datetime('now'))
110    );
111    CREATE TABLE IF NOT EXISTS site_status (
112      id INTEGER PRIMARY KEY AUTOINCREMENT,
113      site_id INTEGER NOT NULL, status TEXT,
114      created_at TEXT DEFAULT (datetime('now'))
115    );
116    CREATE TABLE IF NOT EXISTS human_review_queue (
117      id INTEGER PRIMARY KEY AUTOINCREMENT,
118      file TEXT, reason TEXT, type TEXT,
119      priority TEXT DEFAULT 'medium', status TEXT DEFAULT 'pending',
120      created_at TEXT DEFAULT (datetime('now'))
121    );
122    CREATE TABLE IF NOT EXISTS dashboard_cache (
123      cache_key TEXT PRIMARY KEY, cache_value TEXT NOT NULL,
124      expires_at TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now'))
125    );
126  `);
127  
128  // ── Mock db.js to route through shared in-memory SQLite ──────────────────────
129  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
130  
131  // ── Mock all external dependencies BEFORE importing cron.js ──────────────────
132  
133  mock.module('../../src/utils/sync-email-events.js', {
134    namedExports: { syncEmailEvents: async () => ({ synced: 2, errors: 0 }) },
135  });
136  mock.module('../../src/utils/sync-unsubscribes.js', {
137    namedExports: { syncUnsubscribes: async () => ({ synced: 1, errors: 0 }) },
138  });
139  mock.module('../../src/inbound/sms.js', {
140    namedExports: {
141      pollInboundSMS: async () => ({ processed: 0, new_messages: 0 }),
142      setupWebhookServer: async () => {},
143    },
144  });
145  mock.module('../../src/inbound/email.js', {
146    namedExports: { pollInboundEmails: async () => ({ processed: 0, stored: 0, unmatched: 0 }) },
147  });
148  mock.module('../../src/inbound/processor.js', {
149    namedExports: { processAllReplies: async () => ({ sms: { sent: 0 }, email: { sent: 0 } }) },
150  });
151  mock.module('../../src/cron/poll-free-scans.js', {
152    namedExports: { pollFreeScans: async () => ({ processed: 0, inserted: 0, failed: 0 }) },
153  });
154  mock.module('../../src/cron/poll-purchases.js', {
155    namedExports: { pollPurchases: async () => ({ processed: 0, successful: 0 }) },
156  });
157  mock.module('../../src/cron/process-purchases.js', {
158    namedExports: {
159      processPendingPurchases: async () => ({ processed: 0, delivered: 0, failed: 0 }),
160    },
161  });
162  mock.module('../../src/cron/precompute-dashboard.js', {
163    namedExports: {
164      precomputeDashboard: async () => ({
165        summary: 'Dashboard precomputed',
166        details: {},
167        metrics: {},
168      }),
169    },
170  });
171  mock.module('../../src/cron/process-guardian.js', {
172    namedExports: {
173      runProcessGuardian: async () => ({
174        checks_run: 6,
175        ok: 6,
176        warnings: 0,
177        critical: 0,
178        duration_seconds: 0.1,
179        results: [],
180      }),
181    },
182  });
183  mock.module('../../src/cron/process-reaper.js', {
184    namedExports: {
185      runProcessReaper: async () => ({
186        zombie_count: 0,
187        free_mem_mb: 512,
188        swap_pct: 0,
189        stale_processes_killed: 0,
190        duration_seconds: 0.1,
191      }),
192    },
193  });
194  mock.module('../../src/cron/cleanup-test-dbs.js', {
195    namedExports: { runCleanupTestDbs: () => ({ deleted: 0, freed_kb: 0 }) },
196  });
197  mock.module('../../src/cron/pipeline-status-monitor.js', {
198    namedExports: {
199      runPipelineStatusMonitor: async () => ({
200        summary: 'Pipeline ok',
201        checks_run: 3,
202        duration_seconds: 0.1,
203        actions: [],
204      }),
205    },
206  });
207  mock.module('../../src/cron/classify-unknown-errors.js', {
208    namedExports: {
209      classifyUnknownErrors: async () => ({
210        sites_retried: 0,
211        outreaches_retried: 0,
212        patterns_applied: 0,
213      }),
214    },
215  });
216  mock.module('../../src/agents/utils/task-manager.js', {
217    namedExports: {
218      createAgentTask: async () => 1,
219      findDuplicateTask: async () => null,
220    },
221  });
222  mock.module('../../src/utils/log-rotator.js', {
223    namedExports: { rotateLogs: () => ({}) },
224  });
225  mock.module('../../src/utils/rate-limit-scheduler.js', {
226    namedExports: {
227      getSkipStages: () => new Set(),
228      getRateLimitStatus: () => [],
229      setRateLimit: () => {},
230    },
231  });
232  mock.module('../../src/utils/load-env.js', {
233    namedExports: {},
234  });
235  
236  // Import AFTER mocks (schema already created above)
237  const { default: cronModule } = await import('../../src/cron.js');
238  
239  after(() => {
240    try { db.close(); } catch { /* ignore */ }
241  });
242  
243  // ── Helpers ───────────────────────────────────────────────────────────────────
244  
245  function seedJob(overrides = {}) {
246    const defaults = {
247      name: 'Test Job',
248      task_key: 'syncEmailEvents',
249      enabled: 1,
250      handler_type: 'function',
251      handler_value: null,
252      interval_value: 1,
253      interval_unit: 'minutes',
254      timeout_seconds: 30,
255      critical: 0,
256      last_run_at: null,
257    };
258    const job = { ...defaults, ...overrides };
259    db.prepare(
260      `INSERT OR REPLACE INTO cron_jobs
261       (name, task_key, enabled, handler_type, handler_value, interval_value, interval_unit, timeout_seconds, critical, last_run_at)
262       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
263    ).run(
264      job.name, job.task_key, job.enabled, job.handler_type, job.handler_value,
265      job.interval_value, job.interval_unit, job.timeout_seconds, job.critical, job.last_run_at
266    );
267  }
268  
269  function clearJobs() {
270    db.prepare('DELETE FROM cron_jobs').run();
271    db.prepare('DELETE FROM cron_job_logs').run();
272    db.prepare('DELETE FROM cron_locks').run();
273  }
274  
275  function getLogs(whereClause = '') {
276    return db.prepare(`SELECT * FROM cron_job_logs ${whereClause}`).all();
277  }
278  
279  // ── Tests ─────────────────────────────────────────────────────────────────────
280  
281  describe('runCron — no jobs in database', () => {
282    test('runs without error when no enabled jobs exist', async () => {
283      clearJobs();
284      const { runCron } = cronModule;
285      await assert.doesNotReject(() => runCron());
286    });
287  });
288  
289  describe('runCron — circuit breaker disabled', () => {
290    test('skips all jobs when circuit breaker is disabled', async () => {
291      clearJobs();
292      db.prepare(
293        "INSERT OR REPLACE INTO settings (key, value) VALUES ('cron_circuit_breaker_enabled', 'false')"
294      ).run();
295      seedJob({ name: 'Email Sync', task_key: 'syncEmailEvents' });
296  
297      const { runCron } = cronModule;
298      await assert.doesNotReject(() => runCron());
299  
300      const logs = getLogs();
301      assert.equal(logs.length, 0, 'No logs should exist when circuit breaker disabled');
302  
303      db.prepare("DELETE FROM settings WHERE key = 'cron_circuit_breaker_enabled'").run();
304    });
305  });
306  
307  describe('runCron — function handler job runs', () => {
308    test('runs a function-type job and writes cron_job_logs success entry', async () => {
309      clearJobs();
310      seedJob({ name: 'Email Sync', task_key: 'syncEmailEvents', last_run_at: null });
311  
312      const { runCron } = cronModule;
313      await assert.doesNotReject(() => runCron());
314  
315      const logs = getLogs("WHERE status = 'success'");
316      assert.ok(logs.length >= 1, `Expected ≥1 success log, got ${logs.length}`);
317      assert.ok(logs[0].summary, 'Should have a summary');
318      assert.ok(logs[0].finished_at, 'Should have a finished_at timestamp');
319    });
320  });
321  
322  describe('runCron — job already ran recently (shouldRun=false)', () => {
323    test('skips job that ran 1 second ago (within interval)', async () => {
324      clearJobs();
325      const recentTime = new Date(Date.now() - 1000).toISOString().replace('T', ' ').slice(0, 19);
326      seedJob({
327        name: 'Email Sync',
328        task_key: 'syncEmailEvents',
329        interval_value: 5,
330        interval_unit: 'minutes',
331        last_run_at: recentTime,
332      });
333  
334      const { runCron } = cronModule;
335      await assert.doesNotReject(() => runCron());
336  
337      const logs = getLogs();
338      assert.equal(logs.length, 0, 'Should skip job that ran recently');
339    });
340  });
341  
342  describe('runCron — handler throws error (logTaskFailed path)', () => {
343    test('logs failed status when handler throws', async () => {
344      clearJobs();
345      seedJob({
346        name: 'Unknown Handler',
347        task_key: 'nonExistentHandler',
348        critical: 0,
349        last_run_at: null,
350      });
351  
352      const origExit = process.exit;
353      process.exit = () => {};
354  
355      const { runCron } = cronModule;
356      await assert.doesNotReject(() => runCron());
357  
358      process.exit = origExit;
359  
360      const logs = getLogs("WHERE status = 'failed'");
361      assert.ok(logs.length >= 1, `Expected ≥1 failed log, got ${logs.length}`);
362      assert.ok(
363        logs[0].error_message?.includes('not found'),
364        `error_message: ${logs[0].error_message}`
365      );
366    });
367  });
368  
369  describe('runCron — critical failure calls process.exit(1)', () => {
370    test('calls process.exit(1) when critical job fails', async () => {
371      clearJobs();
372      seedJob({
373        name: 'Critical Job',
374        task_key: 'nonExistentCritical',
375        critical: 1,
376        last_run_at: null,
377      });
378  
379      let exitCode = null;
380      const origExit = process.exit;
381      process.exit = code => { exitCode = code; };
382  
383      const { runCron } = cronModule;
384      await assert.doesNotReject(() => runCron());
385  
386      process.exit = origExit;
387      assert.equal(exitCode, 1, 'Should call process.exit(1) for critical failures');
388    });
389  });
390  
391  describe('runCron — global lock prevents re-entry', () => {
392    test('second runCron while lock is held exits early without running jobs', async () => {
393      clearJobs();
394      seedJob({ name: 'Email Sync', task_key: 'syncEmailEvents', last_run_at: null });
395      db.prepare(
396        `INSERT OR REPLACE INTO cron_locks (lock_key, description, updated_at)
397         VALUES ('cron_runner_global_lock', 'held by test', datetime('now'))`
398      ).run();
399  
400      const { runCron } = cronModule;
401      await assert.doesNotReject(() => runCron());
402  
403      const logs = getLogs();
404      db.prepare("DELETE FROM cron_locks WHERE lock_key = 'cron_runner_global_lock'").run();
405      assert.equal(logs.length, 0, 'Jobs should not run when global lock is held');
406    });
407  });
408  
409  describe('runCron — stale global lock is cleared', () => {
410    test('clears stale lock (>10min) and runs jobs normally', async () => {
411      clearJobs();
412      seedJob({ name: 'Email Sync', task_key: 'syncEmailEvents', last_run_at: null });
413      db.prepare(
414        `INSERT OR REPLACE INTO cron_locks (lock_key, description, updated_at)
415         VALUES ('cron_runner_global_lock', 'stale lock', datetime('now', '-11 minutes'))`
416      ).run();
417  
418      const { runCron } = cronModule;
419      await assert.doesNotReject(() => runCron());
420  
421      const logs = getLogs("WHERE status = 'success'");
422      assert.ok(logs.length >= 1, `Should have run job after clearing stale lock: ${logs.length}`);
423    });
424  });
425  
426  describe('runCron — multiple jobs (hours/days/weeks intervals)', () => {
427    test('seeds and runs multiple jobs with different interval units', async () => {
428      clearJobs();
429      seedJob({ name: 'Job1', task_key: 'syncEmailEvents', interval_value: 1, interval_unit: 'minutes', last_run_at: null });
430      seedJob({ name: 'Job2', task_key: 'syncUnsubscribes', interval_value: 1, interval_unit: 'hours', last_run_at: null });
431      seedJob({ name: 'Job3', task_key: 'pollPurchases', interval_value: 1, interval_unit: 'days', last_run_at: null });
432  
433      const { runCron } = cronModule;
434      await assert.doesNotReject(() => runCron());
435  
436      const logs = getLogs("WHERE status = 'success'");
437      assert.ok(logs.length >= 3, `Expected ≥3 success logs, got ${logs.length}`);
438    });
439  });
440  
441  describe('HANDLERS registry', () => {
442    test('HANDLERS object is exported and has expected function keys', () => {
443      const { HANDLERS } = cronModule;
444      assert.equal(typeof HANDLERS, 'object', 'HANDLERS should be an object');
445      const expectedKeys = [
446        'syncEmailEvents',
447        'syncUnsubscribes',
448        'pollInboundSMS',
449        'pollPurchases',
450        'processGuardian',
451        'processReaper',
452        'classifyUnknownErrors',
453        'databaseMaintenance',
454      ];
455      for (const key of expectedKeys) {
456        assert.ok(typeof HANDLERS[key] === 'function', `HANDLERS.${key} should be a function`);
457      }
458    });
459  });
460  
461  describe('runCron — job with weeks interval', () => {
462    test('does not run weekly job that ran 1 minute ago', async () => {
463      clearJobs();
464      const recentTime = new Date(Date.now() - 60000).toISOString().replace('T', ' ').slice(0, 19);
465      seedJob({
466        name: 'Weekly Job',
467        task_key: 'syncEmailEvents',
468        interval_value: 1,
469        interval_unit: 'weeks',
470        last_run_at: recentTime,
471      });
472  
473      const { runCron } = cronModule;
474      await assert.doesNotReject(() => runCron());
475  
476      const logs = getLogs();
477      assert.equal(logs.length, 0, 'Weekly job should be skipped (ran only 1min ago)');
478    });
479  });
480  
481  describe('generateSummary — via logTaskComplete paths', () => {
482    test('databaseMaintenance handler returns structured result with summary', async () => {
483      const { HANDLERS } = cronModule;
484      const result = await HANDLERS.databaseMaintenance();
485      assert.ok(typeof result === 'object', 'should return object');
486      assert.ok(typeof result.summary === 'string', 'should have summary string');
487      assert.ok(typeof result.metrics === 'object', 'should have metrics');
488    });
489  
490    test('cleanupTestDbs handler returns structured result', async () => {
491      const { HANDLERS } = cronModule;
492      const result = await HANDLERS.cleanupTestDbs();
493      assert.ok(typeof result === 'object', 'should return object');
494      assert.ok(typeof result.summary === 'string', 'should have summary string');
495      assert.ok('deleted' in result.metrics, 'should have deleted in metrics');
496    });
497  
498    test('processGuardian handler returns structured result', async () => {
499      const { HANDLERS } = cronModule;
500      const result = await HANDLERS.processGuardian();
501      assert.ok(typeof result === 'object', 'should return object');
502      assert.ok(typeof result.summary === 'string', 'should have summary string');
503      assert.ok(typeof result.metrics === 'object', 'should have metrics');
504      assert.ok('checks_run' in result.metrics, 'should have checks_run in metrics');
505    });
506  
507    test('processReaper handler returns structured result', async () => {
508      const { HANDLERS } = cronModule;
509      const result = await HANDLERS.processReaper();
510      assert.ok(typeof result === 'object', 'should return object');
511      assert.ok(typeof result.summary === 'string', 'should have summary string');
512      assert.ok('zombie_count' in result.metrics, 'should have zombie_count in metrics');
513    });
514  
515    test('pollFreeScans handler returns structured result', async () => {
516      const { HANDLERS } = cronModule;
517      const result = await HANDLERS.pollFreeScans();
518      assert.ok(typeof result === 'object', 'should return object');
519      assert.ok(typeof result.summary === 'string', 'should have summary string');
520      assert.ok('processed' in result.metrics, 'should have processed in metrics');
521    });
522  
523    test('pollInboundEmails handler returns structured result', async () => {
524      const { HANDLERS } = cronModule;
525      const result = await HANDLERS.pollInboundEmails();
526      assert.ok(typeof result === 'object');
527      assert.ok(typeof result.summary === 'string');
528      assert.ok('processed' in result.metrics);
529    });
530  
531    test('sendPendingReplies handler returns structured result', async () => {
532      const { HANDLERS } = cronModule;
533      const result = await HANDLERS.sendPendingReplies();
534      assert.ok(typeof result === 'object');
535      assert.ok(typeof result.summary === 'string');
536      assert.ok('sms_sent' in result.metrics);
537      assert.ok('email_sent' in result.metrics);
538    });
539  
540    test('processPurchases handler returns structured result', async () => {
541      const { HANDLERS } = cronModule;
542      const result = await HANDLERS.processPurchases();
543      assert.ok(typeof result === 'object');
544      assert.ok(typeof result.summary === 'string');
545      assert.ok('processed' in result.metrics);
546      assert.ok('delivered' in result.metrics);
547      assert.ok('failed' in result.metrics);
548    });
549  
550    test('classifyUnknownErrors handler returns structured result', async () => {
551      const { HANDLERS } = cronModule;
552      const result = await HANDLERS.classifyUnknownErrors();
553      assert.ok(typeof result === 'object');
554      assert.ok(typeof result.summary === 'string');
555      assert.ok('sites_retried' in result.metrics);
556    });
557  
558    test('precomputeDashboard handler returns result', async () => {
559      const { HANDLERS } = cronModule;
560      const result = await HANDLERS.precomputeDashboard();
561      assert.ok(result !== null && result !== undefined);
562    });
563  
564    test('pipelineStatusMonitor handler returns structured result', async () => {
565      const { HANDLERS } = cronModule;
566      const result = await HANDLERS.pipelineStatusMonitor();
567      assert.ok(typeof result === 'object');
568      assert.ok(typeof result.summary === 'string');
569      assert.ok('checks_run' in result.metrics);
570    });
571  });