/ __quarantined_tests__ / utils / cron-locks.test.js
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  });