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 });