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