error-categories.test.js
1 import { test, describe, mock } from 'node:test'; 2 import assert from 'node:assert/strict'; 3 import Database from 'better-sqlite3'; 4 import { createLazyPgMock } from '../helpers/pg-mock.js'; 5 6 // ─── Lazy DB reference — updated per-test ───────────────────────────────────── 7 // Each test that exercises buildStatusTree / buildOutreachTree / buildConversationsTree 8 // creates a fresh in-memory SQLite db and assigns it here. The lazy pg-mock reads 9 // `activeDb` on every SQL call so each test sees its own isolated data. 10 let activeDb = null; 11 12 mock.module('../../src/utils/db.js', { 13 namedExports: createLazyPgMock(() => activeDb), 14 }); 15 16 mock.module('../../src/utils/logger.js', { 17 defaultExport: class { 18 info() {} 19 warn() {} 20 error() {} 21 success() {} 22 debug() {} 23 }, 24 }); 25 26 const { 27 categorizeError, 28 buildStatusTree, 29 buildOutreachTree, 30 buildConversationsTree, 31 computeRetryAt, 32 isOutreachRetriable, 33 } = await import('../../src/utils/error-categories.js'); 34 35 // ────────────────────────────────────────────────────────────────────────────── 36 // categorizeError tests 37 // ────────────────────────────────────────────────────────────────────────────── 38 39 describe('categorizeError — site context', () => { 40 test('null returns retriable unknown', () => { 41 const result = categorizeError(null, 'site'); 42 assert.equal(result.group, 'retriable'); 43 assert.ok(result.label.toLowerCase().includes('unknown')); 44 }); 45 46 test('empty string returns retriable unknown', () => { 47 const result = categorizeError('', 'site'); 48 assert.equal(result.group, 'retriable'); 49 }); 50 51 test('EACCES is retriable', () => { 52 const result = categorizeError('EACCES: permission denied on /run/media/jason/store', 'site'); 53 assert.equal(result.group, 'retriable'); 54 assert.equal(result.label, 'Permission denied'); 55 }); 56 57 test('userDataDir is retriable', () => { 58 const result = categorizeError('Error: userDataDir is already in use', 'site'); 59 assert.equal(result.group, 'retriable'); 60 assert.equal(result.label, 'Browser launch conflict'); 61 }); 62 63 test('launchPersistentContext is retriable', () => { 64 const result = categorizeError('Error: launchPersistentContext failed', 'site'); 65 assert.equal(result.group, 'retriable'); 66 assert.equal(result.label, 'Browser launch conflict'); 67 }); 68 69 test('Social media platform is terminal', () => { 70 const result = categorizeError('Social media platform - not a local business', 'site'); 71 assert.equal(result.group, 'terminal'); 72 assert.equal(result.label, 'Social media'); 73 }); 74 75 test('Business directory is terminal', () => { 76 const result = categorizeError('Business directory site detected', 'site'); 77 assert.equal(result.group, 'terminal'); 78 assert.equal(result.label, 'Business directory'); 79 }); 80 81 test('Cross-border duplicate is terminal', () => { 82 const result = categorizeError('Cross-border duplicate: same domain in AU and US', 'site'); 83 assert.equal(result.group, 'terminal'); 84 assert.equal(result.label, 'Cross-border duplicate'); 85 }); 86 87 test('Timeout is retriable', () => { 88 const result = categorizeError('Timed out waiting for selector', 'site'); 89 assert.equal(result.group, 'retriable'); 90 assert.equal(result.label, 'Timeout'); 91 }); 92 93 test('ECONNRESET is retriable network error', () => { 94 const result = categorizeError('read ECONNRESET', 'site'); 95 assert.equal(result.group, 'retriable'); 96 assert.equal(result.label, 'Network error'); 97 }); 98 99 test('database is locked is retriable', () => { 100 const result = categorizeError('database is locked', 'site'); 101 assert.equal(result.group, 'retriable'); 102 assert.equal(result.label, 'DB lock'); 103 }); 104 105 test('unknown error returns unknown group', () => { 106 const result = categorizeError('Some completely novel error xyz123', 'site'); 107 assert.equal(result.group, 'unknown'); 108 assert.equal(result.label, 'Unknown'); 109 }); 110 }); 111 112 describe('categorizeError — outreach context', () => { 113 test('opted out is terminal', () => { 114 const result = categorizeError('recipient has opted out', 'outreach'); 115 assert.equal(result.group, 'terminal'); 116 assert.equal(result.label, 'Opted out'); 117 }); 118 119 test('business hours is retriable', () => { 120 const result = categorizeError('SMS blocked: outside business hours (8am-9pm)', 'outreach'); 121 assert.equal(result.group, 'retriable'); 122 assert.equal(result.label, 'Business hours block'); 123 }); 124 125 test('gdpr_blocked is terminal', () => { 126 const result = categorizeError('gdpr_blocked', 'outreach'); 127 assert.equal(result.group, 'terminal'); 128 assert.equal(result.label, 'GDPR blocked'); 129 }); 130 131 test('null error is unknown no error stored', () => { 132 const result = categorizeError(null, 'outreach'); 133 assert.equal(result.group, 'retriable'); 134 assert.ok(result.label.includes('Unknown')); 135 }); 136 }); 137 138 // ────────────────────────────────────────────────────────────────────────────── 139 // buildStatusTree / buildOutreachTree with in-memory DB 140 // ────────────────────────────────────────────────────────────────────────────── 141 142 function createTestDb() { 143 const db = new Database(':memory:'); 144 db.exec(` 145 CREATE TABLE sites ( 146 id INTEGER PRIMARY KEY, 147 status TEXT NOT NULL, 148 error_message TEXT, 149 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 150 rescored_at DATETIME 151 ); 152 -- site_status transition log — used by buildStatusTree for delta queries 153 CREATE TABLE site_status ( 154 id INTEGER PRIMARY KEY, 155 site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, 156 status TEXT NOT NULL, 157 error_message TEXT, 158 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 159 ); 160 CREATE TABLE messages ( 161 id INTEGER PRIMARY KEY, 162 site_id INTEGER, 163 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 164 approval_status TEXT, 165 delivery_status TEXT, 166 contact_method TEXT, 167 contact_uri TEXT, 168 sentiment TEXT, 169 intent TEXT, 170 error_message TEXT, 171 retry_at TEXT, 172 sent_at TEXT, 173 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 174 delivered_at DATETIME, 175 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 176 read_at TEXT, 177 message_type TEXT DEFAULT 'outreach', 178 raw_payload TEXT 179 ); 180 `); 181 return db; 182 } 183 184 /** 185 * Insert sites and matching site_status entries in one call so delta queries work. 186 */ 187 function insertSites(db, rows) { 188 for (const row of rows) { 189 const r = db 190 .prepare( 191 `INSERT INTO sites (status, error_message, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)` 192 ) 193 .run(row.status, row.error_message ?? null); 194 db.prepare( 195 `INSERT INTO site_status (site_id, status, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)` 196 ).run(r.lastInsertRowid, row.status); 197 } 198 } 199 200 describe('buildStatusTree', () => { 201 test('returns array ordered by pipeline status', async () => { 202 activeDb = createTestDb(); 203 insertSites(activeDb, [ 204 { status: 'found' }, 205 { status: 'found' }, 206 { status: 'failing' }, 207 { status: 'ignored' }, 208 { status: 'failing', error_message: 'EACCES: permission denied' }, 209 { status: 'ignored', error_message: 'Social media platform' }, 210 ]); 211 212 const tree = await buildStatusTree(); 213 assert.ok(Array.isArray(tree)); 214 assert.ok(tree.length > 0); 215 216 // found should appear before failing in output 217 const foundIdx = tree.findIndex(r => r.status === 'found'); 218 const failingIdx = tree.findIndex(r => r.status === 'failing'); 219 assert.ok(foundIdx < failingIdx, 'found should appear before failing'); 220 221 const foundRow = tree.find(r => r.status === 'found'); 222 assert.equal(foundRow.total, 2); 223 224 activeDb.close(); 225 activeDb = null; 226 }); 227 228 test('failing status has error children', async () => { 229 activeDb = createTestDb(); 230 insertSites(activeDb, [ 231 { status: 'failing', error_message: 'EACCES: permission denied' }, 232 { status: 'failing', error_message: 'EACCES: permission denied' }, 233 { status: 'failing', error_message: 'Social media platform' }, 234 ]); 235 236 const tree = await buildStatusTree(); 237 const failingRow = tree.find(r => r.status === 'failing'); 238 assert.ok(failingRow); 239 assert.ok(failingRow.children); 240 assert.equal(failingRow.children.type, 'errors'); 241 assert.ok(failingRow.children.retriable.length > 0 || failingRow.children.terminal.length > 0); 242 243 activeDb.close(); 244 activeDb = null; 245 }); 246 247 test('delta columns are present', async () => { 248 activeDb = createTestDb(); 249 insertSites(activeDb, [{ status: 'found' }]); 250 const tree = await buildStatusTree(); 251 const row = tree.find(r => r.status === 'found'); 252 assert.ok(row); 253 assert.ok('delta_24h' in row); 254 assert.ok('delta_1h' in row); 255 256 activeDb.close(); 257 activeDb = null; 258 }); 259 }); 260 261 describe('buildOutreachTree', () => { 262 test('returns array with outreach statuses', async () => { 263 activeDb = createTestDb(); 264 activeDb.exec(` 265 INSERT INTO messages (delivery_status, contact_method) VALUES 266 ('approved', 'sms'), ('approved', 'email'), ('sent', 'sms'), ('failed', 'email'); 267 INSERT INTO messages (delivery_status, contact_method, error_message) VALUES 268 ('failed', 'sms', 'outside business hours'); 269 `); 270 271 const tree = await buildOutreachTree(); 272 assert.ok(Array.isArray(tree)); 273 const approvedRow = tree.find(r => r.status === 'approved'); 274 assert.ok(approvedRow); 275 assert.equal(approvedRow.total, 2); 276 277 // approved should have channel children 278 assert.ok(approvedRow.children); 279 assert.equal(approvedRow.children.type, 'channels'); 280 281 activeDb.close(); 282 activeDb = null; 283 }); 284 285 test('failed status has error children', async () => { 286 activeDb = createTestDb(); 287 activeDb.exec(` 288 INSERT INTO messages (delivery_status, contact_method, error_message) VALUES 289 ('failed', 'sms', 'outside business hours'), 290 ('failed', 'email', 'gdpr_blocked'); 291 `); 292 293 const tree = await buildOutreachTree(); 294 const failedRow = tree.find(r => r.status === 'failed'); 295 assert.ok(failedRow); 296 assert.ok(failedRow.children); 297 assert.equal(failedRow.children.type, 'errors'); 298 299 activeDb.close(); 300 activeDb = null; 301 }); 302 303 test('retry_later status has error children and retryStats', async () => { 304 activeDb = createTestDb(); 305 activeDb.exec(` 306 INSERT INTO messages (delivery_status, contact_method, error_message, retry_at) VALUES 307 ('retry_later', 'email', 'Rate limited', datetime('now', '+1 hour')), 308 ('retry_later', 'sms', 'Twilio error', datetime('now', '+2 hours')); 309 `); 310 311 const tree = await buildOutreachTree(); 312 const retryRow = tree.find(r => r.status === 'retry_later'); 313 assert.ok(retryRow, 'Should have retry_later row'); 314 assert.ok(retryRow.children, 'Should have children'); 315 assert.equal(retryRow.children.type, 'errors'); 316 // retryStats should be populated 317 assert.ok(retryRow.retryStats !== undefined, 'Should have retryStats'); 318 319 activeDb.close(); 320 activeDb = null; 321 }); 322 323 test('unknown status is appended to tree', async () => { 324 activeDb = createTestDb(); 325 // Insert a message with a delivery_status that isn't in DELIVERY_STATUS_ORDER 326 activeDb.exec(` 327 INSERT INTO messages (delivery_status, contact_method) VALUES 328 ('custom_unknown_status', 'email'); 329 `); 330 331 const tree = await buildOutreachTree(); 332 const unknownRow = tree.find(r => r.status === 'custom_unknown_status'); 333 assert.ok(unknownRow, 'Unknown status should be appended to tree'); 334 assert.equal(unknownRow.total, 1); 335 assert.equal(unknownRow.children, null); 336 337 activeDb.close(); 338 activeDb = null; 339 }); 340 }); 341 342 describe('buildConversationsTree', () => { 343 test('returns empty array when no inbound messages', async () => { 344 activeDb = createTestDb(); 345 const tree = await buildConversationsTree(); 346 assert.ok(Array.isArray(tree)); 347 assert.equal(tree.length, 0); 348 activeDb.close(); 349 activeDb = null; 350 }); 351 352 test('groups inbound messages by sentiment with intent children', async () => { 353 activeDb = createTestDb(); 354 activeDb.exec(` 355 INSERT INTO messages (direction, sentiment, intent) VALUES 356 ('inbound', 'positive', 'interested'), 357 ('inbound', 'positive', 'interested'), 358 ('inbound', 'positive', 'pricing'), 359 ('inbound', 'negative', 'not-interested'), 360 ('inbound', 'neutral', 'inquiry'); 361 `); 362 363 const tree = await buildConversationsTree(); 364 assert.ok(Array.isArray(tree)); 365 366 const positiveRow = tree.find(r => r.sentiment === 'positive'); 367 assert.ok(positiveRow, 'Should have positive row'); 368 assert.equal(positiveRow.total, 3); 369 assert.ok(Array.isArray(positiveRow.intents)); 370 // interested should be first (highest count) 371 assert.equal(positiveRow.intents[0].label, 'interested'); 372 assert.equal(positiveRow.intents[0].total, 2); 373 374 const negativeRow = tree.find(r => r.sentiment === 'negative'); 375 assert.ok(negativeRow); 376 assert.equal(negativeRow.total, 1); 377 378 activeDb.close(); 379 activeDb = null; 380 }); 381 382 test('SENTIMENT_ORDER puts positive before neutral before negative', async () => { 383 activeDb = createTestDb(); 384 activeDb.exec(` 385 INSERT INTO messages (direction, sentiment, intent) VALUES 386 ('inbound', 'negative', 'not-interested'), 387 ('inbound', 'neutral', 'inquiry'), 388 ('inbound', 'positive', 'interested'); 389 `); 390 391 const tree = await buildConversationsTree(); 392 const sentiments = tree.map(r => r.sentiment); 393 const positiveIdx = sentiments.indexOf('positive'); 394 const neutralIdx = sentiments.indexOf('neutral'); 395 const negativeIdx = sentiments.indexOf('negative'); 396 397 assert.ok(positiveIdx < neutralIdx, 'positive should come before neutral'); 398 assert.ok(neutralIdx < negativeIdx, 'neutral should come before negative'); 399 400 activeDb.close(); 401 activeDb = null; 402 }); 403 404 test('unknown sentiment is appended at end of tree', async () => { 405 activeDb = createTestDb(); 406 activeDb.exec(` 407 INSERT INTO messages (direction, sentiment, intent) VALUES 408 ('inbound', 'positive', 'interested'), 409 ('inbound', 'mystery_sentiment', 'unknown'); 410 `); 411 412 const tree = await buildConversationsTree(); 413 const sentiments = tree.map(r => r.sentiment); 414 const positiveIdx = sentiments.indexOf('positive'); 415 const mysteryIdx = sentiments.indexOf('mystery_sentiment'); 416 417 assert.ok(positiveIdx >= 0, 'positive should be in tree'); 418 assert.ok(mysteryIdx >= 0, 'unknown sentiment should be appended'); 419 assert.ok(positiveIdx < mysteryIdx, 'known sentiments before unknown'); 420 421 activeDb.close(); 422 activeDb = null; 423 }); 424 425 test('ignores outbound messages', async () => { 426 activeDb = createTestDb(); 427 activeDb.exec(` 428 INSERT INTO messages (direction, sentiment, intent) VALUES 429 ('outbound', 'positive', 'interested'), 430 ('inbound', 'negative', 'not-interested'); 431 `); 432 433 const tree = await buildConversationsTree(); 434 const positiveRow = tree.find(r => r.sentiment === 'positive'); 435 assert.equal( 436 positiveRow, 437 undefined, 438 'Outbound messages should not appear in conversations tree' 439 ); 440 441 const negativeRow = tree.find(r => r.sentiment === 'negative'); 442 assert.ok(negativeRow, 'Inbound messages should appear'); 443 444 activeDb.close(); 445 activeDb = null; 446 }); 447 }); 448 449 // ────────────────────────────────────────────────────────────────────────────── 450 // computeRetryAt 451 // ────────────────────────────────────────────────────────────────────────────── 452 453 describe('computeRetryAt', () => { 454 test('returns an ISO datetime string in the future', () => { 455 const before = Date.now(); 456 const result = computeRetryAt('some network error'); 457 const after = Date.now(); 458 assert.ok(typeof result === 'string', 'Should return a string'); 459 const ts = new Date(result).getTime(); 460 assert.ok(ts > before, 'Should be in the future'); 461 // Default is 1 hour = 3600000ms 462 assert.ok(ts <= after + 3600 * 1000 + 5000, 'Should be within 1h + 5s buffer'); 463 }); 464 465 test('returns sooner retry for rate limit errors', () => { 466 // Retry intervals for rate limits should be shorter than the 1-hour default 467 const defaultTs = new Date(computeRetryAt('random error')).getTime(); 468 const rateTs = new Date(computeRetryAt('429 Too Many Requests')).getTime(); 469 // Both should be in the future; rate limit retry <= default retry 470 assert.ok(rateTs <= defaultTs, 'Rate limit retry should not exceed default 1h'); 471 }); 472 473 test('handles null input gracefully', () => { 474 const result = computeRetryAt(null); 475 assert.ok(typeof result === 'string'); 476 assert.ok(new Date(result).getTime() > Date.now()); 477 }); 478 }); 479 480 // ────────────────────────────────────────────────────────────────────────────── 481 // isOutreachRetriable 482 // ────────────────────────────────────────────────────────────────────────────── 483 484 describe('isOutreachRetriable', () => { 485 test('returns true for retriable errors (rate limit)', () => { 486 // "status code 429" matches the rate limit pattern 487 assert.strictEqual(isOutreachRetriable('status code 429: rate limit exceeded'), true); 488 }); 489 490 test('returns true for retriable errors (timeout)', () => { 491 assert.strictEqual(isOutreachRetriable('Timeout: page took too long'), true); 492 }); 493 494 test('returns false for terminal errors', () => { 495 // Invalid/bad phone = terminal failure, not retriable 496 assert.strictEqual(isOutreachRetriable('Invalid E.164 phone number format'), false); 497 }); 498 499 test('handles null gracefully', () => { 500 // null categorizes as unknown/retriable 501 const result = isOutreachRetriable(null); 502 assert.ok(typeof result === 'boolean'); 503 }); 504 }); 505 506 // ────────────────────────────────────────────────────────────────────────────── 507 // buildStatusTree — unknown status branch 508 // ────────────────────────────────────────────────────────────────────────────── 509 510 describe('buildStatusTree — unknown status', () => { 511 test('appends unknown site statuses at end of tree', async () => { 512 activeDb = createTestDb(); 513 insertSites(activeDb, [{ status: 'custom_unknown_pipeline_status' }]); 514 const tree = await buildStatusTree(); 515 const unknownRow = tree.find(r => r.status === 'custom_unknown_pipeline_status'); 516 assert.ok(unknownRow, 'Unknown site status should be appended'); 517 assert.strictEqual(unknownRow.total, 1); 518 assert.strictEqual(unknownRow.cumulative, null); 519 assert.strictEqual(unknownRow.children, null); 520 activeDb.close(); 521 activeDb = null; 522 }); 523 });