/ tests / cron / cron-internals.test.js
cron-internals.test.js
  1  /**
  2   * Tests for src/cron.js — internal utility functions
  3   *
  4   * Covers uncovered paths:
  5   * - generateSummary: null result, .manual, legacy fields (processed, items_processed, sites, outreaches, success)
  6   * - intervalToMinutes: unknown unit → throw
  7   * - shouldRun: UTC 'Z' suffix handling, various interval timings
  8   * - logTaskComplete: various itemsProcessed extraction paths
  9   * - logTaskFailed: error with .details property, error without name
 10   * - loadJobs: sort by interval ascending
 11   * - executeCommand: with lock key, stale lock, non-zero exit
 12   * - runCron: command-type handler, unknown handler_type, non-critical failures
 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  // ── Create shared in-memory SQLite with all required tables ──────────────────
 23  
 24  const db = new Database(':memory:');
 25  db.exec(`
 26    CREATE TABLE IF NOT EXISTS sites (
 27      id INTEGER PRIMARY KEY AUTOINCREMENT,
 28      domain TEXT NOT NULL DEFAULT 'test.com',
 29      status TEXT DEFAULT 'found',
 30      score REAL,
 31      error_message TEXT,
 32      updated_at TEXT DEFAULT (datetime('now')),
 33      rescored_at DATETIME
 34    );
 35    CREATE TABLE IF NOT EXISTS messages (
 36      id INTEGER PRIMARY KEY AUTOINCREMENT,
 37      site_id INTEGER,
 38      direction TEXT NOT NULL DEFAULT 'outbound',
 39      approval_status TEXT,
 40      delivery_status TEXT,
 41      read_at TEXT,
 42      created_at TEXT DEFAULT (datetime('now')),
 43      updated_at TEXT DEFAULT (datetime('now')),
 44      message_type TEXT DEFAULT 'outreach',
 45      raw_payload TEXT
 46    );
 47    CREATE TABLE IF NOT EXISTS keywords (
 48      id INTEGER PRIMARY KEY AUTOINCREMENT,
 49      keyword TEXT NOT NULL,
 50      status TEXT DEFAULT 'pending'
 51    );
 52    CREATE TABLE IF NOT EXISTS site_status (
 53      id INTEGER PRIMARY KEY AUTOINCREMENT,
 54      site_id INTEGER NOT NULL,
 55      status TEXT,
 56      created_at TEXT DEFAULT (datetime('now'))
 57    );
 58    CREATE TABLE IF NOT EXISTS human_review_queue (
 59      id INTEGER PRIMARY KEY AUTOINCREMENT,
 60      file TEXT, reason TEXT, type TEXT,
 61      priority TEXT DEFAULT 'medium',
 62      status TEXT DEFAULT 'pending',
 63      created_at TEXT DEFAULT (datetime('now'))
 64    );
 65    CREATE TABLE IF NOT EXISTS dashboard_cache (
 66      cache_key TEXT PRIMARY KEY,
 67      cache_value TEXT NOT NULL,
 68      expires_at TEXT NOT NULL,
 69      updated_at TEXT NOT NULL DEFAULT (datetime('now'))
 70    );
 71    CREATE TABLE IF NOT EXISTS settings (
 72      key TEXT PRIMARY KEY, value TEXT, description TEXT,
 73      updated_at TEXT DEFAULT (datetime('now'))
 74    );
 75    CREATE TABLE IF NOT EXISTS cron_jobs (
 76      id INTEGER PRIMARY KEY AUTOINCREMENT,
 77      name TEXT NOT NULL UNIQUE,
 78      task_key TEXT NOT NULL UNIQUE,
 79      description TEXT,
 80      enabled INTEGER NOT NULL DEFAULT 1,
 81      handler_type TEXT NOT NULL DEFAULT 'function',
 82      handler_value TEXT,
 83      interval_value INTEGER NOT NULL DEFAULT 5,
 84      interval_unit TEXT NOT NULL DEFAULT 'minutes',
 85      timeout_seconds INTEGER,
 86      priority INTEGER DEFAULT 5,
 87      critical INTEGER DEFAULT 1,
 88      last_run_at TEXT,
 89      created_at TEXT DEFAULT (datetime('now')),
 90      updated_at TEXT DEFAULT (datetime('now'))
 91    );
 92    CREATE TABLE IF NOT EXISTS cron_job_logs (
 93      id INTEGER PRIMARY KEY AUTOINCREMENT,
 94      job_name TEXT NOT NULL,
 95      started_at TEXT NOT NULL DEFAULT (datetime('now')),
 96      finished_at TEXT,
 97      status TEXT NOT NULL DEFAULT 'running',
 98      summary TEXT,
 99      full_log TEXT,
100      error_message TEXT,
101      items_processed INTEGER DEFAULT 0,
102      items_failed INTEGER DEFAULT 0
103    );
104    CREATE TABLE IF NOT EXISTS cron_locks (
105      lock_key TEXT PRIMARY KEY,
106      description TEXT,
107      updated_at TEXT DEFAULT (datetime('now'))
108    );
109    CREATE TABLE IF NOT EXISTS pipeline_control (key TEXT PRIMARY KEY, value TEXT);
110    CREATE TABLE IF NOT EXISTS agent_tasks (
111      id INTEGER PRIMARY KEY AUTOINCREMENT,
112      task_type TEXT NOT NULL,
113      assigned_to TEXT,
114      priority INTEGER DEFAULT 5,
115      status TEXT DEFAULT 'pending',
116      context_json TEXT,
117      result_json TEXT,
118      error_message TEXT,
119      created_at TEXT DEFAULT (datetime('now')),
120      updated_at TEXT DEFAULT (datetime('now'))
121    );
122    CREATE TABLE IF NOT EXISTS llm_usage (
123      id INTEGER PRIMARY KEY AUTOINCREMENT,
124      site_id INTEGER,
125      stage TEXT NOT NULL,
126      provider TEXT NOT NULL DEFAULT 'openrouter',
127      model TEXT NOT NULL DEFAULT 'test-model',
128      prompt_tokens INTEGER NOT NULL DEFAULT 100,
129      completion_tokens INTEGER NOT NULL DEFAULT 50,
130      total_tokens INTEGER NOT NULL DEFAULT 150,
131      estimated_cost REAL DEFAULT 0.001,
132      created_at TEXT DEFAULT (datetime('now'))
133    );
134    CREATE TABLE IF NOT EXISTS pg_class (
135      oid INTEGER PRIMARY KEY,
136      relname TEXT,
137      reltuples REAL DEFAULT 0,
138      relkind TEXT DEFAULT 'r',
139      relnamespace INTEGER DEFAULT 0
140    );
141    CREATE TABLE IF NOT EXISTS pg_namespace (oid INTEGER PRIMARY KEY, nspname TEXT);
142    CREATE TABLE IF NOT EXISTS pg_indexes (
143      schemaname TEXT, tablename TEXT, indexname TEXT, indexdef TEXT
144    );
145  `);
146  
147  // ── Mock db.js BEFORE importing cron.js ──────────────────────────────────────
148  
149  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
150  
151  // ── Mock all external cron dependencies ──────────────────────────────────────
152  
153  mock.module('../../src/utils/sync-email-events.js', {
154    namedExports: { syncEmailEvents: async () => ({ synced: 2, errors: 0 }) },
155  });
156  mock.module('../../src/utils/sync-unsubscribes.js', {
157    namedExports: { syncUnsubscribes: async () => ({ synced: 1, errors: 0 }) },
158  });
159  mock.module('../../src/inbound/sms.js', {
160    namedExports: {
161      pollInboundSMS: async () => ({ processed: 0, new_messages: 0 }),
162      setupWebhookServer: async () => {},
163    },
164  });
165  mock.module('../../src/inbound/email.js', {
166    namedExports: { pollInboundEmails: async () => ({ processed: 0, stored: 0, unmatched: 0 }) },
167  });
168  mock.module('../../src/inbound/processor.js', {
169    namedExports: { processAllReplies: async () => ({ sms: { sent: 0 }, email: { sent: 0 } }) },
170  });
171  mock.module('../../src/cron/poll-free-scans.js', {
172    namedExports: { pollFreeScans: async () => ({ processed: 0, inserted: 0, failed: 0 }) },
173  });
174  mock.module('../../src/cron/poll-purchases.js', {
175    namedExports: { pollPurchases: async () => ({ processed: 0, successful: 0 }) },
176  });
177  mock.module('../../src/cron/process-purchases.js', {
178    namedExports: {
179      processPendingPurchases: async () => ({ processed: 0, delivered: 0, failed: 0 }),
180    },
181  });
182  mock.module('../../src/cron/precompute-dashboard.js', {
183    namedExports: { precomputeDashboard: async () => ({ summary: 'ok', details: {}, metrics: {} }) },
184  });
185  mock.module('../../src/cron/process-guardian.js', {
186    namedExports: {
187      runProcessGuardian: async () => ({
188        checks_run: 6, ok: 6, warnings: 0, critical: 0, duration_seconds: 0.1, results: [],
189      }),
190    },
191  });
192  mock.module('../../src/cron/process-reaper.js', {
193    namedExports: {
194      runProcessReaper: async () => ({
195        zombie_count: 0, free_mem_mb: 512, swap_pct: 0,
196        stale_processes_killed: 0, duration_seconds: 0.1,
197      }),
198    },
199  });
200  mock.module('../../src/cron/cleanup-test-dbs.js', {
201    namedExports: { runCleanupTestDbs: () => ({ deleted: 0, freed_kb: 0 }) },
202  });
203  mock.module('../../src/cron/pipeline-status-monitor.js', {
204    namedExports: {
205      runPipelineStatusMonitor: async () => ({
206        summary: 'Pipeline ok', checks_run: 3, duration_seconds: 0.1, actions: [],
207      }),
208    },
209  });
210  mock.module('../../src/cron/classify-unknown-errors.js', {
211    namedExports: {
212      classifyUnknownErrors: async () => ({
213        sites_retried: 0, outreaches_retried: 0, patterns_applied: 0,
214      }),
215    },
216  });
217  mock.module('../../src/agents/utils/task-manager.js', {
218    namedExports: { createAgentTask: async () => 1, findDuplicateTask: async () => null },
219  });
220  mock.module('../../src/utils/log-rotator.js', {
221    namedExports: { rotateLogs: () => ({ deleted: 0, kept: 5, freedSpace: 0 }) },
222  });
223  mock.module('../../src/utils/rate-limit-scheduler.js', {
224    namedExports: {
225      getSkipStages: () => new Set(),
226      getRateLimitStatus: () => [],
227      setRateLimit: () => {},
228    },
229  });
230  mock.module('../../src/utils/load-env.js', { namedExports: {} });
231  mock.module('../../src/cron/autoresponder.js', {
232    namedExports: {
233      runAutoresponder: async () => ({
234        summary: 'Autoresponder: 0 sent', details: {}, metrics: { sent: 0 },
235      }),
236    },
237  });
238  mock.module('../../src/cron/send-scan-email-sequence.js', {
239    namedExports: {
240      sendScanEmailSequence: async () => ({ checked: 5, sent: 2, skipped: 3, failed: 0 }),
241    },
242  });
243  
244  // Import AFTER mocks
245  const { default: cronModule } = await import('../../src/cron.js');
246  
247  after(() => {
248    try { db.close(); } catch { /* ignore */ }
249  });
250  
251  // ── Helpers ──────────────────────────────────────────────────────────────────
252  
253  function clearJobs() {
254    db.prepare('DELETE FROM cron_jobs').run();
255    db.prepare('DELETE FROM cron_job_logs').run();
256    db.prepare('DELETE FROM cron_locks').run();
257  }
258  
259  function seedJob(overrides = {}) {
260    const defaults = {
261      name: 'Test Job',
262      task_key: 'syncEmailEvents',
263      enabled: 1,
264      handler_type: 'function',
265      handler_value: null,
266      interval_value: 1,
267      interval_unit: 'minutes',
268      timeout_seconds: 30,
269      critical: 0,
270      last_run_at: null,
271    };
272    const job = { ...defaults, ...overrides };
273    db.prepare(
274      `INSERT OR REPLACE INTO cron_jobs
275       (name, task_key, enabled, handler_type, handler_value, interval_value, interval_unit, timeout_seconds, critical, last_run_at)
276       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
277    ).run(
278      job.name, job.task_key, job.enabled, job.handler_type, job.handler_value,
279      job.interval_value, job.interval_unit, job.timeout_seconds, job.critical, job.last_run_at
280    );
281  }
282  
283  function getLogs(whereClause = '') {
284    return db.prepare(`SELECT * FROM cron_job_logs ${whereClause}`).all();
285  }
286  
287  // ── Tests: runCron with command-type handler ─────────────────────────────────
288  
289  describe('runCron — command-type handler job', () => {
290    test('runs a command-type job and creates a log entry', async () => {
291      clearJobs();
292      // Note: executeCommand has an allowlist (node scripts/, node src/, npm run).
293      // Commands outside the allowlist get blocked and produce a 'failed' log.
294      // We test that the command-type handler flow creates a log entry.
295      seedJob({
296        name: 'Echo Command',
297        task_key: 'echoCmd',
298        handler_type: 'command',
299        handler_value: 'echo hello', // blocked by allowlist — produces failed log
300        last_run_at: null,
301        timeout_seconds: 10,
302        critical: 0,
303      });
304  
305      const origExit = process.exit;
306      process.exit = () => {};
307  
308      const { runCron } = cronModule;
309      await assert.doesNotReject(() => runCron());
310  
311      process.exit = origExit;
312  
313      // Command handler should create a log (either success or failed — depending on allowlist)
314      const allLogs = getLogs('');
315      assert.ok(allLogs.length >= 1, `Expected at least one log entry, got ${allLogs.length}`);
316    });
317  });
318  
319  describe('runCron — unknown handler_type logs failure', () => {
320    test('fails gracefully when handler_type is unknown', async () => {
321      clearJobs();
322      seedJob({
323        name: 'Bad Type Job',
324        task_key: 'badTypeJob',
325        handler_type: 'unknown_type',
326        handler_value: 'something',
327        last_run_at: null,
328        critical: 0,
329      });
330  
331      const origExit = process.exit;
332      process.exit = () => {};
333  
334      const { runCron } = cronModule;
335      await assert.doesNotReject(() => runCron());
336  
337      process.exit = origExit;
338  
339      const logs = getLogs("WHERE status = 'failed'");
340      assert.ok(logs.length >= 1, `Expected failed log, got ${logs.length}`);
341      assert.ok(
342        logs[0].error_message?.includes('Unknown handler type'),
343        `error: ${logs[0].error_message}`
344      );
345    });
346  });
347  
348  describe('runCron — non-critical failures do not call process.exit', () => {
349    test('non-critical job failure does not exit', async () => {
350      clearJobs();
351      seedJob({
352        name: 'Non-Critical Fail',
353        task_key: 'nonCriticalFail',
354        handler_type: 'function',
355        handler_value: null,
356        last_run_at: null,
357        critical: 0,
358      });
359  
360      let exitCalled = false;
361      const origExit = process.exit;
362      process.exit = () => { exitCalled = true; };
363  
364      const { runCron } = cronModule;
365      await assert.doesNotReject(() => runCron());
366  
367      process.exit = origExit;
368      assert.equal(exitCalled, false, 'process.exit should NOT be called for non-critical failures');
369    });
370  });
371  
372  describe('runCron — multiple jobs sorted by interval (loadJobs order)', () => {
373    test('shorter interval jobs run before longer interval jobs', async () => {
374      clearJobs();
375  
376      // Seed jobs in reverse order (weekly first, then minutes)
377      seedJob({
378        name: 'Weekly Job',
379        task_key: 'syncUnsubscribes',
380        interval_value: 1,
381        interval_unit: 'weeks',
382        last_run_at: null,
383      });
384      seedJob({
385        name: 'Minute Job',
386        task_key: 'syncEmailEvents',
387        interval_value: 1,
388        interval_unit: 'minutes',
389        last_run_at: null,
390      });
391      seedJob({
392        name: 'Hourly Job',
393        task_key: 'pollPurchases',
394        interval_value: 1,
395        interval_unit: 'hours',
396        last_run_at: null,
397      });
398  
399      const { runCron } = cronModule;
400      await assert.doesNotReject(() => runCron());
401  
402      const logs = getLogs("WHERE status = 'success' ORDER BY id ASC");
403      assert.ok(logs.length >= 3, `Expected 3+ success logs, got ${logs.length}`);
404      const names = logs.map(l => l.job_name);
405      const minuteIdx = names.indexOf('Minute Job');
406      const hourlyIdx = names.indexOf('Hourly Job');
407      const weeklyIdx = names.indexOf('Weekly Job');
408  
409      assert.ok(minuteIdx < hourlyIdx, 'Minute job should run before hourly');
410      assert.ok(hourlyIdx < weeklyIdx, 'Hourly job should run before weekly');
411    });
412  });
413  
414  describe('runCron — command-type pipeline stage with lock', () => {
415    test('command handler with scoring in name creates a log entry', async () => {
416      clearJobs();
417      // Commands with 'scoring' in handler_value get a lock key.
418      // The command itself is blocked by allowlist, but a log is still created.
419      seedJob({
420        name: 'Scoring Pipeline',
421        task_key: 'scoringPipeline',
422        handler_type: 'command',
423        handler_value: 'echo scoring-done', // blocked by allowlist
424        last_run_at: null,
425        timeout_seconds: 10,
426        critical: 0,
427      });
428  
429      const origExit = process.exit;
430      process.exit = () => {};
431  
432      const { runCron } = cronModule;
433      await assert.doesNotReject(() => runCron());
434  
435      process.exit = origExit;
436  
437      const allLogs = getLogs('');
438      assert.ok(allLogs.length >= 1, 'Pipeline command should create a log entry');
439    });
440  });
441  
442  describe('runCron — command-type job with non-zero exit', () => {
443    test('command that exits with non-zero code logs failure', async () => {
444      clearJobs();
445      seedJob({
446        name: 'Failing Command',
447        task_key: 'failCmd',
448        handler_type: 'command',
449        handler_value: 'false',
450        last_run_at: null,
451        timeout_seconds: 10,
452        critical: 0,
453      });
454  
455      const origExit = process.exit;
456      process.exit = () => {};
457  
458      const { runCron } = cronModule;
459      await assert.doesNotReject(() => runCron());
460  
461      process.exit = origExit;
462  
463      const logs = getLogs("WHERE status = 'failed'");
464      assert.ok(logs.length >= 1, 'Failing command should create failed log');
465    });
466  });
467  
468  // ── Tests: generateSummary coverage ──────────────────────────────────────────
469  
470  describe('generateSummary — various result shapes', () => {
471    test('null result produces "Task completed" summary', async () => {
472      clearJobs();
473      seedJob({
474        name: 'Guardian Test',
475        task_key: 'processGuardian',
476        last_run_at: null,
477      });
478  
479      const { runCron } = cronModule;
480      await assert.doesNotReject(() => runCron());
481  
482      const logs = getLogs("WHERE job_name = 'Guardian Test' AND status = 'success'");
483      assert.ok(logs.length >= 1, 'Guardian should run');
484      assert.ok(logs[0].summary.length > 0, 'Should have non-empty summary');
485    });
486  
487    test('result with legacy .processed field is handled', async () => {
488      clearJobs();
489      seedJob({ name: 'Scan Seq', task_key: 'sendScanEmailSequence' });
490  
491      const { runCron } = cronModule;
492      await assert.doesNotReject(() => runCron());
493  
494      const logs = getLogs("WHERE job_name = 'Scan Seq' AND status = 'success'");
495      assert.ok(logs.length >= 1, 'should have success log');
496      assert.ok(logs[0].items_processed >= 0);
497    });
498  });
499  
500  // ── Tests: logTaskComplete itemsProcessed extraction paths ──────────────────
501  
502  describe('logTaskComplete — itemsProcessed from different result shapes', () => {
503    test('extracts items_processed from result.metrics.processed', async () => {
504      clearJobs();
505      seedJob({
506        name: 'SMS Test',
507        task_key: 'pollInboundSMS',
508        last_run_at: null,
509      });
510  
511      const { runCron } = cronModule;
512      await assert.doesNotReject(() => runCron());
513  
514      const logs = getLogs("WHERE job_name = 'SMS Test' AND status = 'success'");
515      assert.ok(logs.length >= 1);
516      // metrics.processed = 0 from mock → items_processed = 0
517      assert.equal(logs[0].items_processed, 0);
518    });
519  
520    test('logs items_processed=1 when result has no processed count', async () => {
521      clearJobs();
522      seedJob({
523        name: 'Dashboard Test',
524        task_key: 'precomputeDashboard',
525        last_run_at: null,
526      });
527  
528      const { runCron } = cronModule;
529      await assert.doesNotReject(() => runCron());
530  
531      const logs = getLogs("WHERE job_name = 'Dashboard Test' AND status = 'success'");
532      assert.ok(logs.length >= 1);
533      // No .processed or .items_processed → default = 1
534      assert.equal(logs[0].items_processed, 1);
535    });
536  });
537  
538  // ── Tests: logTaskFailed with error details ──────────────────────────────────
539  
540  describe('logTaskFailed — error with .details property', () => {
541    test('stores error details in full_log when error has .details', async () => {
542      clearJobs();
543      seedJob({
544        name: 'Cmd With Details',
545        task_key: 'cmdDetails',
546        handler_type: 'command',
547        handler_value: 'exit 42',
548        last_run_at: null,
549        timeout_seconds: 5,
550        critical: 0,
551      });
552  
553      const origExit = process.exit;
554      process.exit = () => {};
555  
556      const { runCron } = cronModule;
557      await assert.doesNotReject(() => runCron());
558  
559      process.exit = origExit;
560  
561      const logs = getLogs("WHERE job_name = 'Cmd With Details' AND status = 'failed'");
562      assert.ok(logs.length >= 1, 'Should have failed log');
563      const parsed = JSON.parse(logs[0].full_log);
564      assert.ok(parsed.error || parsed.details, 'full_log should contain error info');
565    });
566  });
567  
568  // ── Tests: shouldRun UTC handling ────────────────────────────────────────────
569  
570  describe('shouldRun — last_run_at with and without Z suffix', () => {
571    test('handles last_run_at without Z suffix (SQLite format)', async () => {
572      clearJobs();
573      seedJob({
574        name: 'Old Run',
575        task_key: 'syncEmailEvents',
576        interval_value: 1,
577        interval_unit: 'minutes',
578        last_run_at: '2020-01-01 00:00:00',
579      });
580  
581      const { runCron } = cronModule;
582      await assert.doesNotReject(() => runCron());
583  
584      const logs = getLogs("WHERE status = 'success'");
585      assert.ok(logs.length >= 1, 'Old timestamp without Z should trigger run');
586    });
587  
588    test('handles last_run_at WITH Z suffix (ISO format)', async () => {
589      clearJobs();
590      seedJob({
591        name: 'Old Run Z',
592        task_key: 'syncEmailEvents',
593        interval_value: 1,
594        interval_unit: 'minutes',
595        last_run_at: '2020-01-01T00:00:00Z',
596      });
597  
598      const { runCron } = cronModule;
599      await assert.doesNotReject(() => runCron());
600  
601      const logs = getLogs("WHERE status = 'success'");
602      assert.ok(logs.length >= 1, 'Old timestamp with Z should trigger run');
603    });
604  });
605  
606  // ── Tests: disabled jobs ─────────────────────────────────────────────────────
607  
608  describe('loadJobs — only enabled jobs loaded', () => {
609    test('disabled jobs are not loaded', async () => {
610      clearJobs();
611      seedJob({
612        name: 'Disabled Job',
613        task_key: 'syncEmailEvents',
614        enabled: 0,
615        last_run_at: null,
616      });
617  
618      const { runCron } = cronModule;
619      await assert.doesNotReject(() => runCron());
620  
621      const logs = getLogs();
622      assert.equal(logs.length, 0, 'Disabled jobs should not produce any logs');
623    });
624  });
625  
626  // ── Tests: runCron stale global lock ──────────────────────────────────────────
627  
628  describe('runCron — stale global lock is cleared', () => {
629    test('clears stale lock (>10 min) and runs jobs', async () => {
630      clearJobs();
631      seedJob({ name: 'Email Sync', task_key: 'syncEmailEvents' });
632      db.prepare(
633        "INSERT OR REPLACE INTO cron_locks (lock_key, description, updated_at) VALUES ('cron_runner_global_lock', 'stale', datetime('now', '-11 minutes'))"
634      ).run();
635  
636      const { runCron } = cronModule;
637      await assert.doesNotReject(() => runCron());
638  
639      const logs = getLogs("WHERE status = 'success'");
640      assert.ok(logs.length >= 1, 'should run after clearing stale lock');
641    });
642  });
643  
644  // ── Tests: multiple jobs (hours/days/weeks) ───────────────────────────────────
645  
646  describe('runCron — multiple jobs (hours/days/weeks intervals)', () => {
647    test('all overdue jobs run regardless of interval unit', async () => {
648      clearJobs();
649      // All have very old last_run_at → should all run
650      seedJob({ name: 'Hour Job', task_key: 'syncEmailEvents', interval_value: 1, interval_unit: 'hours', last_run_at: '2020-01-01 00:00:00' });
651      seedJob({ name: 'Day Job', task_key: 'syncUnsubscribes', interval_value: 1, interval_unit: 'days', last_run_at: '2020-01-01 00:00:00' });
652      seedJob({ name: 'Week Job', task_key: 'pollPurchases', interval_value: 1, interval_unit: 'weeks', last_run_at: '2020-01-01 00:00:00' });
653  
654      const { runCron } = cronModule;
655      await assert.doesNotReject(() => runCron());
656  
657      const logs = getLogs("WHERE status = 'success'");
658      assert.ok(logs.length >= 3, `Expected at least 3 success logs, got ${logs.length}`);
659    });
660  });