retry-handler-supplement.test.js
1 /** 2 * Retry Handler Supplement - Circuit Breaker Retry Path 3 * Covers lines 50-67: when max retries exceeded AND error is circuit breaker, 4 * site keeps current status and gets recapture_at scheduled (returns false, not true). 5 */ 6 7 import { describe, it, mock } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import Database from 'better-sqlite3'; 10 import { createPgMock } from '../helpers/pg-mock.js'; 11 12 // ─── In-memory test DB ──────────────────────────────────────────────────────── 13 14 const db = new Database(':memory:'); 15 db.exec(` 16 CREATE TABLE sites ( 17 id INTEGER PRIMARY KEY AUTOINCREMENT, 18 domain TEXT DEFAULT 'example.com', 19 landing_page_url TEXT DEFAULT 'https://example.com', 20 keyword TEXT DEFAULT 'test', 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 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 28 ); 29 `); 30 31 mock.module('../../src/utils/db.js', { 32 namedExports: createPgMock(db), 33 }); 34 35 mock.module('../../src/utils/logger.js', { 36 defaultExport: class { 37 info() {} 38 warn() {} 39 error() {} 40 success() {} 41 debug() {} 42 }, 43 }); 44 45 const { recordFailure } = await import('../../src/utils/retry-handler.js'); 46 47 function clearSites() { 48 db.prepare('DELETE FROM sites').run(); 49 } 50 51 function insertSite(overrides = {}) { 52 const defaults = { 53 domain: 'example.com', 54 landing_page_url: 'https://example.com', 55 keyword: 'test', 56 status: 'found', 57 retry_count: 0, 58 error_message: null, 59 }; 60 const site = { ...defaults, ...overrides }; 61 const result = db 62 .prepare( 63 `INSERT INTO sites (domain, landing_page_url, keyword, status, retry_count, error_message) 64 VALUES (?, ?, ?, ?, ?, ?)` 65 ) 66 .run( 67 site.domain, 68 site.landing_page_url, 69 site.keyword, 70 site.status, 71 site.retry_count, 72 site.error_message 73 ); 74 return result.lastInsertRowid; 75 } 76 77 function getSite(siteId) { 78 return db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 79 } 80 81 describe('retry-handler circuit breaker path', () => { 82 it('should schedule circuit breaker retry instead of marking as failing when breaker is open', async () => { 83 clearSites(); 84 // At max retries (2 → 3 for assets stage limit of 3) with a circuit breaker error 85 const siteId = insertSite({ status: 'assets_captured', retry_count: 2 }); 86 87 const markedFailing = await recordFailure( 88 siteId, 89 'assets', 90 'Breaker is open', 91 'assets_captured' 92 ); 93 94 // Should return false (not marked as failing) because it's a circuit breaker error 95 assert.equal(markedFailing, false, 'circuit breaker errors should not mark site as failing'); 96 97 const site = getSite(siteId); 98 // Status should remain at current stage 99 assert.equal(site.status, 'assets_captured', 'status should remain unchanged'); 100 // Error message should mention circuit breaker retry 101 assert.ok( 102 site.error_message.includes('Circuit breaker retry in 1h'), 103 `error_message should mention circuit breaker retry, got: ${site.error_message}` 104 ); 105 assert.ok( 106 site.error_message.includes('Breaker is open'), 107 `error_message should include original error, got: ${site.error_message}` 108 ); 109 // recapture_at should be set 110 assert.ok(site.recapture_at, 'recapture_at should be scheduled'); 111 }); 112 113 it('should handle EOPENBREAKER error as circuit breaker', async () => { 114 clearSites(); 115 const siteId = insertSite({ status: 'prog_scored', retry_count: 2 }); 116 117 const markedFailing = await recordFailure(siteId, 'assets', 'EOPENBREAKER', 'prog_scored'); 118 119 assert.equal(markedFailing, false); 120 const site = getSite(siteId); 121 assert.equal(site.status, 'prog_scored'); 122 assert.ok(site.error_message.includes('Circuit breaker retry in 1h')); 123 }); 124 125 it('should handle "circuit open" error as circuit breaker', async () => { 126 clearSites(); 127 const siteId = insertSite({ status: 'enriched', retry_count: 4 }); 128 129 // proposals stage has limit 5, so at retry_count=4, the 5th failure triggers max retries 130 const markedFailing = await recordFailure( 131 siteId, 132 'proposals', 133 'circuit is open error', 134 'enriched' 135 ); 136 137 assert.equal(markedFailing, false, 'should not fail - circuit breaker retry instead'); 138 const site = getSite(siteId); 139 assert.equal(site.status, 'enriched'); 140 assert.ok(site.recapture_at, 'recapture_at should be scheduled for retry in 1h'); 141 // retry_count is preserved (not reset) on circuit breaker retry — failure count is kept for tracking 142 assert.equal(site.retry_count, 4, 'retry_count should be preserved on circuit breaker retry'); 143 }); 144 });