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