replies-supplement.test.js
1 /** 2 * Replies Stage Supplement — Cover already-classified branch (lines 91-147) 3 * 4 * When a message already has sentiment AND intent set, the replies stage 5 * takes the else branch (lines 134-160): advances conversation_status and 6 * marks as processed without calling analyzeReply. 7 * 8 * Also covers: 9 * - Lines 91-95: logging when intent/sentiment are already set on message 10 * - Lines 136-147: intentStatusMap in the else branch 11 */ 12 13 import { test, describe, mock, beforeEach } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 import Database from 'better-sqlite3'; 16 import { createPgMock } from '../helpers/pg-mock.js'; 17 18 // ─── Create in-memory test DB ───────────────────────────────────────────────── 19 20 const db = new Database(':memory:'); 21 22 db.exec(` 23 CREATE TABLE sites ( 24 id INTEGER PRIMARY KEY AUTOINCREMENT, 25 domain TEXT NOT NULL, 26 landing_page_url TEXT, 27 status TEXT DEFAULT 'outreach_sent', 28 keyword TEXT, 29 score REAL, 30 conversation_status TEXT, 31 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 32 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 33 rescored_at DATETIME 34 ); 35 36 CREATE TABLE messages ( 37 id INTEGER PRIMARY KEY AUTOINCREMENT, 38 site_id INTEGER, 39 contact_uri TEXT, 40 contact_method TEXT, 41 message_body TEXT, 42 direction TEXT NOT NULL DEFAULT 'inbound', 43 intent TEXT, 44 sentiment TEXT, 45 processed_at TEXT, 46 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 47 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 48 message_type TEXT DEFAULT 'outreach', 49 raw_payload TEXT, 50 read_at TEXT 51 ); 52 53 CREATE TABLE opt_outs ( 54 id INTEGER PRIMARY KEY AUTOINCREMENT, 55 phone TEXT, 56 email TEXT, 57 method TEXT NOT NULL, 58 opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP, 59 source TEXT DEFAULT 'inbound', 60 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 61 ); 62 `); 63 64 // ─── Mock db.js BEFORE importing replies.js ────────────────────────────────── 65 66 mock.module('../../src/utils/db.js', { 67 namedExports: createPgMock(db), 68 }); 69 70 mock.module('../../src/utils/logger.js', { 71 defaultExport: class { 72 info() {} 73 warn() {} 74 error() {} 75 success() {} 76 debug() {} 77 }, 78 }); 79 80 mock.module('../../src/utils/summary-generator.js', { 81 namedExports: { 82 generateStageCompletion: () => {}, 83 displayProgress: () => {}, 84 }, 85 }); 86 87 // Import AFTER mock.module 88 const { runRepliesStage } = await import('../../src/stages/replies.js'); 89 90 // ─── Helpers ───────────────────────────────────────────────────────────────── 91 92 let siteSeq = 100; 93 94 function clearTables() { 95 db.prepare('DELETE FROM messages').run(); 96 db.prepare('DELETE FROM sites').run(); 97 } 98 99 function insertSite() { 100 const id = siteSeq++; 101 db.prepare( 102 `INSERT INTO sites (id, domain, landing_page_url, status, keyword) 103 VALUES (?, ?, ?, 'outreach_sent', 'test kw')` 104 ).run(id, `site${id}.com`, `https://site${id}.com`); 105 return id; 106 } 107 108 function insertClassifiedReply(siteId, intent = 'inquiry', sentiment = 'neutral') { 109 db.prepare(`UPDATE sites SET conversation_status = 'active' WHERE id = ?`).run(siteId); 110 const result = db.prepare( 111 `INSERT INTO messages (site_id, contact_uri, contact_method, message_body, direction, intent, sentiment) 112 VALUES (?, 'sender@test.com', 'email', 'Hello world!', 'inbound', ?, ?)` 113 ).run(siteId, intent, sentiment); 114 return result.lastInsertRowid; 115 } 116 117 // ─── Tests ─────────────────────────────────────────────────────────────────── 118 119 describe('Replies Stage Supplement - already-classified branch (lines 134-147)', () => { 120 beforeEach(clearTables); 121 122 test('already-classified reply with inquiry intent → advances to active status', async () => { 123 const siteId = insertSite(); 124 const msgId = insertClassifiedReply(siteId, 'inquiry', 'neutral'); 125 126 const result = await runRepliesStage(); 127 128 assert.ok(result.processed >= 1); 129 130 const msg = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId); 131 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 132 133 assert.ok(msg.processed_at !== null, 'message should be marked as processed'); 134 assert.equal(site.conversation_status, 'active', 'inquiry → active'); 135 }); 136 137 test('already-classified reply with interested intent → site qualified', async () => { 138 const siteId = insertSite(); 139 insertClassifiedReply(siteId, 'interested', 'positive'); 140 141 await runRepliesStage(); 142 143 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 144 assert.equal(site.conversation_status, 'qualified', 'interested → qualified'); 145 }); 146 147 test('already-classified reply with opt-out intent → site not_interested', async () => { 148 const siteId = insertSite(); 149 insertClassifiedReply(siteId, 'opt-out', 'negative'); 150 151 await runRepliesStage(); 152 153 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 154 assert.equal(site.conversation_status, 'not_interested', 'opt-out → not_interested'); 155 }); 156 157 test('already-classified reply with autoresponder intent → site closed', async () => { 158 const siteId = insertSite(); 159 insertClassifiedReply(siteId, 'autoresponder', 'neutral'); 160 161 await runRepliesStage(); 162 163 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 164 assert.equal(site.conversation_status, 'closed', 'autoresponder → closed'); 165 }); 166 167 test('already-classified reply with pricing intent → site active', async () => { 168 const siteId = insertSite(); 169 insertClassifiedReply(siteId, 'pricing', 'neutral'); 170 171 await runRepliesStage(); 172 173 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId); 174 assert.equal(site.conversation_status, 'active', 'pricing → active'); 175 }); 176 });