/ tests / utils / error-categories.test.js
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  });