/ tests / utils / error-categories-supplement.test.js
error-categories-supplement.test.js
  1  /**
  2   * Supplement tests for src/utils/error-categories.js
  3   *
  4   * Covers uncovered functions and branches:
  5   *   - computeRetryAt: correct intervals for each pattern
  6   *   - computeRetryAt: default 1-hour fallback
  7   *   - isOutreachRetriable: true for retriable, false for terminal
  8   *   - buildOutreachTree: basic shape returned
  9   *   - buildConversationsTree: basic shape returned
 10   *   - Additional outreach terminal/retriable pattern coverage
 11   *
 12   * Note: markOutreachResult was removed from error-categories.js (migrated to inline
 13   * async helpers in callers).  Those tests have been removed.
 14   */
 15  
 16  import { test, describe, mock } from 'node:test';
 17  import assert from 'node:assert/strict';
 18  import Database from 'better-sqlite3';
 19  import { createLazyPgMock } from '../helpers/pg-mock.js';
 20  
 21  // ─── Lazy DB reference — updated per-test ─────────────────────────────────────
 22  let activeDb = null;
 23  
 24  mock.module('../../src/utils/db.js', {
 25    namedExports: createLazyPgMock(() => activeDb),
 26  });
 27  
 28  mock.module('../../src/utils/logger.js', {
 29    defaultExport: class {
 30      info() {}
 31      warn() {}
 32      error() {}
 33      success() {}
 34      debug() {}
 35    },
 36  });
 37  
 38  const {
 39    categorizeError,
 40    computeRetryAt,
 41    isOutreachRetriable,
 42    buildOutreachTree,
 43    buildConversationsTree,
 44    buildStatusTree,
 45  } = await import('../../src/utils/error-categories.js');
 46  
 47  // ── computeRetryAt ────────────────────────────────────────────────────────────
 48  
 49  describe('computeRetryAt', () => {
 50    test('returns ISO datetime string', () => {
 51      const result = computeRetryAt('Some unknown error');
 52      assert.ok(typeof result === 'string');
 53      assert.ok(!isNaN(Date.parse(result)), 'Should be a parseable ISO datetime');
 54    });
 55  
 56    test('default fallback is approximately 1 hour from now', () => {
 57      const before = Date.now();
 58      const result = computeRetryAt('some novel error nobody has seen before');
 59      const after = Date.now();
 60      const ts = Date.parse(result);
 61      // Should be 1 hour (3600s) from now, within a 5-second window
 62      assert.ok(ts >= before + 3595000 && ts <= after + 3605000, 'Default should be ~1 hour');
 63    });
 64  
 65    test('rate limit (429) → ~1 hour', () => {
 66      const before = Date.now();
 67      const result = computeRetryAt('status code 429: too many requests');
 68      const ts = Date.parse(result);
 69      // 3600 seconds
 70      assert.ok(ts >= before + 3590000 && ts <= Date.now() + 3610000, 'Rate limit should be ~1h');
 71    });
 72  
 73    test('business hours → ~8 hours (28800s)', () => {
 74      const before = Date.now();
 75      const result = computeRetryAt('outside business hours - please retry during business hours');
 76      const ts = Date.parse(result);
 77      assert.ok(ts >= before + 28790000, 'Business hours should be ~8h');
 78    });
 79  
 80    test('per-recipient cooldown → ~72 hours (259200s)', () => {
 81      const before = Date.now();
 82      const result = computeRetryAt('per-recipient cooldown: too many sends');
 83      const ts = Date.parse(result);
 84      assert.ok(ts >= before + 259190000, 'Per-recipient cooldown should be ~72h');
 85    });
 86  
 87    test('null error → returns default 1-hour datetime', () => {
 88      const result = computeRetryAt(null);
 89      const ts = Date.parse(result);
 90      assert.ok(!isNaN(ts));
 91    });
 92  
 93    test('DNS failure (ERR_NAME_NOT_RESOLVED) → ~24 hours (86400s)', () => {
 94      const before = Date.now();
 95      const result = computeRetryAt('net::ERR_NAME_NOT_RESOLVED: could not resolve host');
 96      const ts = Date.parse(result);
 97      assert.ok(ts >= before + 86390000, 'DNS failure should be ~24h');
 98    });
 99  
100    test('browser crash → ~15 minutes (900s)', () => {
101      const before = Date.now();
102      const result = computeRetryAt('browser has been closed');
103      const ts = Date.parse(result);
104      assert.ok(ts >= before + 890000 && ts <= Date.now() + 910000, 'Browser crash should be ~15min');
105    });
106  
107    test('ECONNRESET → ~1 hour', () => {
108      const before = Date.now();
109      const result = computeRetryAt('ECONNRESET: connection reset by peer');
110      const ts = Date.parse(result);
111      assert.ok(ts >= before + 3590000, 'ECONNRESET should be ~1h');
112    });
113  });
114  
115  // ── isOutreachRetriable ───────────────────────────────────────────────────────
116  
117  describe('isOutreachRetriable', () => {
118    test('returns true for retriable errors', () => {
119      assert.equal(isOutreachRetriable('outside business hours'), true);
120      assert.equal(isOutreachRetriable('ECONNRESET: network error'), true);
121      assert.equal(isOutreachRetriable('Timeout during form submission'), true);
122      assert.equal(isOutreachRetriable('Breaker is open'), true);
123    });
124  
125    test('returns false for terminal errors', () => {
126      assert.equal(isOutreachRetriable('opted out of our messages'), false);
127      assert.equal(isOutreachRetriable('gdpr_blocked'), false);
128      assert.equal(isOutreachRetriable('Cloudflare blocked the request'), false);
129    });
130  
131    test('returns true for null (unknown error is retriable by default)', () => {
132      // null → categorizeError returns retriable (unknown no error stored)
133      assert.equal(isOutreachRetriable(null), true);
134    });
135  
136    test('returns true for unknown errors (group=unknown treated by categorizeError)', () => {
137      // An unknown error should NOT be retriable — its group is 'unknown', not 'retriable'
138      const { group } = categorizeError('Some unknown novel error', 'outreach');
139      // unknown → isOutreachRetriable returns false for unknown
140      assert.equal(group, 'unknown');
141      assert.equal(isOutreachRetriable('Some unknown novel error'), false);
142    });
143  });
144  
145  // ── Additional categorizeError patterns ───────────────────────────────────────
146  
147  describe('categorizeError — additional outreach patterns', () => {
148    test('ZeroBounce is terminal for outreach', () => {
149      const { group } = categorizeError('ZeroBounce: email invalid', 'outreach');
150      assert.equal(group, 'terminal');
151    });
152  
153    test('SMS region blocked is terminal', () => {
154      const { group } = categorizeError(
155        'Permission to send an SMS has not been enabled for the region AU',
156        'outreach'
157      );
158      assert.equal(group, 'terminal');
159    });
160  
161    test('landline number is retriable (for outreach)', () => {
162      const { group } = categorizeError('landline detected: cannot send SMS', 'outreach');
163      assert.equal(group, 'retriable');
164    });
165  
166    test('form page failed to load is retriable', () => {
167      const { group } = categorizeError('Form page failed to load: request timeout', 'outreach');
168      assert.equal(group, 'retriable');
169    });
170  
171    test('per-recipient cooldown is retriable', () => {
172      const { group } = categorizeError('per-recipient cooldown in effect', 'outreach');
173      assert.equal(group, 'retriable');
174    });
175  });
176  
177  describe('categorizeError — additional site terminal patterns', () => {
178    test('Home service franchise is terminal', () => {
179      const { group } = categorizeError('Home service franchise detected', 'site');
180      assert.equal(group, 'terminal');
181    });
182  
183    test('Government domain is terminal', () => {
184      const { group } = categorizeError('Government domain: gov.au', 'site');
185      assert.equal(group, 'terminal');
186    });
187  
188    test('Education domain is terminal', () => {
189      const { group } = categorizeError('Education domain: .edu', 'site');
190      assert.equal(group, 'terminal');
191    });
192  
193    test('Duplicate domain is terminal', () => {
194      const { group } = categorizeError('Duplicate domain: already exists', 'site');
195      assert.equal(group, 'terminal');
196    });
197  
198    test('Country mismatch is terminal', () => {
199      const { group } = categorizeError('Country mismatch: AU vs NZ', 'site');
200      assert.equal(group, 'terminal');
201    });
202  
203    test('Max recapture attempts is terminal', () => {
204      const { group } = categorizeError('Max recapture attempts reached', 'site');
205      assert.equal(group, 'terminal');
206    });
207  
208    test('HTTP 404 Cannot capture is terminal', () => {
209      const { group } = categorizeError('HTTP 404 Cannot capture assets: page gone', 'site');
210      assert.equal(group, 'terminal');
211    });
212  });
213  
214  // ── buildOutreachTree and buildConversationsTree ──────────────────────────────
215  
216  describe('buildOutreachTree', () => {
217    function createFullDb() {
218      const db = new Database(':memory:');
219      db.exec(`
220        CREATE TABLE sites (
221          id INTEGER PRIMARY KEY,
222          status TEXT,
223          domain TEXT DEFAULT 'example.com',
224          landing_page_url TEXT DEFAULT 'https://example.com',
225          country_code TEXT DEFAULT 'AU',
226          rescored_at DATETIME
227        );
228        CREATE TABLE messages (
229          id INTEGER PRIMARY KEY AUTOINCREMENT,
230          site_id INTEGER,
231          direction TEXT DEFAULT 'outbound',
232          contact_method TEXT DEFAULT 'email',
233          message_body TEXT,
234          delivery_status TEXT DEFAULT 'pending',
235          approval_status TEXT DEFAULT 'approved',
236          intent TEXT,
237          error_message TEXT,
238          retry_at DATETIME,
239          created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
240          updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
241          message_type TEXT DEFAULT 'outreach',
242          raw_payload TEXT,
243          read_at TEXT
244        );
245        CREATE TABLE conversations (
246          id INTEGER PRIMARY KEY AUTOINCREMENT,
247          site_id INTEGER,
248          channel TEXT,
249          status TEXT DEFAULT 'active',
250          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
251        );
252      `);
253      return db;
254    }
255  
256    test('returns array (possibly empty) for empty messages table', async () => {
257      activeDb = createFullDb();
258      const result = await buildOutreachTree();
259      assert.ok(Array.isArray(result), 'buildOutreachTree should return an array');
260      activeDb.close();
261      activeDb = null;
262    });
263  
264    test('returns non-empty array when messages exist', async () => {
265      activeDb = createFullDb();
266      activeDb.prepare(`INSERT INTO sites (id, status) VALUES (1, 'outreach_sent')`).run();
267      activeDb.prepare(
268        `
269        INSERT INTO messages (site_id, direction, contact_method, delivery_status, approval_status)
270        VALUES (1, 'outbound', 'email', 'sent', 'approved')
271      `
272      ).run();
273      activeDb.prepare(
274        `
275        INSERT INTO messages (site_id, direction, contact_method, delivery_status, approval_status)
276        VALUES (1, 'outbound', 'sms', 'delivered', 'approved')
277      `
278      ).run();
279  
280      const result = await buildOutreachTree();
281      assert.ok(Array.isArray(result));
282      // Result shape is implementation-dependent but should be an array
283      activeDb.close();
284      activeDb = null;
285    });
286  });
287  
288  describe('buildConversationsTree', () => {
289    function createConvDb() {
290      const db = new Database(':memory:');
291      db.exec(`
292        CREATE TABLE sites (
293          id INTEGER PRIMARY KEY,
294          status TEXT DEFAULT 'found',
295          domain TEXT DEFAULT 'example.com',
296          landing_page_url TEXT DEFAULT 'https://example.com',
297          country_code TEXT DEFAULT 'AU',
298          score REAL,
299          grade TEXT
300        );
301        CREATE TABLE messages (
302          id INTEGER PRIMARY KEY AUTOINCREMENT,
303          site_id INTEGER,
304          direction TEXT DEFAULT 'outbound',
305          contact_method TEXT DEFAULT 'email',
306          message_body TEXT,
307          delivery_status TEXT DEFAULT 'pending',
308          approval_status TEXT DEFAULT 'pending',
309          intent TEXT DEFAULT 'outreach',
310          message_type TEXT DEFAULT 'outreach',
311          sentiment TEXT,
312          created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
313          updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
314          raw_payload TEXT,
315          read_at TEXT
316        );
317      `);
318      return db;
319    }
320  
321    test('returns object with expected keys for empty DB', async () => {
322      activeDb = createConvDb();
323      const result = await buildConversationsTree();
324      // Result should be an object (tree structure) or array
325      assert.ok(result !== null && result !== undefined, 'Should return something non-null');
326      activeDb.close();
327      activeDb = null;
328    });
329  
330    test('handles inbound messages correctly', async () => {
331      activeDb = createConvDb();
332      activeDb.prepare(`INSERT INTO sites (id) VALUES (1)`).run();
333      activeDb.prepare(
334        `
335        INSERT INTO messages (site_id, direction, intent, message_type, delivery_status, approval_status)
336        VALUES (1, 'inbound', 'inquiry', 'reply', 'delivered', 'approved')
337      `
338      ).run();
339  
340      const result = await buildConversationsTree();
341      assert.ok(result !== null && result !== undefined);
342      activeDb.close();
343      activeDb = null;
344    });
345  });
346  
347  // ── buildStatusTree — additional coverage ─────────────────────────────────────
348  
349  describe('buildStatusTree — additional coverage', () => {
350    function createStatusDb() {
351      const db = new Database(':memory:');
352      db.exec(`
353        CREATE TABLE sites (
354          id INTEGER PRIMARY KEY AUTOINCREMENT,
355          status TEXT NOT NULL DEFAULT 'found',
356          error_message TEXT,
357          domain TEXT DEFAULT 'example.com',
358          updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
359        );
360        CREATE TABLE site_status (
361          id INTEGER PRIMARY KEY AUTOINCREMENT,
362          site_id INTEGER,
363          status TEXT,
364          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
365        );
366      `);
367      return db;
368    }
369  
370    test('returns array for empty sites table', async () => {
371      activeDb = createStatusDb();
372      const result = await buildStatusTree();
373      assert.ok(Array.isArray(result), 'buildStatusTree should return array');
374      activeDb.close();
375      activeDb = null;
376    });
377  
378    test('includes delta fields in result rows', async () => {
379      activeDb = createStatusDb();
380      activeDb.prepare(`INSERT INTO sites (status) VALUES ('found')`).run();
381      activeDb.prepare(`INSERT INTO sites (status) VALUES ('found')`).run();
382      activeDb.prepare(`INSERT INTO sites (status) VALUES ('failing')`).run();
383  
384      const result = await buildStatusTree();
385      assert.ok(Array.isArray(result));
386      if (result.length > 0) {
387        const row = result[0];
388        assert.ok('status' in row || 'total' in row, 'Result rows should have status or total');
389      }
390      activeDb.close();
391      activeDb = null;
392    });
393  });