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