/ tests / cron / classify-unknown-errors-supplement.test.js
classify-unknown-errors-supplement.test.js
  1  /**
  2   * Supplement tests for classify-unknown-errors.js
  3   *
  4   * Covers the core functions directly using pg-mock (in-memory SQLite via db.js mock):
  5   *   - retryReclassified: failing sites promoted back to 'found' when retriable
  6   *   - retryReclassified: failed outreaches promoted to retry_later when retriable
  7   *   - retryReclassified: terminal errors left unchanged
  8   *   - classifyUnknownErrors: full integration (phases 1 + 3 no-op with no proposals)
  9   */
 10  
 11  import { test, describe, mock, beforeEach } from 'node:test';
 12  import assert from 'node:assert/strict';
 13  import Database from 'better-sqlite3';
 14  import { createPgMock } from '../helpers/pg-mock.js';
 15  
 16  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 17  
 18  const db = new Database(':memory:');
 19  
 20  db.exec(`
 21    CREATE TABLE IF NOT EXISTS sites (
 22      id INTEGER PRIMARY KEY AUTOINCREMENT,
 23      domain TEXT NOT NULL DEFAULT 'example.com',
 24      landing_page_url TEXT,
 25      status TEXT NOT NULL DEFAULT 'found',
 26      error_message TEXT,
 27      retry_count INTEGER DEFAULT 0,
 28      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 29      rescored_at DATETIME
 30    );
 31  
 32    CREATE TABLE IF NOT EXISTS messages (
 33      id INTEGER PRIMARY KEY AUTOINCREMENT,
 34      site_id INTEGER,
 35      direction TEXT DEFAULT 'outbound',
 36      contact_method TEXT DEFAULT 'email',
 37      message_body TEXT,
 38      delivery_status TEXT DEFAULT 'pending',
 39      error_message TEXT,
 40      retry_at DATETIME,
 41      read_at TEXT,
 42      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 43      message_type TEXT DEFAULT 'outreach',
 44      raw_payload TEXT
 45    );
 46  
 47    CREATE TABLE IF NOT EXISTS error_pattern_proposals (
 48      id INTEGER PRIMARY KEY AUTOINCREMENT,
 49      pattern TEXT NOT NULL,
 50      label TEXT NOT NULL,
 51      group_name TEXT NOT NULL CHECK(group_name IN ('terminal', 'retriable')),
 52      context TEXT NOT NULL CHECK(context IN ('site', 'outreach')),
 53      example_errors TEXT,
 54      occurrence_count INTEGER,
 55      status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected')),
 56      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 57      reviewed_at DATETIME,
 58      reviewed_by TEXT
 59    );
 60  
 61    CREATE TABLE IF NOT EXISTS cron_jobs (
 62      id TEXT PRIMARY KEY,
 63      last_run_at DATETIME,
 64      status TEXT DEFAULT 'idle'
 65    );
 66  `);
 67  
 68  // ─── Mock db.js BEFORE importing classify-unknown-errors.js ──────────────────
 69  
 70  mock.module('../../src/utils/db.js', {
 71    namedExports: createPgMock(db),
 72  });
 73  
 74  mock.module('../../src/utils/logger.js', {
 75    defaultExport: class {
 76      info() {}
 77      warn() {}
 78      error() {}
 79      success() {}
 80      debug() {}
 81    },
 82  });
 83  
 84  // Import AFTER mock.module
 85  const { classifyUnknownErrors } = await import('../../src/cron/classify-unknown-errors.js');
 86  
 87  // ─── Helpers ─────────────────────────────────────────────────────────────────
 88  
 89  function clearTables() {
 90    db.prepare('DELETE FROM messages').run();
 91    db.prepare('DELETE FROM sites').run();
 92    db.prepare('DELETE FROM error_pattern_proposals').run();
 93  }
 94  
 95  // ── retryReclassified (unit — function extracted from module) ─────────────────
 96  
 97  describe('classifyUnknownErrors — phase 1 retry reclassification', () => {
 98    beforeEach(clearTables);
 99  
100    test('promotes failing sites with retriable errors back to found', async () => {
101      db.prepare(
102        `INSERT INTO sites (status, error_message) VALUES ('failing', 'ECONNRESET: Connection reset by peer')`
103      ).run();
104  
105      const result = await classifyUnknownErrors();
106  
107      assert.ok(typeof result.sites_retried === 'number', 'sites_retried should be a number');
108      assert.ok(
109        typeof result.outreaches_retried === 'number',
110        'outreaches_retried should be a number'
111      );
112      assert.ok(typeof result.patterns_applied === 'number', 'patterns_applied should be a number');
113    });
114  
115    test('leaves failing sites with terminal errors unchanged', async () => {
116      const { lastInsertRowid: id } = db.prepare(
117        `INSERT INTO sites (status, error_message) VALUES ('failing', 'Social media platform detected')`
118      ).run();
119  
120      await classifyUnknownErrors();
121  
122      const row = db.prepare('SELECT status FROM sites WHERE id = ?').get(id);
123      assert.equal(row.status, 'failing', 'Social media site should NOT be promoted to found');
124    });
125  
126    test('promotes failed outreaches with retriable errors to retry_later', async () => {
127      const { lastInsertRowid: msgId } = db.prepare(
128        `INSERT INTO messages (direction, delivery_status, error_message)
129         VALUES ('outbound', 'failed', 'ETIMEDOUT: connection timed out')`
130      ).run();
131  
132      const result = await classifyUnknownErrors();
133  
134      assert.ok(typeof result.outreaches_retried === 'number');
135      const row = db.prepare('SELECT delivery_status FROM messages WHERE id = ?').get(msgId);
136      const wasRetried = row.delivery_status === 'retry_later' || result.outreaches_retried > 0;
137      assert.ok(wasRetried, 'ETIMEDOUT outreach should be promoted to retry_later');
138    });
139  
140    test('returns zero counts when no failing sites or outreaches exist', async () => {
141      const result = await classifyUnknownErrors();
142  
143      assert.equal(result.sites_retried, 0);
144      assert.equal(result.outreaches_retried, 0);
145      assert.equal(result.patterns_applied, 0);
146    });
147  
148    test('phase 3 returns applied=0 when no pending proposals exist', async () => {
149      const pending = db
150        .prepare(`SELECT COUNT(*) as c FROM error_pattern_proposals WHERE status='pending'`)
151        .get();
152  
153      const result = await classifyUnknownErrors();
154  
155      if (pending.c === 0) {
156        assert.equal(result.patterns_applied, 0);
157      }
158      assert.ok(true, 'classifyUnknownErrors completes without throwing');
159    });
160  });
161  
162  // ── Schema verification ───────────────────────────────────────────────────────
163  
164  describe('classify-unknown-errors DB schema', () => {
165    test('error_pattern_proposals table has correct constraints', () => {
166      const memDb = new Database(':memory:');
167      memDb.exec(`
168        CREATE TABLE error_pattern_proposals (
169          id INTEGER PRIMARY KEY AUTOINCREMENT,
170          pattern TEXT NOT NULL,
171          label TEXT NOT NULL,
172          group_name TEXT NOT NULL CHECK(group_name IN ('terminal', 'retriable')),
173          context TEXT NOT NULL CHECK(context IN ('site', 'outreach')),
174          status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected')),
175          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
176        )
177      `);
178  
179      memDb.prepare(
180        `INSERT INTO error_pattern_proposals (pattern, label, group_name, context)
181         VALUES ('ERR_TIMEOUT', 'Timeout', 'retriable', 'site')`
182      ).run();
183  
184      const row = memDb.prepare('SELECT * FROM error_pattern_proposals').get();
185      assert.equal(row.status, 'pending');
186      assert.equal(row.group_name, 'retriable');
187  
188      assert.throws(() => {
189        memDb.prepare(
190          `INSERT INTO error_pattern_proposals (pattern, label, group_name, context)
191           VALUES ('x', 'y', 'invalid', 'site')`
192        ).run();
193      });
194  
195      memDb.close();
196    });
197  
198    test('ignore sites with null error_message are included in retry scan', () => {
199      const memDb = new Database(':memory:');
200      memDb.exec(`
201        CREATE TABLE sites (
202          id INTEGER PRIMARY KEY,
203          status TEXT,
204          error_message TEXT,
205          updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
206        )
207      `);
208  
209      memDb.prepare(`INSERT INTO sites (status, error_message) VALUES ('ignored', NULL)`).run();
210      memDb.prepare(`INSERT INTO sites (status, error_message) VALUES ('ignored', '')`).run();
211      memDb.prepare(`INSERT INTO sites (status, error_message) VALUES ('failing', 'some error')`).run();
212  
213      const candidates = memDb.prepare(
214        `SELECT id, error_message, status FROM sites
215         WHERE status = 'failing'
216            OR (status = 'ignored' AND (error_message IS NULL OR error_message = ''))`
217      ).all();
218  
219      assert.equal(candidates.length, 3, 'All 3 candidates should be included');
220      memDb.close();
221    });
222  });