/ tests / utils / retry-handler-supplement2.test.js
retry-handler-supplement2.test.js
  1  /**
  2   * Supplement tests for src/utils/retry-handler.js
  3   *
  4   * Covers uncovered branches:
  5   *   - recordFailure: circuit breaker error at max retries → keeps current status, sets retry in 1h
  6   *   - recordFailure: HTTP 403/404 long-term retry (within limit)
  7   *   - recordFailure: HTTP 403/404 long-term retry exhausted → marks as failing
  8   *   - recordFailure: non-circuit, non-http error under retry limit → increments counter
  9   *   - recordFailure: marks as failing when max retries exceeded (non-special error)
 10   *   - getRetryStats: returns correct aggregate stats
 11   *   - resetRetries: zeroes out retry_count and error_message
 12   */
 13  
 14  import { test, describe, mock, beforeEach } from 'node:test';
 15  import assert from 'node:assert/strict';
 16  import Database from 'better-sqlite3';
 17  import { createPgMock } from '../helpers/pg-mock.js';
 18  
 19  // ── Shared in-memory DB for the db.js mock ────────────────────────────────────
 20  // retry-handler.js imports `run` and `getOne` from db.js. We mock db.js to
 21  // route those calls to this in-memory SQLite instance.
 22  
 23  const db = new Database(':memory:');
 24  
 25  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 26  
 27  // ── Import module under test AFTER mock.module ─────────────────────────────────
 28  const { recordFailure, resetRetries, getRetryStats } = await import('../../src/utils/retry-handler.js');
 29  
 30  // ── Schema setup helper ───────────────────────────────────────────────────────
 31  
 32  function resetSchema() {
 33    db.exec(`DROP TABLE IF EXISTS sites`);
 34    db.exec(`
 35      CREATE TABLE sites (
 36        id INTEGER PRIMARY KEY AUTOINCREMENT,
 37        status TEXT NOT NULL DEFAULT 'found',
 38        error_message TEXT,
 39        retry_count INTEGER DEFAULT 0,
 40        recapture_count INTEGER DEFAULT 0,
 41        last_retry_at DATETIME,
 42        recapture_at DATETIME,
 43        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 44        rescored_at DATETIME
 45      )
 46    `);
 47  }
 48  
 49  function insertSite(overrides = {}) {
 50    const { lastInsertRowid } = db
 51      .prepare(
 52        `INSERT INTO sites (status, retry_count, recapture_count) VALUES (?, ?, ?)`
 53      )
 54      .run(
 55        overrides.status ?? 'assets_captured',
 56        overrides.retry_count ?? 0,
 57        overrides.recapture_count ?? 0
 58      );
 59    return lastInsertRowid;
 60  }
 61  
 62  // ── Tests ─────────────────────────────────────────────────────────────────────
 63  
 64  describe('recordFailure — under retry limit', () => {
 65    beforeEach(() => resetSchema());
 66  
 67    test('increments retry_count and keeps current status', async () => {
 68      const id = insertSite({ status: 'assets_captured', retry_count: 0 });
 69  
 70      const result = await recordFailure(id, 'assets', new Error('ECONNRESET'), 'assets_captured');
 71  
 72      assert.equal(result, false, 'should return false (still retrying)');
 73      const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
 74      assert.equal(row.retry_count, 1);
 75      assert.equal(row.status, 'assets_captured', 'status should not change while retrying');
 76      assert.ok(row.error_message.includes('ECONNRESET'));
 77    });
 78  
 79    test('handles string error (not Error object)', async () => {
 80      const id = insertSite({ status: 'prog_scored', retry_count: 1 });
 81  
 82      await recordFailure(id, 'scoring', 'Timeout error', 'prog_scored');
 83      const row = db.prepare('SELECT error_message FROM sites WHERE id = ?').get(id);
 84      assert.ok(row.error_message.includes('Timeout error'));
 85    });
 86  
 87    test('uses default max retries (5) for unknown stage name', async () => {
 88      const id = insertSite({ retry_count: 3 }); // 3 current, max=5 → still retrying
 89  
 90      const result = await recordFailure(id, 'unknown_stage', new Error('Some error'), 'found');
 91      assert.equal(result, false, 'should still be retrying at retry 4/5');
 92    });
 93  });
 94  
 95  describe('recordFailure — at max retries, non-special errors', () => {
 96    beforeEach(() => resetSchema());
 97  
 98    test('marks site as failing when max retries exceeded for assets stage', async () => {
 99      // assets limit = 3, so retry_count=2 means next increment hits 3 which >= 3 → failing
100      const id = insertSite({ status: 'assets_captured', retry_count: 2 });
101  
102      const result = await recordFailure(id, 'assets', new Error('Something broke'), 'assets_captured');
103  
104      assert.equal(result, true, 'should return true (now failing)');
105      const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
106      assert.equal(row.status, 'failing');
107      assert.ok(row.error_message.includes('Max retries'));
108      assert.ok(row.error_message.includes('Something broke'));
109    });
110  
111    test('marks site as failing for proposals stage at retry limit 5', async () => {
112      const id = insertSite({ retry_count: 4 }); // proposals limit = 5 → hits cap at 5
113  
114      const result = await recordFailure(id, 'proposals', new Error('LLM timeout'), 'prog_scored');
115      assert.equal(result, true, 'should be failing after 5 retries');
116      const row = db.prepare('SELECT status FROM sites WHERE id = ?').get(id);
117      assert.equal(row.status, 'failing');
118    });
119  });
120  
121  describe('recordFailure — circuit breaker error at max retries', () => {
122    beforeEach(() => resetSchema());
123  
124    test('schedules 1-hour retry instead of failing for circuit breaker error', async () => {
125      const id = insertSite({ status: 'prog_scored', retry_count: 2 }); // assets limit=3 → at cap
126  
127      const result = await recordFailure(
128        id,
129        'assets',
130        new Error('Breaker is open: too many errors'),
131        'prog_scored'
132      );
133  
134      assert.equal(result, false, 'circuit breaker should NOT mark as failing');
135      const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
136      // Status should remain at current stage
137      assert.equal(row.status, 'prog_scored');
138      // recapture_at should be set to ~1 hour from now
139      assert.ok(row.recapture_at, 'recapture_at should be set for 1h retry');
140      assert.ok(row.error_message.includes('Circuit breaker'));
141    });
142  
143    test('recognizes EOPENBREAKER as circuit breaker error', async () => {
144      const id = insertSite({ retry_count: 4 }); // proposals limit=5 → at cap
145  
146      const result = await recordFailure(
147        id,
148        'proposals',
149        new Error('EOPENBREAKER: circuit open'),
150        'proposals'
151      );
152  
153      assert.equal(result, false, 'EOPENBREAKER should not mark as failing');
154    });
155  });
156  
157  describe('recordFailure — HTTP 403/404 long-term retry', () => {
158    beforeEach(() => resetSchema());
159  
160    test('schedules 7-day retry for HTTP 403 within retry limit', async () => {
161      const id = insertSite({ retry_count: 2, recapture_count: 0 }); // assets limit=3 → at cap
162  
163      const result = await recordFailure(
164        id,
165        'assets',
166        new Error('HTTP 403 - Cannot capture assets for error response'),
167        'found'
168      );
169  
170      assert.equal(result, false, 'HTTP 403 within limit should schedule retry, not fail');
171      const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
172      assert.equal(row.status, 'found'); // reset to found for retry
173      assert.equal(row.retry_count, 0); // reset
174      assert.equal(row.recapture_count, 1);
175      assert.ok(row.recapture_at, 'recapture_at should be set for 7-day retry');
176    });
177  
178    test('schedules 7-day retry for HTTP 404 within retry limit', async () => {
179      const id = insertSite({ retry_count: 2, recapture_count: 3 }); // still within 8 max
180  
181      const result = await recordFailure(
182        id,
183        'assets',
184        new Error('HTTP 404 - Cannot capture assets for error response'),
185        'found'
186      );
187  
188      assert.equal(result, false, 'HTTP 404 within long-term limit should schedule retry');
189    });
190  
191    test('falls through to failing when HTTP retries exhausted (recapture_count >= 8)', async () => {
192      const id = insertSite({ retry_count: 2, recapture_count: 8 }); // at HTTP cap
193  
194      const result = await recordFailure(
195        id,
196        'assets',
197        new Error('HTTP 404 - Cannot capture assets for error response'),
198        'found'
199      );
200  
201      assert.equal(result, true, 'should fail after 8 HTTP retries');
202      const row = db.prepare('SELECT status FROM sites WHERE id = ?').get(id);
203      assert.equal(row.status, 'failing');
204    });
205  });
206  
207  describe('resetRetries', () => {
208    beforeEach(() => resetSchema());
209  
210    test('zeroes retry_count, clears error_message and last_retry_at', async () => {
211      const id = insertSite({ retry_count: 3 });
212      db.prepare(
213        `UPDATE sites SET error_message = 'old error', last_retry_at = CURRENT_TIMESTAMP WHERE id = ?`
214      ).run(id);
215  
216      await resetRetries(id);
217  
218      const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
219      assert.equal(row.retry_count, 0);
220      assert.equal(row.error_message, null);
221      assert.equal(row.last_retry_at, null);
222    });
223  });
224  
225  describe('getRetryStats', () => {
226    beforeEach(() => resetSchema());
227  
228    test('returns correct counts for failing and retrying sites', async () => {
229      db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('failing', 3)`).run();
230      db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('failing', 2)`).run();
231      db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('found', 1)`).run();
232      db.prepare(`INSERT INTO sites (status, retry_count) VALUES ('assets_captured', 0)`).run();
233  
234      const stats = await getRetryStats();
235      assert.equal(stats.failing_sites, 2);
236      assert.equal(stats.retrying_sites, 1); // only 'found' with retry_count>0
237      assert.ok(stats.max_retry_count >= 3);
238    });
239  
240    test('returns zero stats for empty table', async () => {
241      const stats = await getRetryStats();
242      assert.equal(stats.failing_sites, 0);
243      assert.equal(stats.retrying_sites, 0);
244    });
245  });