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