/ tests / utils / retry-handler-http.test.js
retry-handler-http.test.js
  1  /**
  2   * Retry Handler HTTP Long-Term Retry Path
  3   * Covers the httpLongTermRetryDays code path (lines 85-114):
  4   * HTTP 403/404 errors schedule long-term retries instead of permanent failure.
  5   *
  6   * Migrated to use db.js mock (pg-mock) — retry-handler.js now uses db.js (PostgreSQL).
  7   */
  8  
  9  import { describe, it, mock, beforeEach, afterEach } from 'node:test';
 10  import assert from 'node:assert/strict';
 11  import Database from 'better-sqlite3';
 12  import { createPgMock } from '../helpers/pg-mock.js';
 13  
 14  // Minimal schema for sites table
 15  const SCHEMA_SQL = `
 16    CREATE TABLE IF NOT EXISTS sites (
 17      id INTEGER PRIMARY KEY AUTOINCREMENT,
 18      domain TEXT NOT NULL,
 19      landing_page_url TEXT NOT NULL,
 20      keyword TEXT,
 21      status TEXT DEFAULT 'found',
 22      retry_count INTEGER DEFAULT 0,
 23      recapture_count INTEGER DEFAULT 0,
 24      error_message TEXT,
 25      last_retry_at DATETIME,
 26      recapture_at DATETIME
 27    );
 28  `;
 29  
 30  // Shared in-memory db — pg-mock routes all db.js calls here
 31  // Must be created before mock.module, and the same instance reused throughout
 32  // (mock.module can only be called once; createPgMock captures this db by ref in closures)
 33  const db = new Database(':memory:');
 34  db.exec(SCHEMA_SQL);
 35  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 36  
 37  // Dynamic import AFTER mock.module
 38  const { recordFailure, resetRetries, getRetryStats } = await import(
 39    '../../src/utils/retry-handler.js'
 40  );
 41  
 42  function insertSite(testDb, overrides = {}) {
 43    const defaults = {
 44      domain: 'example.com',
 45      landing_page_url: 'https://example.com',
 46      keyword: 'test',
 47      status: 'found',
 48      retry_count: 0,
 49      recapture_count: 0,
 50      error_message: null,
 51    };
 52    const site = { ...defaults, ...overrides };
 53    const result = testDb
 54      .prepare(
 55        `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count, recapture_count, error_message)
 56         VALUES (?, ?, ?, ?, ?, ?, ?)`
 57      )
 58      .run(
 59        site.domain,
 60        site.landing_page_url,
 61        site.keyword,
 62        site.status,
 63        site.retry_count,
 64        site.recapture_count,
 65        site.error_message
 66      );
 67    return result.lastInsertRowid;
 68  }
 69  
 70  describe('retry-handler HTTP long-term retry path', () => {
 71    beforeEach(() => { db.exec('DELETE FROM sites'); });
 72  
 73    it('schedules long-term retry for HTTP 403 at max retries', async () => {
 74      // assets stage limit is 3; retry_count=2 → 3rd failure triggers max retries check
 75      const siteId = insertSite(db, { status: 'found', retry_count: 2, recapture_count: 0 });
 76  
 77      const markedFailing = await recordFailure(
 78        siteId,
 79        'assets',
 80        'HTTP 403: Cannot capture page',
 81        'found'
 82      );
 83  
 84      assert.equal(markedFailing, false, 'HTTP 403 should NOT mark site as failing');
 85  
 86      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
 87      assert.equal(site.status, 'found', 'status should be reset to found');
 88      assert.equal(site.retry_count, 0, 'retry_count should be reset to 0');
 89      assert.equal(site.recapture_count, 1, 'recapture_count should be incremented');
 90      assert.ok(site.recapture_at, 'recapture_at should be scheduled');
 91      assert.ok(
 92        site.error_message.includes('HTTP retry 1/8'),
 93        `error_message should show retry counter, got: ${site.error_message}`
 94      );
 95    });
 96  
 97    it('schedules long-term retry for HTTP 404 at max retries', async () => {
 98      const siteId = insertSite(db, { status: 'found', retry_count: 2, recapture_count: 0 });
 99  
100      const markedFailing = await recordFailure(
101        siteId,
102        'assets',
103        'HTTP 404: Cannot capture page',
104        'found'
105      );
106  
107      assert.equal(markedFailing, false, 'HTTP 404 should NOT mark site as failing');
108  
109      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
110      assert.equal(site.status, 'found', 'status should be reset to found');
111      assert.ok(site.recapture_at, 'recapture_at should be scheduled');
112    });
113  
114    it('marks as failing when HTTP retries exhausted (> 8)', async () => {
115      // HTTP_MAX_LONG_RETRIES = 8; recapture_count=8 means we've already done 8 long-term retries
116      const siteId = insertSite(db, {
117        status: 'assets_captured',
118        retry_count: 2,
119        recapture_count: 8,
120      });
121  
122      const markedFailing = await recordFailure(
123        siteId,
124        'assets',
125        'HTTP 403: Cannot capture page',
126        'assets_captured'
127      );
128  
129      assert.equal(markedFailing, true, 'After 8 HTTP retries, should mark as failing');
130      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
131      assert.equal(site.status, 'failing', 'status should be failing after exhausting HTTP retries');
132    });
133  
134    it('increments recapture_count on each HTTP retry', async () => {
135      // Already had 3 long-term retries
136      const siteId = insertSite(db, { status: 'found', retry_count: 2, recapture_count: 3 });
137  
138      const markedFailing = await recordFailure(
139        siteId,
140        'assets',
141        'HTTP 403: Cannot capture page',
142        'found'
143      );
144  
145      assert.equal(markedFailing, false);
146      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
147      assert.equal(site.recapture_count, 4);
148      assert.ok(site.error_message.includes('HTTP retry 4/8'));
149    });
150  
151    it('does not trigger HTTP retry for non-HTTP errors', async () => {
152      // Non-HTTP error at max retries → permanent failure
153      const siteId = insertSite(db, { status: 'found', retry_count: 2 });
154  
155      const markedFailing = await recordFailure(
156        siteId,
157        'assets',
158        'Connection timeout: ECONNREFUSED',
159        'found'
160      );
161  
162      assert.equal(markedFailing, true, 'Non-HTTP error should mark as failing');
163      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
164      assert.equal(site.status, 'failing');
165    });
166  
167    it('increments retry_count for errors below max retries threshold', async () => {
168      const siteId = insertSite(db, { status: 'found', retry_count: 0 });
169  
170      const markedFailing = await recordFailure(
171        siteId,
172        'assets',
173        'HTTP 403: Cannot capture page',
174        'found'
175      );
176  
177      // Only 1 retry, max is 3 → still retrying
178      assert.equal(markedFailing, false);
179      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
180      assert.equal(site.retry_count, 1);
181      assert.equal(site.status, 'found');
182    });
183  });
184  
185  describe('retry-handler resetRetries and getRetryStats', () => {
186    beforeEach(() => {
187      db.exec('DELETE FROM sites');
188    });
189  
190    it('resetRetries clears retry_count and error_message', async () => {
191      const siteId = db
192        .prepare(
193          `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count, error_message)
194           VALUES ('test.com', 'https://test.com', 'test', 'found', 3, 'Some error')`
195        )
196        .run().lastInsertRowid;
197  
198      await resetRetries(siteId);
199  
200      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
201      assert.equal(site.retry_count, 0);
202      assert.equal(site.error_message, null);
203      assert.equal(site.last_retry_at, null);
204    });
205  
206    it('getRetryStats returns counts for failing and retrying sites', async () => {
207      // Insert a failing site
208      db.prepare(
209        `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count)
210         VALUES ('fail.com', 'https://fail.com', 'test', 'failing', 5)`
211      ).run();
212  
213      // Insert a retrying site
214      db.prepare(
215        `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count)
216         VALUES ('retry.com', 'https://retry.com', 'test', 'found', 2)`
217      ).run();
218  
219      const stats = await getRetryStats();
220      assert.equal(stats.failing_sites, 1);
221      assert.equal(stats.retrying_sites, 1);
222      assert.ok(stats.max_retry_count >= 5);
223    });
224  
225    it('getRetryStats returns zeros when no sites', async () => {
226      const stats = await getRetryStats();
227      assert.equal(stats.failing_sites, 0);
228      assert.equal(stats.retrying_sites, 0);
229    });
230  });