/ tests / cli / cron-manager.test.js
cron-manager.test.js
  1  /**
  2   * Tests for cron-manager CLI exported functions
  3   *
  4   * cron-manager exports: listJobs, enableJob, disableJob, removeJob, viewLogs, showStats, addJob
  5   * All use db.js (run/getOne/getAll) — mocked via pg-mock with in-memory SQLite.
  6   */
  7  
  8  import { test, describe, mock, before, after, beforeEach } from 'node:test';
  9  import assert from 'node:assert/strict';
 10  import Database from 'better-sqlite3';
 11  import { createPgMock } from '../helpers/pg-mock.js';
 12  
 13  // ─── In-memory SQLite with cron schema ───────────────────────────────────────
 14  
 15  const testDb = new Database(':memory:');
 16  
 17  testDb.exec(`
 18    CREATE TABLE IF NOT EXISTS cron_jobs (
 19      id INTEGER PRIMARY KEY AUTOINCREMENT,
 20      name TEXT NOT NULL UNIQUE,
 21      task_key TEXT NOT NULL UNIQUE,
 22      description TEXT,
 23      handler_type TEXT NOT NULL DEFAULT 'command',
 24      handler_value TEXT NOT NULL,
 25      interval_value INTEGER NOT NULL DEFAULT 5,
 26      interval_unit TEXT NOT NULL DEFAULT 'minutes',
 27      enabled INTEGER DEFAULT 1,
 28      last_run_at DATETIME,
 29      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 30      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 31      pause_pipeline INTEGER DEFAULT 0,
 32      critical INTEGER DEFAULT 1,
 33      timeout_seconds INTEGER DEFAULT NULL
 34    );
 35    CREATE TABLE IF NOT EXISTS cron_job_logs (
 36      id INTEGER PRIMARY KEY AUTOINCREMENT,
 37      job_name TEXT NOT NULL,
 38      started_at DATETIME NOT NULL,
 39      finished_at DATETIME,
 40      status TEXT NOT NULL DEFAULT 'success',
 41      summary TEXT,
 42      full_log TEXT,
 43      items_processed INTEGER DEFAULT 0,
 44      items_failed INTEGER DEFAULT 0,
 45      error_message TEXT,
 46      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 47    );
 48  `);
 49  
 50  // ─── Mock db.js BEFORE importing module under test ────────────────────────────
 51  
 52  mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) });
 53  mock.module('../../src/utils/load-env.js', { defaultExport: {} });
 54  mock.module('../../src/utils/logger.js', {
 55    defaultExport: class {
 56      info() {}
 57      success() {}
 58      error() {}
 59      warn() {}
 60    },
 61  });
 62  
 63  const { listJobs, enableJob, disableJob, removeJob, viewLogs, showStats, addJob, parseArgs } =
 64    await import('../../src/cli/cron-manager.js');
 65  
 66  // ─── Helpers ─────────────────────────────────────────────────────────────────
 67  
 68  let jobSeq = 1;
 69  function insertJob({ taskKey = null, name = null, enabled = 1 } = {}) {
 70    const seq = jobSeq++;
 71    const key = taskKey || `testJob${seq}`;
 72    const jobName = name || `Test Job ${seq}`;
 73    testDb.prepare(
 74      `INSERT INTO cron_jobs (name, task_key, description, handler_type, handler_value, interval_value, interval_unit, enabled)
 75       VALUES (?, ?, 'Test description', 'command', 'npm run test', 5, 'minutes', ?)`
 76    ).run(jobName, key, enabled);
 77    return { taskKey: key, name: jobName };
 78  }
 79  
 80  function clearJobs() {
 81    testDb.prepare('DELETE FROM cron_jobs').run();
 82    testDb.prepare('DELETE FROM cron_job_logs').run();
 83    jobSeq = 1;
 84  }
 85  
 86  // ─── Tests ───────────────────────────────────────────────────────────────────
 87  
 88  after(() => {
 89    testDb.close();
 90  });
 91  
 92  describe('cron-manager exported functions', () => {
 93    beforeEach(() => clearJobs());
 94  
 95    // ─── enableJob ─────────────────────────────────────────────────────────────
 96  
 97    describe('enableJob', () => {
 98      test('returns 1 when job not found', async () => {
 99        const result = await enableJob('nonExistentKey');
100        assert.strictEqual(result, 1);
101      });
102  
103      test('returns 0 when job is already enabled', async () => {
104        insertJob({ taskKey: 'alreadyEnabled', enabled: 1 });
105        const result = await enableJob('alreadyEnabled');
106        assert.strictEqual(result, 0);
107      });
108  
109      test('enables a disabled job and returns 0', async () => {
110        insertJob({ taskKey: 'toEnable', enabled: 0 });
111  
112        const result = await enableJob('toEnable');
113        assert.strictEqual(result, 0);
114  
115        const row = testDb.prepare('SELECT enabled FROM cron_jobs WHERE task_key=?').get('toEnable');
116        assert.strictEqual(row.enabled, 1);
117      });
118    });
119  
120    // ─── disableJob ────────────────────────────────────────────────────────────
121  
122    describe('disableJob', () => {
123      test('returns 1 when job not found', async () => {
124        const result = await disableJob('nonExistentKey');
125        assert.strictEqual(result, 1);
126      });
127  
128      test('returns 0 when job is already disabled', async () => {
129        insertJob({ taskKey: 'alreadyDisabled', enabled: 0 });
130        const result = await disableJob('alreadyDisabled');
131        assert.strictEqual(result, 0);
132      });
133  
134      test('disables an enabled job and returns 0', async () => {
135        insertJob({ taskKey: 'toDisable', enabled: 1 });
136  
137        const result = await disableJob('toDisable');
138        assert.strictEqual(result, 0);
139  
140        const row = testDb.prepare('SELECT enabled FROM cron_jobs WHERE task_key=?').get('toDisable');
141        assert.strictEqual(row.enabled, 0);
142      });
143    });
144  
145    // ─── removeJob ─────────────────────────────────────────────────────────────
146  
147    describe('removeJob', () => {
148      test('returns 1 when job not found', async () => {
149        const result = await removeJob('nonExistentKey');
150        assert.strictEqual(result, 1);
151      });
152  
153      test('removes existing job and returns 0', async () => {
154        insertJob({ taskKey: 'toRemove' });
155  
156        const result = await removeJob('toRemove');
157        assert.strictEqual(result, 0);
158  
159        const row = testDb.prepare('SELECT id FROM cron_jobs WHERE task_key=?').get('toRemove');
160        assert.strictEqual(row, undefined, 'Job should be deleted');
161      });
162    });
163  
164    // ─── viewLogs ──────────────────────────────────────────────────────────────
165  
166    describe('viewLogs', () => {
167      test('returns 1 when job not found', async () => {
168        const result = await viewLogs('nonExistentKey');
169        assert.strictEqual(result, 1);
170      });
171  
172      test('returns 0 when job exists but has no logs', async () => {
173        insertJob({ taskKey: 'noLogs', name: 'No Logs Job' });
174        const result = await viewLogs('noLogs');
175        assert.strictEqual(result, 0);
176      });
177  
178      test('returns 0 when job has execution logs', async () => {
179        const { taskKey, name } = insertJob({ taskKey: 'withLogs', name: 'With Logs Job' });
180        testDb.prepare(
181          `INSERT INTO cron_job_logs (job_name, status, started_at, finished_at, items_processed, items_failed)
182           VALUES (?, 'success', datetime('now', '-1 hour'), datetime('now', '-50 minutes'), 10, 0)`
183        ).run(name);
184  
185        const result = await viewLogs(taskKey);
186        assert.strictEqual(result, 0);
187      });
188  
189      test('respects limit parameter', async () => {
190        const { taskKey, name } = insertJob({ taskKey: 'limitLogs', name: 'Limit Logs Job' });
191        // Insert 5 log entries
192        for (let i = 0; i < 5; i++) {
193          testDb.prepare(
194            `INSERT INTO cron_job_logs (job_name, status, started_at) VALUES (?, 'success', datetime('now', '-${i} hours'))`
195          ).run(name);
196        }
197  
198        // Should not throw with limit=2
199        const result = await viewLogs(taskKey, 2);
200        assert.strictEqual(result, 0);
201      });
202    });
203  
204    // ─── addJob ────────────────────────────────────────────────────────────────
205  
206    describe('addJob', () => {
207      test('returns 1 when required options are missing', async () => {
208        const result = await addJob({});
209        assert.strictEqual(result, 1);
210      });
211  
212      test('returns 1 when name is missing', async () => {
213        const result = await addJob({ taskKey: 'key', handlerValue: 'cmd' });
214        assert.strictEqual(result, 1);
215      });
216  
217      test('inserts new job with valid options', async () => {
218        const result = await addJob({
219          name: 'My New Job',
220          taskKey: 'myNewJob',
221          handlerValue: 'npm run test',
222          type: 'command',
223          interval: 15,
224          unit: 'minutes',
225          description: 'A test job',
226        });
227  
228        // Should not return error code
229        assert.ok(result !== 1, `Expected success (not 1), got: ${result}`);
230  
231        const row = testDb.prepare('SELECT * FROM cron_jobs WHERE task_key=?').get('myNewJob');
232        assert.ok(row, 'Job should be in DB');
233        assert.strictEqual(row.name, 'My New Job');
234        assert.strictEqual(row.interval_value, 15);
235      });
236  
237      test('rejects duplicate task_key', async () => {
238        insertJob({ taskKey: 'dupKey' });
239        const result = await addJob({
240          name: 'Duplicate Job',
241          taskKey: 'dupKey',
242          handlerValue: 'npm run test',
243        });
244        assert.ok(result === 1 || typeof result === 'number');
245      });
246    });
247  
248    // ─── showStats ─────────────────────────────────────────────────────────────
249  
250    describe('showStats', () => {
251      test('does not throw with empty DB', async () => {
252        await assert.doesNotReject(() => showStats());
253      });
254  
255      test('does not throw with populated DB', async () => {
256        insertJob({ enabled: 1 });
257        insertJob({ enabled: 0 });
258        await assert.doesNotReject(() => showStats());
259      });
260    });
261  
262    // ─── listJobs ──────────────────────────────────────────────────────────────
263  
264    describe('listJobs', () => {
265      test('does not throw with empty DB', async () => {
266        await assert.doesNotReject(() => listJobs());
267      });
268  
269      test('does not throw with jobs in DB', async () => {
270        insertJob({ enabled: 1 });
271        insertJob({ enabled: 0 });
272        await assert.doesNotReject(() => listJobs());
273      });
274  
275      test('filter=1 shows only enabled jobs', async () => {
276        insertJob({ taskKey: 'enabled1', enabled: 1 });
277        insertJob({ taskKey: 'disabled1', enabled: 0 });
278        // listJobs with filter=1 should not throw and query only enabled
279        await assert.doesNotReject(() => listJobs(1));
280      });
281  
282      test('filter=0 shows only disabled jobs', async () => {
283        insertJob({ taskKey: 'enabled2', enabled: 1 });
284        insertJob({ taskKey: 'disabled2', enabled: 0 });
285        await assert.doesNotReject(() => listJobs(0));
286      });
287    });
288  
289    // ─── viewLogs — summary/error_message branches ─────────────────────────────
290  
291    describe('viewLogs — log detail branches', () => {
292      test('shows summary when log has summary field', async () => {
293        const { taskKey, name } = insertJob({ taskKey: 'summaryJob', name: 'Summary Job' });
294        testDb.prepare(
295          `INSERT INTO cron_job_logs (job_name, status, started_at, finished_at, items_processed, items_failed, summary)
296           VALUES (?, 'success', datetime('now', '-1 hour'), datetime('now'), 5, 0, 'Processed 5 sites')`
297        ).run(name);
298        const result = await viewLogs(taskKey);
299        assert.strictEqual(result, 0);
300      });
301  
302      test('shows error_message when log has error', async () => {
303        const { taskKey, name } = insertJob({ taskKey: 'errorJob', name: 'Error Job' });
304        testDb.prepare(
305          `INSERT INTO cron_job_logs (job_name, status, started_at, finished_at, items_processed, items_failed, error_message)
306           VALUES (?, 'failed', datetime('now', '-1 hour'), datetime('now'), 0, 1, 'Connection timeout')`
307        ).run(name);
308        const result = await viewLogs(taskKey);
309        assert.strictEqual(result, 0);
310      });
311    });
312  
313    // ─── showStats — recent failures branch ────────────────────────────────────
314  
315    describe('showStats — recent failures', () => {
316      test('shows recent failures when failed logs exist', async () => {
317        const { name } = insertJob({ taskKey: 'failStatsJob', name: 'Fail Stats Job' });
318        // Insert a failed log within last 7 days
319        testDb.prepare(
320          `INSERT INTO cron_job_logs (job_name, status, started_at, finished_at)
321           VALUES (?, 'failed', datetime('now', '-1 day'), datetime('now', '-1 day', '+1 minute'))`
322        ).run(name);
323        await assert.doesNotReject(() => showStats());
324      });
325    });
326  
327    // ─── parseArgs ─────────────────────────────────────────────────────────────
328  
329    describe('parseArgs', () => {
330      test('returns empty object for empty args', () => {
331        const result = parseArgs([]);
332        assert.deepStrictEqual(result, {});
333      });
334  
335      test('parses single --key value pair', () => {
336        const result = parseArgs(['--name', 'My Job']);
337        assert.deepStrictEqual(result, { name: 'My Job' });
338      });
339  
340      test('parses multiple --key value pairs', () => {
341        const result = parseArgs(['--name', 'My Job', '--interval', '15', '--unit', 'minutes']);
342        assert.deepStrictEqual(result, { name: 'My Job', interval: '15', unit: 'minutes' });
343      });
344  
345      test('skips args that do not start with --', () => {
346        const result = parseArgs(['list', '--name', 'My Job']);
347        // 'list' is not a --key so it's skipped; --name consumes next arg 'My Job'
348        assert.strictEqual(result.name, 'My Job');
349      });
350    });
351  });