/ tests / utils / retry-handler.test.js
retry-handler.test.js
  1  /**
  2   * Tests for Retry Handler Utility
  3   * Covers recordFailure, resetRetries, and getRetryStats functions
  4   *
  5   * Uses createPgMock with in-memory SQLite to test actual SQL logic.
  6   */
  7  
  8  import { describe, it, mock, before } 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 test DB with minimal schema ─────────────────────────────────
 14  
 15  const db = new Database(':memory:');
 16  db.exec(`
 17    CREATE TABLE sites (
 18      id INTEGER PRIMARY KEY AUTOINCREMENT,
 19      domain TEXT DEFAULT 'example.com',
 20      landing_page_url TEXT DEFAULT 'https://example.com',
 21      keyword TEXT DEFAULT 'test',
 22      status TEXT DEFAULT 'found',
 23      retry_count INTEGER DEFAULT 0,
 24      recapture_count INTEGER DEFAULT 0,
 25      error_message TEXT,
 26      last_retry_at DATETIME,
 27      recapture_at DATETIME,
 28      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
 29    );
 30  `);
 31  
 32  // ─── Mock db.js before importing retry-handler ─────────────────────────────
 33  
 34  mock.module('../../src/utils/db.js', {
 35    namedExports: createPgMock(db),
 36  });
 37  
 38  mock.module('../../src/utils/logger.js', {
 39    defaultExport: class {
 40      info() {}
 41      warn() {}
 42      error() {}
 43      success() {}
 44      debug() {}
 45    },
 46  });
 47  
 48  const { recordFailure, resetRetries, getRetryStats } = await import(
 49    '../../src/utils/retry-handler.js'
 50  );
 51  
 52  // ─── Helpers ─────────────────────────────────────────────────────────────────
 53  
 54  function clearSites() {
 55    db.prepare('DELETE FROM sites').run();
 56  }
 57  
 58  function insertSite(overrides = {}) {
 59    const defaults = {
 60      domain: 'example.com',
 61      landing_page_url: 'https://example.com',
 62      keyword: 'test keyword',
 63      status: 'found',
 64      retry_count: 0,
 65      error_message: null,
 66    };
 67    const site = { ...defaults, ...overrides };
 68  
 69    const result = db
 70      .prepare(
 71        `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count, error_message)
 72         VALUES (?, ?, ?, ?, ?, ?)`
 73      )
 74      .run(
 75        site.domain,
 76        site.landing_page_url,
 77        site.keyword,
 78        site.status,
 79        site.retry_count,
 80        site.error_message
 81      );
 82  
 83    return result.lastInsertRowid;
 84  }
 85  
 86  function getSite(siteId) {
 87    return db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
 88  }
 89  
 90  describe('retry-handler', () => {
 91    before(() => clearSites());
 92  
 93    describe('recordFailure', () => {
 94      it('should record first failure and set retry_count to 1', async () => {
 95        clearSites();
 96        const siteId = insertSite({ status: 'found', retry_count: 0 });
 97  
 98        const markedFailing = await recordFailure(siteId, 'assets', 'Connection timeout', 'found');
 99  
100        assert.equal(markedFailing, false, 'should not mark as failing on first retry');
101  
102        const site = getSite(siteId);
103        assert.equal(site.retry_count, 1);
104        assert.equal(site.error_message, 'Connection timeout');
105        assert.equal(site.status, 'found', 'should keep current status while retrying');
106        assert.ok(site.last_retry_at, 'should set last_retry_at timestamp');
107      });
108  
109      it('should increment retry count on subsequent failures', async () => {
110        clearSites();
111        const siteId = insertSite({
112          status: 'assets_captured',
113          retry_count: 1,
114          error_message: 'Previous error',
115        });
116  
117        const markedFailing = await recordFailure(
118          siteId,
119          'scoring',
120          'API rate limit exceeded',
121          'assets_captured'
122        );
123  
124        assert.equal(markedFailing, false);
125  
126        const site = getSite(siteId);
127        assert.equal(site.retry_count, 2);
128        assert.equal(site.error_message, 'API rate limit exceeded');
129        assert.equal(site.status, 'assets_captured');
130      });
131  
132      it('should mark as failing when max retries exceeded for assets (limit 3)', async () => {
133        clearSites();
134        const siteId = insertSite({
135          status: 'found',
136          retry_count: 2,
137        });
138  
139        // Third retry should trigger failing (retry_count goes from 2 to 3, limit is 3)
140        const markedFailing = await recordFailure(siteId, 'assets', 'Browser crash', 'found');
141  
142        assert.equal(markedFailing, true, 'should mark as failing at retry limit');
143  
144        const site = getSite(siteId);
145        assert.equal(site.retry_count, 3);
146        assert.equal(site.status, 'failing');
147        assert.ok(
148          site.error_message.includes('Max retries (3) exceeded'),
149          `error_message should include max retries info, got: ${site.error_message}`
150        );
151        assert.ok(
152          site.error_message.includes('Browser crash'),
153          'error_message should include original error'
154        );
155      });
156  
157      it('should mark as failing when max retries exceeded for proposals (limit 5)', async () => {
158        clearSites();
159        const siteId = insertSite({
160          status: 'enriched',
161          retry_count: 4,
162        });
163  
164        // Fifth retry should trigger failing (retry_count goes from 4 to 5, limit is 5)
165        const markedFailing = await recordFailure(
166          siteId,
167          'proposals',
168          'LLM generation failed',
169          'enriched'
170        );
171  
172        assert.equal(markedFailing, true, 'should mark as failing at retry limit');
173  
174        const site = getSite(siteId);
175        assert.equal(site.retry_count, 5);
176        assert.equal(site.status, 'failing');
177        assert.ok(
178          site.error_message.includes('Max retries (5) exceeded'),
179          `error_message should include max retries info, got: ${site.error_message}`
180        );
181      });
182  
183      it('should keep current status while retrying (not yet at limit)', async () => {
184        clearSites();
185        const siteId = insertSite({
186          status: 'prog_scored',
187          retry_count: 0,
188        });
189  
190        await recordFailure(siteId, 'rescoring', 'Temporary failure', 'prog_scored');
191  
192        const site = getSite(siteId);
193        assert.equal(site.status, 'prog_scored', 'status should remain at current stage');
194        assert.equal(site.retry_count, 1);
195      });
196  
197      it('should handle Error objects correctly', async () => {
198        clearSites();
199        const siteId = insertSite({ status: 'found' });
200        const error = new Error('ECONNREFUSED: connection refused');
201  
202        await recordFailure(siteId, 'assets', error, 'found');
203  
204        const site = getSite(siteId);
205        assert.equal(
206          site.error_message,
207          'ECONNREFUSED: connection refused',
208          'should extract message from Error object'
209        );
210      });
211  
212      it('should handle string errors correctly', async () => {
213        clearSites();
214        const siteId = insertSite({ status: 'found' });
215  
216        await recordFailure(siteId, 'assets', 'plain string error', 'found');
217  
218        const site = getSite(siteId);
219        assert.equal(site.error_message, 'plain string error');
220      });
221  
222      it('should handle non-string/non-Error values by converting to string', async () => {
223        clearSites();
224        const siteId = insertSite({ status: 'found' });
225  
226        await recordFailure(siteId, 'assets', 42, 'found');
227  
228        const site = getSite(siteId);
229        assert.equal(site.error_message, '42');
230      });
231  
232      it('should use default limit of 5 for unknown stages', async () => {
233        clearSites();
234        const siteId = insertSite({
235          status: 'found',
236          retry_count: 3,
237        });
238  
239        // Retry count goes from 3 to 4, default limit is 5 so should NOT fail yet
240        const markedFailing = await recordFailure(siteId, 'unknown_stage', 'some error', 'found');
241  
242        assert.equal(markedFailing, false, 'should not mark failing before default limit');
243  
244        const site = getSite(siteId);
245        assert.equal(site.retry_count, 4);
246        assert.equal(site.status, 'found');
247      });
248  
249      it('should mark as failing at default limit of 5 for unknown stages', async () => {
250        clearSites();
251        const siteId = insertSite({
252          status: 'found',
253          retry_count: 4,
254        });
255  
256        // Retry count goes from 4 to 5, default limit is 5 so should fail
257        const markedFailing = await recordFailure(
258          siteId,
259          'unknown_stage',
260          'persistent error',
261          'found'
262        );
263  
264        assert.equal(markedFailing, true, 'should mark failing at default limit');
265  
266        const site = getSite(siteId);
267        assert.equal(site.retry_count, 5);
268        assert.equal(site.status, 'failing');
269        assert.ok(site.error_message.includes('Max retries (5) exceeded'));
270      });
271  
272      it('should set last_retry_at timestamp when retrying', async () => {
273        clearSites();
274        const siteId = insertSite({ status: 'found' });
275  
276        await recordFailure(siteId, 'assets', 'timeout', 'found');
277  
278        const site = getSite(siteId);
279        assert.ok(site.last_retry_at, 'last_retry_at should be set');
280      });
281  
282      it('should set last_retry_at timestamp when marking as failing', async () => {
283        clearSites();
284        const siteId = insertSite({ status: 'found', retry_count: 2 });
285  
286        await recordFailure(siteId, 'assets', 'final failure', 'found');
287  
288        const site = getSite(siteId);
289        assert.ok(site.last_retry_at, 'last_retry_at should be set even when failing');
290      });
291    });
292  
293    describe('resetRetries', () => {
294      it('should reset retry_count to 0 and clear error_message', async () => {
295        clearSites();
296        const siteId = insertSite({
297          status: 'prog_scored',
298          retry_count: 2,
299          error_message: 'Previous error',
300        });
301  
302        // Ensure last_retry_at is set
303        await recordFailure(siteId, 'scoring', 'temp error', 'prog_scored');
304  
305        // Now reset
306        await resetRetries(siteId);
307  
308        const site = getSite(siteId);
309        assert.equal(site.retry_count, 0, 'retry_count should be reset to 0');
310        assert.equal(site.error_message, null, 'error_message should be cleared');
311        assert.equal(site.last_retry_at, null, 'last_retry_at should be cleared');
312      });
313  
314      it('should be idempotent on a site with no retries', async () => {
315        clearSites();
316        const siteId = insertSite({ status: 'found', retry_count: 0 });
317  
318        await resetRetries(siteId);
319  
320        const site = getSite(siteId);
321        assert.equal(site.retry_count, 0);
322        assert.equal(site.error_message, null);
323      });
324  
325      it('should not change the site status', async () => {
326        clearSites();
327        const siteId = insertSite({
328          status: 'assets_captured',
329          retry_count: 1,
330          error_message: 'some error',
331        });
332  
333        await resetRetries(siteId);
334  
335        const site = getSite(siteId);
336        assert.equal(site.status, 'assets_captured', 'status should remain unchanged');
337      });
338    });
339  
340    describe('getRetryStats', () => {
341      it('should return stats with no sites in database', async () => {
342        clearSites();
343        const stats = await getRetryStats();
344  
345        assert.equal(stats.failing_sites, 0);
346        assert.equal(stats.retrying_sites, 0);
347        // SQLite returns null for AVG over empty set
348        assert.ok(stats.avg_retry_count === null || stats.avg_retry_count === 0);
349        assert.ok(stats.max_retry_count === null || stats.max_retry_count === 0);
350      });
351  
352      it('should return stats with no retrying sites', async () => {
353        clearSites();
354        insertSite({ status: 'found', retry_count: 0 });
355        insertSite({ status: 'prog_scored', retry_count: 0 });
356        insertSite({ status: 'enriched', retry_count: 0 });
357  
358        const stats = await getRetryStats();
359  
360        assert.equal(stats.failing_sites, 0);
361        assert.equal(stats.retrying_sites, 0);
362        assert.ok(
363          stats.avg_retry_count === null || stats.avg_retry_count === 0,
364          'avg should be null or 0 when no sites have retries'
365        );
366        assert.equal(stats.max_retry_count, 0);
367      });
368  
369      it('should count failing sites correctly', async () => {
370        clearSites();
371        insertSite({ status: 'failing', retry_count: 3, error_message: 'Max retries exceeded' });
372        insertSite({ status: 'failing', retry_count: 5, error_message: 'Max retries exceeded' });
373        insertSite({ status: 'found', retry_count: 0 });
374  
375        const stats = await getRetryStats();
376  
377        assert.equal(stats.failing_sites, 2);
378        assert.equal(stats.retrying_sites, 0);
379      });
380  
381      it('should count retrying sites correctly (retry_count > 0 and not failing)', async () => {
382        clearSites();
383        insertSite({ status: 'found', retry_count: 1, error_message: 'Timeout' });
384        insertSite({ status: 'prog_scored', retry_count: 2, error_message: 'API error' });
385        insertSite({ status: 'found', retry_count: 0 });
386        insertSite({ status: 'failing', retry_count: 3, error_message: 'Max retries exceeded' });
387  
388        const stats = await getRetryStats();
389  
390        assert.equal(stats.retrying_sites, 2, 'should count sites with retries that are not failing');
391        assert.equal(stats.failing_sites, 1);
392      });
393  
394      it('should calculate avg and max retry counts correctly', async () => {
395        clearSites();
396        insertSite({ status: 'found', retry_count: 2, error_message: 'Error A' });
397        insertSite({ status: 'prog_scored', retry_count: 4, error_message: 'Error B' });
398        insertSite({ status: 'failing', retry_count: 6, error_message: 'Max retries' });
399        insertSite({ status: 'found', retry_count: 0 }); // no retries, excluded from avg
400  
401        const stats = await getRetryStats();
402  
403        // AVG of sites with retry_count > 0: (2 + 4 + 6) / 3 = 4
404        assert.equal(Number(stats.avg_retry_count), 4);
405        assert.equal(stats.max_retry_count, 6);
406      });
407  
408      it('should exclude ignore status sites from stats', async () => {
409        clearSites();
410        insertSite({ status: 'ignored', retry_count: 3, error_message: 'Blocked domain' });
411        insertSite({ status: 'found', retry_count: 1, error_message: 'Timeout' });
412  
413        const stats = await getRetryStats();
414  
415        assert.equal(stats.retrying_sites, 1, 'should not count ignore sites');
416        assert.equal(stats.failing_sites, 0);
417        assert.equal(stats.max_retry_count, 1);
418      });
419  
420      it('should exclude high_score status sites from stats', async () => {
421        clearSites();
422        insertSite({ status: 'high_score', retry_count: 2, error_message: 'Some error' });
423        insertSite({ status: 'failing', retry_count: 5, error_message: 'Max retries' });
424  
425        const stats = await getRetryStats();
426  
427        assert.equal(stats.failing_sites, 1, 'should not count high_score as failing');
428        assert.equal(stats.retrying_sites, 0, 'should not count high_score as retrying');
429        // Only the failing site's retry_count is included
430        assert.equal(stats.max_retry_count, 5);
431      });
432  
433      it('should exclude both ignore and high_score from all calculations', async () => {
434        clearSites();
435        insertSite({ status: 'ignored', retry_count: 10, error_message: 'Ignored' });
436        insertSite({ status: 'high_score', retry_count: 8, error_message: 'High score' });
437        insertSite({ status: 'found', retry_count: 0 });
438  
439        const stats = await getRetryStats();
440  
441        assert.equal(stats.failing_sites, 0);
442        assert.equal(stats.retrying_sites, 0);
443        assert.ok(
444          stats.avg_retry_count === null || stats.avg_retry_count === 0,
445          'should not include excluded sites in avg'
446        );
447        assert.equal(stats.max_retry_count, 0);
448      });
449    });
450  });