cron-locks.test.js
1 /** 2 * Cron Lock Management Tests 3 * 4 * Tests for the cron_locks table utility that prevents duplicate cron job runs. 5 * Pure function module — uses a mock database object (no real SQLite needed). 6 */ 7 8 import { test, describe } from 'node:test'; 9 import assert from 'node:assert/strict'; 10 11 import { 12 acquireLock, 13 releaseLock, 14 checkStaleLock, 15 clearStaleLock, 16 checkAndClearStaleLock, 17 } from '../../src/utils/cron-locks.js'; 18 19 // ─── Mock database factory ──────────────────────────────────────────────────── 20 21 /** 22 * Create a mock DB that simulates cron_locks table behavior. 23 * @param {Object} opts 24 * @param {boolean} opts.lockExists - Whether a lock row already exists 25 * @param {Date|null} opts.lockAge - Age of existing lock (Date object) 26 * @param {boolean} opts.insertThrows - Whether INSERT should throw (lock contention) 27 */ 28 function createMockDb({ lockExists = false, lockAge = null, insertThrows = false } = {}) { 29 const runCalls = []; 30 const rows = lockExists 31 ? [{ updated_at: lockAge ? lockAge.toISOString() : new Date().toISOString() }] 32 : []; 33 34 return { 35 runCalls, 36 prepare(sql) { 37 return { 38 run(...args) { 39 runCalls.push({ sql, args }); 40 if (sql.includes('INSERT') && insertThrows) { 41 const err = new Error('UNIQUE constraint failed: cron_locks.lock_key'); 42 err.code = 'SQLITE_CONSTRAINT_UNIQUE'; 43 throw err; 44 } 45 return { changes: 1 }; 46 }, 47 get(_lockKey) { 48 return rows[0] || undefined; 49 }, 50 all() { 51 return rows; 52 }, 53 }; 54 }, 55 }; 56 } 57 58 // ─── acquireLock ────────────────────────────────────────────────────────────── 59 60 describe('acquireLock', () => { 61 test('returns true when lock is successfully acquired', () => { 62 const db = createMockDb(); 63 const result = acquireLock(db, 'cron_scoring_running', 'Scoring stage'); 64 65 assert.equal(result, true); 66 assert.equal(db.runCalls.length, 1); 67 assert.ok(db.runCalls[0].sql.includes('INSERT INTO cron_locks')); 68 assert.equal(db.runCalls[0].args[0], 'cron_scoring_running'); 69 assert.equal(db.runCalls[0].args[1], 'Scoring stage'); 70 }); 71 72 test('returns false when lock already exists (UNIQUE constraint)', () => { 73 const db = createMockDb({ insertThrows: true }); 74 const result = acquireLock(db, 'cron_scoring_running', 'Scoring stage'); 75 76 assert.equal(result, false); 77 }); 78 79 test('uses null description when not provided', () => { 80 const db = createMockDb(); 81 acquireLock(db, 'my_lock'); 82 83 assert.equal(db.runCalls[0].args[1], null); 84 }); 85 }); 86 87 // ─── releaseLock ────────────────────────────────────────────────────────────── 88 89 describe('releaseLock', () => { 90 test('deletes the lock row', () => { 91 const db = createMockDb({ lockExists: true }); 92 releaseLock(db, 'cron_scoring_running'); 93 94 assert.equal(db.runCalls.length, 1); 95 assert.ok(db.runCalls[0].sql.includes('DELETE FROM cron_locks')); 96 assert.equal(db.runCalls[0].args[0], 'cron_scoring_running'); 97 }); 98 99 test('does not throw when lock does not exist', () => { 100 const db = createMockDb({ lockExists: false }); 101 assert.doesNotThrow(() => releaseLock(db, 'nonexistent_lock')); 102 }); 103 }); 104 105 // ─── checkStaleLock ─────────────────────────────────────────────────────────── 106 107 describe('checkStaleLock', () => { 108 test('returns null when no lock exists', () => { 109 const db = createMockDb({ lockExists: false }); 110 const result = checkStaleLock(db, 'cron_scoring_running'); 111 112 assert.equal(result, null); 113 }); 114 115 test('returns "active" when lock is fresh (within threshold)', () => { 116 const freshTime = new Date(); // lock just acquired 117 const db = createMockDb({ lockExists: true, lockAge: freshTime }); 118 const result = checkStaleLock(db, 'cron_scoring_running', 10 * 60 * 1000); // 10 min threshold 119 120 assert.equal(result, 'active'); 121 }); 122 123 test('returns "stale" when lock is older than threshold', () => { 124 const staleTime = new Date(Date.now() - 20 * 60 * 1000); // 20 minutes ago 125 const db = createMockDb({ lockExists: true, lockAge: staleTime }); 126 const result = checkStaleLock(db, 'cron_scoring_running', 10 * 60 * 1000); // 10 min threshold 127 128 assert.equal(result, 'stale'); 129 }); 130 131 test('uses 10-minute default threshold', () => { 132 // Lock exactly at threshold (9 min old) → active 133 const nineMinAgo = new Date(Date.now() - 9 * 60 * 1000); 134 const db = createMockDb({ lockExists: true, lockAge: nineMinAgo }); 135 assert.equal(checkStaleLock(db, 'my_lock'), 'active'); 136 137 // Lock over threshold (11 min old) → stale 138 const elevenMinAgo = new Date(Date.now() - 11 * 60 * 1000); 139 const db2 = createMockDb({ lockExists: true, lockAge: elevenMinAgo }); 140 assert.equal(checkStaleLock(db2, 'my_lock'), 'stale'); 141 }); 142 }); 143 144 // ─── clearStaleLock ─────────────────────────────────────────────────────────── 145 146 describe('clearStaleLock', () => { 147 test('deletes the lock by key', () => { 148 const db = createMockDb({ lockExists: true }); 149 clearStaleLock(db, 'cron_enrich_running'); 150 151 assert.equal(db.runCalls.length, 1); 152 assert.ok(db.runCalls[0].sql.includes('DELETE FROM cron_locks')); 153 assert.equal(db.runCalls[0].args[0], 'cron_enrich_running'); 154 }); 155 }); 156 157 // ─── checkAndClearStaleLock ─────────────────────────────────────────────────── 158 159 describe('checkAndClearStaleLock', () => { 160 test('returns true when no lock exists (can proceed)', () => { 161 const db = createMockDb({ lockExists: false }); 162 const result = checkAndClearStaleLock(db, 'cron_scoring_running', 'Scoring'); 163 164 assert.equal(result, true); 165 assert.equal(db.runCalls.length, 0, 'No DB writes needed'); 166 }); 167 168 test('returns false when active lock exists (must wait)', () => { 169 const freshTime = new Date(); 170 const db = createMockDb({ lockExists: true, lockAge: freshTime }); 171 const result = checkAndClearStaleLock(db, 'cron_scoring_running', 'Scoring'); 172 173 assert.equal(result, false); 174 assert.equal(db.runCalls.length, 0, 'Should not delete active lock'); 175 }); 176 177 test('clears stale lock and returns true (can proceed)', () => { 178 const staleTime = new Date(Date.now() - 20 * 60 * 1000); // 20 min ago 179 const db = createMockDb({ lockExists: true, lockAge: staleTime }); 180 const result = checkAndClearStaleLock(db, 'cron_scoring_running', 'Scoring', 10 * 60 * 1000); 181 182 assert.equal(result, true); 183 // Should have deleted the stale lock 184 const deleteCall = db.runCalls.find(c => c.sql.includes('DELETE')); 185 assert.ok(deleteCall, 'Should delete stale lock'); 186 assert.equal(deleteCall.args[0], 'cron_scoring_running'); 187 }); 188 189 test('uses 10-minute default stale threshold', () => { 190 const elevenMinAgo = new Date(Date.now() - 11 * 60 * 1000); 191 const db = createMockDb({ lockExists: true, lockAge: elevenMinAgo }); 192 const result = checkAndClearStaleLock(db, 'lock', 'Stage'); 193 194 assert.equal(result, true, '11-min old lock should be considered stale with default threshold'); 195 }); 196 197 test('respects custom stale threshold', () => { 198 // 5-minute old lock with 3-minute threshold → stale 199 const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); 200 const db = createMockDb({ lockExists: true, lockAge: fiveMinAgo }); 201 const result = checkAndClearStaleLock(db, 'lock', 'Stage', 3 * 60 * 1000); 202 203 assert.equal(result, true, 'Should clear lock older than custom threshold'); 204 }); 205 }); 206 207 // ─── Acquire → Release lifecycle ───────────────────────────────────────────── 208 209 describe('lock lifecycle', () => { 210 test('acquire succeeds, second acquire fails, release allows re-acquire', () => { 211 // Simulate with state tracking mock 212 let lockHeld = false; 213 const runCalls = []; 214 215 const db = { 216 prepare(sql) { 217 return { 218 run(...args) { 219 runCalls.push({ sql, args }); 220 if (sql.includes('INSERT')) { 221 if (lockHeld) { 222 throw new Error('UNIQUE constraint failed'); 223 } 224 lockHeld = true; 225 } 226 if (sql.includes('DELETE')) { 227 lockHeld = false; 228 } 229 return { changes: 1 }; 230 }, 231 get() { 232 return lockHeld ? { updated_at: new Date().toISOString() } : undefined; 233 }, 234 }; 235 }, 236 }; 237 238 // First acquire: succeeds 239 assert.equal(acquireLock(db, 'my_lock', 'Test'), true); 240 assert.equal(lockHeld, true); 241 242 // Second acquire while held: fails 243 assert.equal(acquireLock(db, 'my_lock', 'Test'), false); 244 assert.equal(lockHeld, true, 'Lock still held after failed acquire'); 245 246 // Release: clears lock 247 releaseLock(db, 'my_lock'); 248 assert.equal(lockHeld, false); 249 250 // Re-acquire after release: succeeds 251 assert.equal(acquireLock(db, 'my_lock', 'Test'), true); 252 assert.equal(lockHeld, true); 253 }); 254 });