/ tests / utils / retry-handler-supplement.test.js
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  });