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 });