/ __quarantined_tests__ / cron / cron-tasks.test.js
cron-tasks.test.js
  1  /**
  2   * Tests for src/cron.js — HANDLERS registry and module shape
  3   *
  4   * Verifies that:
  5   * - cronModule exports { runCron, HANDLERS }
  6   * - HANDLERS has expected handler functions
  7   * - checkKeywords and databaseMaintenance handlers return structured results
  8   *
  9   * NOTE: requires --experimental-test-module-mocks
 10   */
 11  
 12  import { test, describe, mock, after } from 'node:test';
 13  import assert from 'node:assert/strict';
 14  import Database from 'better-sqlite3';
 15  import { join } from 'path';
 16  import { tmpdir } from 'os';
 17  import { existsSync, unlinkSync } from 'fs';
 18  
 19  const TEST_DB = join(tmpdir(), `test-cron-tasks-${Date.now()}.db`);
 20  process.env.DATABASE_PATH = TEST_DB;
 21  process.env.NODE_ENV = 'test';
 22  
 23  // ── Mock all external modules ─────────────────────────────────────────────────
 24  
 25  mock.module('../../src/utils/sync-email-events.js', {
 26    namedExports: { syncEmailEvents: async () => ({ synced: 0, errors: 0 }) },
 27  });
 28  mock.module('../../src/utils/sync-unsubscribes.js', {
 29    namedExports: { syncUnsubscribes: async () => ({ synced: 0, errors: 0 }) },
 30  });
 31  mock.module('../../src/inbound/sms.js', {
 32    namedExports: {
 33      pollInboundSMS: async () => ({ processed: 0, new_messages: 0 }),
 34      setupWebhookServer: async () => {},
 35    },
 36  });
 37  mock.module('../../src/inbound/email.js', {
 38    namedExports: { pollInboundEmails: async () => ({ processed: 0, stored: 0, unmatched: 0 }) },
 39  });
 40  mock.module('../../src/inbound/processor.js', {
 41    namedExports: { processAllReplies: async () => ({ sms: { sent: 0 }, email: { sent: 0 } }) },
 42  });
 43  mock.module('../../src/cron/poll-free-scans.js', {
 44    namedExports: { pollFreeScans: async () => ({ processed: 0, inserted: 0, failed: 0 }) },
 45  });
 46  mock.module('../../src/cron/poll-purchases.js', {
 47    namedExports: { pollPurchases: async () => ({ processed: 0, successful: 0 }) },
 48  });
 49  mock.module('../../src/cron/process-purchases.js', {
 50    namedExports: {
 51      processPendingPurchases: async () => ({ processed: 0, delivered: 0, failed: 0 }),
 52    },
 53  });
 54  mock.module('../../src/cron/precompute-dashboard.js', {
 55    namedExports: {
 56      precomputeDashboard: async () => ({ summary: 'ok', details: {}, metrics: {} }),
 57    },
 58  });
 59  mock.module('../../src/cron/process-guardian.js', {
 60    namedExports: {
 61      runProcessGuardian: async () => ({
 62        checks_run: 0,
 63        ok: 0,
 64        warnings: 0,
 65        critical: 0,
 66        duration_seconds: 0,
 67        results: [],
 68      }),
 69    },
 70  });
 71  mock.module('../../src/cron/process-reaper.js', {
 72    namedExports: {
 73      runProcessReaper: async () => ({
 74        zombie_count: 0,
 75        free_mem_mb: 512,
 76        swap_pct: 0,
 77        stale_processes_killed: 0,
 78        duration_seconds: 0,
 79      }),
 80    },
 81  });
 82  mock.module('../../src/cron/cleanup-test-dbs.js', {
 83    namedExports: { runCleanupTestDbs: () => ({ deleted: 0, freed_kb: 0 }) },
 84  });
 85  mock.module('../../src/cron/pipeline-status-monitor.js', {
 86    namedExports: {
 87      runPipelineStatusMonitor: async () => ({
 88        summary: 'ok',
 89        checks_run: 0,
 90        duration_seconds: 0,
 91        actions: [],
 92      }),
 93    },
 94  });
 95  mock.module('../../src/cron/classify-unknown-errors.js', {
 96    namedExports: {
 97      classifyUnknownErrors: async () => ({
 98        sites_retried: 0,
 99        outreaches_retried: 0,
100        patterns_applied: 0,
101      }),
102    },
103  });
104  mock.module('../../src/agents/utils/task-manager.js', {
105    namedExports: {
106      createAgentTask: async () => 1,
107      findDuplicateTask: async () => null,
108    },
109  });
110  mock.module('../../src/utils/log-rotator.js', {
111    namedExports: { rotateLogs: () => ({ deleted: 2, kept: 5, freedSpace: 1024 * 1024 }) },
112  });
113  mock.module('../../src/utils/rate-limit-scheduler.js', {
114    namedExports: {
115      getSkipStages: () => new Set(),
116      getRateLimitStatus: () => [],
117      setRateLimit: () => {},
118    },
119  });
120  mock.module('../../src/utils/load-env.js', {
121    namedExports: {},
122  });
123  
124  // ── Create minimal schema ─────────────────────────────────────────────────────
125  
126  {
127    const db = new Database(TEST_DB);
128    db.pragma('journal_mode = WAL');
129    db.exec(`
130      CREATE TABLE IF NOT EXISTS settings (
131        key TEXT PRIMARY KEY,
132        value TEXT,
133        description TEXT,
134        updated_at TEXT DEFAULT CURRENT_TIMESTAMP
135      );
136      CREATE TABLE IF NOT EXISTS cron_jobs (
137        id INTEGER PRIMARY KEY AUTOINCREMENT,
138        name TEXT NOT NULL UNIQUE,
139        task_key TEXT NOT NULL UNIQUE,
140        enabled INTEGER NOT NULL DEFAULT 1,
141        handler_type TEXT NOT NULL DEFAULT 'function',
142        handler_value TEXT,
143        interval_value INTEGER NOT NULL DEFAULT 5,
144        interval_unit TEXT NOT NULL DEFAULT 'minutes',
145        timeout_seconds INTEGER,
146        priority INTEGER DEFAULT 5,
147        critical INTEGER DEFAULT 1,
148        last_run_at TEXT
149      );
150      CREATE TABLE IF NOT EXISTS cron_job_logs (
151        id INTEGER PRIMARY KEY AUTOINCREMENT,
152        job_name TEXT NOT NULL,
153        started_at TEXT NOT NULL DEFAULT (datetime('now')),
154        finished_at TEXT,
155        status TEXT NOT NULL DEFAULT 'running',
156        summary TEXT,
157        full_log TEXT,
158        error_message TEXT,
159        items_processed INTEGER DEFAULT 0,
160        items_failed INTEGER DEFAULT 0
161      );
162      CREATE TABLE IF NOT EXISTS cron_locks (
163        lock_key TEXT PRIMARY KEY,
164        description TEXT,
165        updated_at TEXT DEFAULT (datetime('now'))
166      );
167      CREATE TABLE IF NOT EXISTS messages (
168        id INTEGER PRIMARY KEY AUTOINCREMENT,
169        site_id INTEGER,
170        direction TEXT NOT NULL DEFAULT 'outbound',
171        created_at TEXT DEFAULT CURRENT_TIMESTAMP,
172        updated_at TEXT DEFAULT CURRENT_TIMESTAMP
173      );
174      CREATE TABLE IF NOT EXISTS sites (
175        id INTEGER PRIMARY KEY AUTOINCREMENT,
176        domain TEXT NOT NULL DEFAULT 'test.com',
177        status TEXT DEFAULT 'found',
178        score REAL,
179        error_message TEXT,
180        updated_at TEXT DEFAULT CURRENT_TIMESTAMP
181      );
182      CREATE TABLE IF NOT EXISTS keywords (
183        id INTEGER PRIMARY KEY AUTOINCREMENT,
184        keyword TEXT NOT NULL,
185        status TEXT DEFAULT 'pending'
186      );
187      CREATE TABLE IF NOT EXISTS pipeline_control (
188        key TEXT PRIMARY KEY,
189        value TEXT
190      );
191      CREATE TABLE IF NOT EXISTS agent_tasks (
192        id INTEGER PRIMARY KEY AUTOINCREMENT,
193        task_type TEXT NOT NULL,
194        assigned_to TEXT,
195        priority INTEGER DEFAULT 5,
196        status TEXT DEFAULT 'pending',
197        context_json TEXT,
198        created_at TEXT DEFAULT (datetime('now')),
199        updated_at TEXT DEFAULT (datetime('now'))
200      );
201      CREATE TABLE IF NOT EXISTS site_status (
202        id INTEGER PRIMARY KEY AUTOINCREMENT,
203        site_id INTEGER NOT NULL,
204        status TEXT,
205        created_at TEXT DEFAULT (datetime('now'))
206      );
207      CREATE TABLE IF NOT EXISTS human_review_queue (
208        id INTEGER PRIMARY KEY AUTOINCREMENT,
209        file TEXT,
210        reason TEXT,
211        type TEXT,
212        priority TEXT DEFAULT 'medium',
213        status TEXT DEFAULT 'pending',
214        created_at TEXT DEFAULT (datetime('now'))
215      );
216      CREATE TABLE IF NOT EXISTS outreach_history (
217        id INTEGER PRIMARY KEY AUTOINCREMENT,
218        site_id INTEGER,
219        channel TEXT,
220        status TEXT,
221        created_at TEXT DEFAULT (datetime('now'))
222      );
223    `);
224    db.close();
225  }
226  
227  // Import AFTER mocks and schema
228  const { default: cronModule } = await import('../../src/cron.js');
229  
230  after(() => {
231    if (existsSync(TEST_DB)) {
232      try {
233        unlinkSync(TEST_DB);
234      } catch {
235        /* ignore */
236      }
237    }
238  });
239  
240  // ── Tests ─────────────────────────────────────────────────────────────────────
241  
242  describe('HANDLERS registry', () => {
243    test('HANDLERS is a non-empty object', () => {
244      const { HANDLERS } = cronModule;
245      assert.ok(typeof HANDLERS === 'object' && HANDLERS !== null);
246      assert.ok(Object.keys(HANDLERS).length > 10, 'Should have many handler definitions');
247    });
248  
249    test('each HANDLER is a function', () => {
250      const { HANDLERS } = cronModule;
251      for (const [key, fn] of Object.entries(HANDLERS)) {
252        assert.ok(typeof fn === 'function', `HANDLERS.${key} should be a function`);
253      }
254    });
255  
256    test('HANDLERS has all expected keys', () => {
257      const { HANDLERS } = cronModule;
258      const expectedKeys = [
259        'syncEmailEvents',
260        'syncUnsubscribes',
261        'pollInboundSMS',
262        'pollInboundEmails',
263        'sendPendingReplies',
264        'pollFreeScans',
265        'pollPurchases',
266        'processPurchases',
267        'processGuardian',
268        'processReaper',
269        'cleanupTestDbs',
270        'pipelineStatusMonitor',
271        'classifyUnknownErrors',
272        'databaseMaintenance',
273        'checkKeywords',
274      ];
275      for (const key of expectedKeys) {
276        assert.ok(key in HANDLERS, `HANDLERS should have key: ${key}`);
277      }
278    });
279  
280    test('runCron is a function', () => {
281      const { runCron } = cronModule;
282      assert.ok(typeof runCron === 'function');
283    });
284  });
285  
286  describe('HANDLERS — checkKeywords', () => {
287    test('checkKeywords returns structured result with metrics', async () => {
288      const { HANDLERS } = cronModule;
289      const result = await HANDLERS.checkKeywords();
290      assert.ok(typeof result === 'object', 'should return object');
291      assert.ok(typeof result.summary === 'string', 'should have summary');
292      assert.ok(typeof result.metrics === 'object', 'should have metrics');
293      assert.ok('pending' in result.metrics, 'should have pending in metrics');
294      assert.ok('active' in result.metrics, 'should have active in metrics');
295    });
296  });
297  
298  describe('HANDLERS — databaseMaintenance', () => {
299    test('databaseMaintenance returns structured result', async () => {
300      const { HANDLERS } = cronModule;
301      const result = await HANDLERS.databaseMaintenance();
302      assert.ok(typeof result === 'object', 'should return object');
303      assert.ok(typeof result.summary === 'string', 'should have summary');
304      assert.ok(typeof result.metrics === 'object', 'should have metrics');
305      assert.ok('sites' in result.metrics, 'should have sites count');
306      assert.ok('outreaches' in result.metrics, 'should have outreaches count');
307      assert.ok('healthy' in result.metrics, 'should have healthy flag');
308    });
309  });
310  
311  describe('HANDLERS — monitorPipeline and monitorSystem', () => {
312    test('monitorPipeline creates agent tasks and returns metrics', async () => {
313      const { HANDLERS } = cronModule;
314      const result = await HANDLERS.monitorPipeline();
315      assert.ok(typeof result === 'object', 'should return object');
316      assert.ok(typeof result.summary === 'string', 'should have summary');
317      assert.ok(typeof result.metrics === 'object', 'should have metrics');
318      assert.ok('created' in result.metrics, 'should have created count');
319      assert.ok('skipped' in result.metrics, 'should have skipped count');
320    });
321  
322    test('monitorSystem creates agent tasks and returns metrics', async () => {
323      const { HANDLERS } = cronModule;
324      const result = await HANDLERS.monitorSystem();
325      assert.ok(typeof result === 'object', 'should return object');
326      assert.ok(typeof result.summary === 'string', 'should have summary');
327      assert.ok('created' in result.metrics, 'should have created count');
328    });
329  });
330  
331  describe('HANDLERS — maintenance and utility handlers', () => {
332    test('classifyUnknownErrors returns structured result', async () => {
333      const { HANDLERS } = cronModule;
334      const result = await HANDLERS.classifyUnknownErrors();
335      assert.ok(typeof result === 'object');
336      assert.ok(typeof result.summary === 'string');
337      assert.ok('sites_retried' in result.metrics);
338      assert.ok('outreaches_retried' in result.metrics);
339    });
340  
341    test('processGuardian returns structured result', async () => {
342      const { HANDLERS } = cronModule;
343      const result = await HANDLERS.processGuardian();
344      assert.ok(typeof result === 'object');
345      assert.ok(typeof result.summary === 'string');
346      assert.ok('checks_run' in result.metrics);
347      assert.ok('ok' in result.metrics);
348      assert.ok('warnings' in result.metrics);
349      assert.ok('critical' in result.metrics);
350    });
351  
352    test('processReaper returns structured result', async () => {
353      const { HANDLERS } = cronModule;
354      const result = await HANDLERS.processReaper();
355      assert.ok(typeof result === 'object');
356      assert.ok(typeof result.summary === 'string');
357      assert.ok('zombie_count' in result.metrics);
358      assert.ok('free_mem_mb' in result.metrics);
359    });
360  
361    test('cleanupTestDbs returns structured result', async () => {
362      const { HANDLERS } = cronModule;
363      const result = await HANDLERS.cleanupTestDbs();
364      assert.ok(typeof result === 'object');
365      assert.ok(typeof result.summary === 'string');
366      assert.ok('deleted' in result.metrics);
367      assert.ok('freed_kb' in result.metrics);
368    });
369  
370    test('pipelineStatusMonitor returns structured result', async () => {
371      const { HANDLERS } = cronModule;
372      const result = await HANDLERS.pipelineStatusMonitor();
373      assert.ok(typeof result === 'object');
374      assert.ok(typeof result.summary === 'string');
375      assert.ok('checks_run' in result.metrics);
376    });
377  
378    test('pollFreeScans returns structured result', async () => {
379      const { HANDLERS } = cronModule;
380      const result = await HANDLERS.pollFreeScans();
381      assert.ok(typeof result === 'object');
382      assert.ok(typeof result.summary === 'string');
383    });
384  
385    test('pollInboundEmails returns structured result', async () => {
386      const { HANDLERS } = cronModule;
387      const result = await HANDLERS.pollInboundEmails();
388      assert.ok(typeof result === 'object');
389      assert.ok(typeof result.summary === 'string');
390    });
391  
392    test('sendPendingReplies returns structured result', async () => {
393      const { HANDLERS } = cronModule;
394      const result = await HANDLERS.sendPendingReplies();
395      assert.ok(typeof result === 'object');
396      assert.ok(typeof result.summary === 'string');
397    });
398  
399    test('processPurchases returns structured result', async () => {
400      const { HANDLERS } = cronModule;
401      const result = await HANDLERS.processPurchases();
402      assert.ok(typeof result === 'object');
403      assert.ok(typeof result.summary === 'string');
404    });
405  
406    test('precomputeDashboard returns a result', async () => {
407      const { HANDLERS } = cronModule;
408      const result = await HANDLERS.precomputeDashboard();
409      assert.ok(typeof result === 'object');
410    });
411  
412    test('rotateLogs returns structured result', async () => {
413      const { HANDLERS } = cronModule;
414      const result = await HANDLERS.rotateLogs();
415      assert.ok(typeof result === 'object');
416      assert.ok(typeof result.summary === 'string');
417      assert.ok('deleted' in result.metrics);
418      assert.ok('kept' in result.metrics);
419      assert.ok('freed_mb' in result.metrics);
420    });
421  
422    test('checkRateLimits returns api status', async () => {
423      const { HANDLERS } = cronModule;
424      const result = await HANDLERS.checkRateLimits();
425      assert.ok(typeof result === 'object');
426      assert.ok(typeof result.summary === 'string');
427      assert.ok('configured' in result.metrics);
428      assert.ok('missing' in result.metrics);
429    });
430  
431    test('technicalDebtReview handles missing TODO.md gracefully', async () => {
432      const { HANDLERS } = cronModule;
433      // docs/TODO.md may or may not exist — handler catches the error and returns gracefully
434      await assert.doesNotReject(() => HANDLERS.technicalDebtReview());
435    });
436  
437    test('analyzePerformance runs without error', async () => {
438      const { HANDLERS } = cronModule;
439      const result = await HANDLERS.analyzePerformance();
440      assert.ok(typeof result === 'object');
441      assert.ok(typeof result.summary === 'string');
442      assert.ok('tables_analyzed' in result.metrics);
443    });
444  
445    test('purgeSiteStatusHistory returns deletion metrics', async () => {
446      const { HANDLERS } = cronModule;
447      const result = await HANDLERS.purgeSiteStatusHistory();
448      assert.ok(typeof result === 'object');
449      assert.ok(typeof result.summary === 'string');
450      assert.ok('rows_deleted' in result.metrics);
451    });
452  
453    test('vacuumDatabase vacuums main DB and returns metrics', async () => {
454      const { HANDLERS } = cronModule;
455      // TEST_DB exists; no backup dir → vacuums just the main DB
456      const result = await HANDLERS.vacuumDatabase();
457      assert.ok(typeof result === 'object');
458      assert.ok(typeof result.summary === 'string');
459      assert.ok('databases_vacuumed' in result.metrics);
460      assert.ok(result.metrics.databases_vacuumed >= 1, 'should vacuum at least main DB');
461    });
462  
463    test('backupDatabase skips when backup dir does not exist', async () => {
464      const { HANDLERS } = cronModule;
465      // db/backup dir will not exist in test env → triggers skip path
466      const result = await HANDLERS.backupDatabase();
467      assert.ok(typeof result === 'object');
468      assert.ok(typeof result.summary === 'string');
469      // Either skipped (no backup dir) or succeeded
470      assert.ok(
471        result.metrics.skipped === 1 || result.metrics.success === 1,
472        `unexpected metrics: ${JSON.stringify(result.metrics)}`
473      );
474    });
475  
476    test('diskCleanup runs without throwing', async () => {
477      const { HANDLERS } = cronModule;
478      // Handler may fail in test env (permission issues with coverage dir) — just verify it doesn't crash fatally
479      let result;
480      try {
481        result = await HANDLERS.diskCleanup();
482      } catch {
483        // Permission errors on coverage dir are acceptable in test env
484        return;
485      }
486      assert.ok(typeof result === 'object');
487      assert.ok(typeof result.summary === 'string');
488      assert.ok('actions' in result.metrics);
489      assert.ok('freed_mb' in result.metrics);
490    });
491  
492    test('unifiedAutofix returns result when script throws', async () => {
493      const { HANDLERS } = cronModule;
494      // In test env, node scripts/unified-autofix.js will likely fail
495      // Handler should catch and return error result, not throw
496      await assert.doesNotReject(() => HANDLERS.unifiedAutofix());
497    });
498  });