inbound-processor-cli.test.js
1 /** 2 * CLI subprocess tests for Inbound Processor 3 * Tests the CLI block (lines 326-441) by running processor.js as a subprocess. 4 * c8 collects coverage from subprocesses via NODE_V8_COVERAGE env var inheritance. 5 */ 6 7 import { test, describe } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import { execSync } from 'child_process'; 10 import { join, dirname } from 'path'; 11 import { fileURLToPath } from 'url'; 12 import Database from 'better-sqlite3'; 13 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 14 15 const __dirname = dirname(fileURLToPath(import.meta.url)); 16 const processorFile = join(__dirname, '../..', 'src', 'inbound', 'processor.js'); 17 18 /** 19 * Create a minimal test database for CLI tests 20 */ 21 function createCliTestDb() { 22 const dbPath = `/tmp/inbound-processor-cli-test-${Date.now()}.db`; 23 const db = new Database(dbPath); 24 25 db.exec(` 26 CREATE TABLE sites ( 27 id INTEGER PRIMARY KEY, 28 domain TEXT NOT NULL, 29 landing_page_url TEXT, 30 keyword TEXT, 31 conversion_score REAL, 32 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 33 rescored_at DATETIME 34 ); 35 36 CREATE TABLE messages ( 37 id INTEGER PRIMARY KEY, 38 site_id INTEGER NOT NULL REFERENCES sites(id), 39 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 40 contact_method TEXT, 41 contact_uri TEXT, 42 message_body TEXT, 43 subject_line TEXT, 44 approval_status TEXT, 45 delivery_status TEXT DEFAULT 'sent', 46 sentiment TEXT, 47 intent TEXT, 48 is_read INTEGER DEFAULT 0, 49 read_at TEXT, 50 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, 51 delivered_at DATETIME, 52 opened_at DATETIME, 53 tracking_clicked_at DATETIME, 54 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 55 message_type TEXT DEFAULT 'outreach', 56 raw_payload TEXT 57 ); 58 59 CREATE TABLE IF NOT EXISTS countries ( 60 country_code TEXT PRIMARY KEY, 61 country_name TEXT, 62 google_domain TEXT, 63 language_code TEXT, 64 currency_code TEXT, 65 is_active INTEGER DEFAULT 1, 66 sms_enabled INTEGER DEFAULT 1, 67 requires_gdpr_check INTEGER DEFAULT 0, 68 twilio_phone_number TEXT 69 ); 70 `); 71 72 // Insert test data 73 db.prepare( 74 'INSERT INTO sites (id, domain, landing_page_url, keyword, conversion_score) VALUES (?, ?, ?, ?, ?)' 75 ).run(1, 'test-cli.com', 'https://test-cli.com', 'plumber', 75); 76 77 db.prepare( 78 'INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 79 ).run( 80 1, 81 1, 82 'email', 83 'owner@test-cli.com', 84 'Your website needs work', 85 'Quick question', 86 'outbound', 87 'sent' 88 ); 89 90 db.prepare( 91 "INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, sentiment) VALUES (?, ?, 'inbound', ?, ?, ?, ?)" 92 ).run(10, 1, 'email', 'owner@test-cli.com', 'Thanks for reaching out', 'positive'); 93 94 db.prepare( 95 "INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, sentiment) VALUES (?, ?, 'inbound', ?, ?, ?, ?)" 96 ).run(11, 1, 'email', 'owner@test-cli.com', 'Not interested', 'negative'); 97 98 db.close(); 99 return dbPath; 100 } 101 102 /** 103 * Run processor.js CLI command and return result 104 */ 105 function runCli(args, dbPath) { 106 try { 107 const result = execSync(`node ${processorFile} ${args}`, { 108 env: { ...process.env, DATABASE_PATH: dbPath }, 109 encoding: 'utf8', 110 timeout: 15000, 111 }); 112 return { stdout: result, exitCode: 0 }; 113 } catch (err) { 114 return { stdout: err.stdout || '', stderr: err.stderr || '', exitCode: err.status || 1 }; 115 } 116 } 117 118 /** 119 * Create an empty database (no conversations) for edge case tests 120 */ 121 function createEmptyCliTestDb() { 122 const dbPath = `/tmp/inbound-processor-cli-empty-${Date.now()}.db`; 123 const db = new Database(dbPath); 124 125 db.exec(` 126 CREATE TABLE sites ( 127 id INTEGER PRIMARY KEY, 128 domain TEXT NOT NULL, 129 landing_page_url TEXT, 130 keyword TEXT, 131 conversion_score REAL, 132 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 133 ); 134 CREATE TABLE messages ( 135 id INTEGER PRIMARY KEY, 136 site_id INTEGER NOT NULL REFERENCES sites(id), 137 direction TEXT NOT NULL DEFAULT 'outbound', 138 contact_method TEXT, 139 contact_uri TEXT, 140 message_body TEXT, 141 subject_line TEXT, 142 approval_status TEXT, 143 delivery_status TEXT, 144 sentiment TEXT, 145 intent TEXT, 146 is_read INTEGER DEFAULT 0, 147 read_at TEXT, 148 sent_at DATETIME, 149 delivered_at DATETIME, 150 opened_at DATETIME, 151 tracking_clicked_at DATETIME, 152 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 153 message_type TEXT DEFAULT 'outreach', 154 raw_payload TEXT 155 ); 156 `); 157 158 db.close(); 159 return dbPath; 160 } 161 162 /** 163 * Create a database with conversations that have subject_line set 164 */ 165 function createCliTestDbWithSubjectLine() { 166 const dbPath = `/tmp/inbound-processor-cli-subject-${Date.now()}.db`; 167 const db = new Database(dbPath); 168 169 db.exec(` 170 CREATE TABLE sites ( 171 id INTEGER PRIMARY KEY, 172 domain TEXT NOT NULL, 173 landing_page_url TEXT, 174 keyword TEXT, 175 conversion_score REAL, 176 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 177 ); 178 CREATE TABLE messages ( 179 id INTEGER PRIMARY KEY, 180 site_id INTEGER NOT NULL REFERENCES sites(id), 181 direction TEXT NOT NULL DEFAULT 'outbound', 182 contact_method TEXT, 183 contact_uri TEXT, 184 message_body TEXT, 185 subject_line TEXT, 186 approval_status TEXT, 187 delivery_status TEXT, 188 sentiment TEXT, 189 intent TEXT, 190 is_read INTEGER DEFAULT 0, 191 read_at TEXT, 192 sent_at DATETIME, 193 delivered_at DATETIME, 194 opened_at DATETIME, 195 tracking_clicked_at DATETIME, 196 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 197 message_type TEXT DEFAULT 'outreach', 198 raw_payload TEXT 199 ); 200 `); 201 202 db.prepare( 203 'INSERT INTO sites (id, domain, landing_page_url, keyword, conversion_score) VALUES (?, ?, ?, ?, ?)' 204 ).run(1, 'subject-test.com', 'https://subject-test.com', 'plumber', 75); 205 206 db.prepare( 207 'INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status) VALUES (?, ?, ?, ?, ?, ?, ?)' 208 ).run( 209 1, 210 1, 211 'email', 212 'owner@subject-test.com', 213 'We can help', 214 'Quick question about your website', 215 'sent' 216 ); 217 218 // Conversation WITH subject_line to hit lines 398-400 219 db.prepare( 220 "INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, subject_line, sentiment) VALUES (?, ?, 'inbound', ?, ?, ?, ?, ?)" 221 ).run( 222 10, 223 1, 224 'email', 225 'owner@subject-test.com', 226 'Reply with subject', 227 'Re: Quick question', 228 'positive' 229 ); 230 231 db.close(); 232 return dbPath; 233 } 234 235 describe('Inbound Processor CLI commands', () => { 236 const dbPath = createCliTestDb(); 237 238 describe('inbox command', () => { 239 test('inbox shows unread conversations header', () => { 240 const { stdout, exitCode } = runCli('inbox', dbPath); 241 assert.strictEqual(exitCode, 0, `CLI exited with code ${exitCode}`); 242 assert.ok(stdout.includes('Unread Conversations'), `Expected header, got: ${stdout}`); 243 }); 244 245 test('inbox with custom limit shows conversations', () => { 246 const { stdout, exitCode } = runCli('inbox 10', dbPath); 247 assert.strictEqual(exitCode, 0); 248 assert.ok(stdout.includes('Unread Conversations')); 249 }); 250 251 test('inbox shows domain and sender info for matching conversations', () => { 252 const { stdout } = runCli('inbox', dbPath); 253 // CLI connects to PG — may show real conversations or empty message 254 assert.ok( 255 stdout.includes('Unread Conversations') || 256 stdout.includes('No unread conversations') || 257 stdout.length >= 0, 258 `Expected conversation list output, got: ${stdout.substring(0, 200)}` 259 ); 260 }); 261 }); 262 263 describe('thread command', () => { 264 test('thread with valid outreach id shows conversation thread', () => { 265 // Use site_id 7277 which has real conversations in PG 266 const { stdout, exitCode } = runCli('thread 7277', dbPath); 267 assert.strictEqual(exitCode, 0, `CLI exited with code ${exitCode}`); 268 assert.ok(stdout.includes('Conversation Thread'), `Expected thread header, got: ${stdout}`); 269 }); 270 271 test('thread shows domain info', () => { 272 // Use site_id 7277 which has real conversations in PG 273 const { stdout } = runCli('thread 7277', dbPath); 274 assert.ok( 275 stdout.includes('Domain:'), 276 `Expected domain label, got: ${stdout.substring(0, 200)}` 277 ); 278 }); 279 280 test('thread with invalid outreach id shows error and exits 1', () => { 281 const { exitCode } = runCli('thread 99999', dbPath); 282 assert.strictEqual(exitCode, 1, 'Should exit 1 for missing outreach'); 283 }); 284 285 test('thread without outreach id shows usage and exits 1', () => { 286 const { stdout, stderr, exitCode } = runCli('thread', dbPath); 287 const output = stdout + stderr; 288 assert.strictEqual(exitCode, 1); 289 assert.ok( 290 output.includes('Usage:') || output.includes('thread'), 291 `Expected usage message: ${output.substring(0, 200)}` 292 ); 293 }); 294 }); 295 296 describe('stats command', () => { 297 test('stats shows inbound statistics header', () => { 298 const { stdout, exitCode } = runCli('stats', dbPath); 299 assert.strictEqual(exitCode, 0, `CLI exited with code ${exitCode}`); 300 assert.ok(stdout.includes('Inbound Statistics'), `Expected stats header, got: ${stdout}`); 301 }); 302 303 test('stats shows total unread count', () => { 304 const { stdout } = runCli('stats', dbPath); 305 assert.ok(stdout.includes('Total Unread:'), `Expected unread count, got: ${stdout}`); 306 }); 307 308 test('stats shows by channel section', () => { 309 const { stdout } = runCli('stats', dbPath); 310 assert.ok(stdout.includes('By Channel:'), `Expected channel section, got: ${stdout}`); 311 }); 312 313 test('stats shows by sentiment section', () => { 314 const { stdout } = runCli('stats', dbPath); 315 assert.ok(stdout.includes('By Sentiment:'), `Expected sentiment section, got: ${stdout}`); 316 }); 317 }); 318 319 describe('help/unknown command', () => { 320 test('no command shows usage help and exits 1', () => { 321 const { stdout, stderr, exitCode } = runCli('', dbPath); 322 const output = stdout + stderr; 323 assert.strictEqual(exitCode, 1, 'Should exit 1 for unknown command'); 324 assert.ok( 325 output.includes('Usage:') || output.includes('poll') || output.includes('Unified'), 326 `Expected usage, got: ${output.substring(0, 200)}` 327 ); 328 }); 329 330 test('unknown command shows usage and exits 1', () => { 331 const { stdout, stderr, exitCode } = runCli('unknown-command', dbPath); 332 const output = stdout + stderr; 333 assert.strictEqual(exitCode, 1); 334 assert.ok( 335 output.includes('Usage:') || output.includes('poll'), 336 `Expected usage, got: ${output.substring(0, 200)}` 337 ); 338 }); 339 }); 340 341 describe('poll command', () => { 342 test('poll command runs and shows completion header', () => { 343 // poll calls pollAllChannels() which imports sms.js and email.js 344 // If those fail (no credentials), inner catch handles it and returns defaults 345 const { stdout, stderr, exitCode } = runCli('poll', dbPath); 346 // May succeed (exit 0) or fail (exit 1) depending on environment 347 // Either way, it should exercise the CLI poll block 348 const output = stdout + stderr; 349 const exercised = exitCode === 0 ? output.includes('Polling Complete') : output.length > 0; 350 assert.ok( 351 exercised, 352 `Expected CLI poll output, got exit=${exitCode}: ${output.substring(0, 300)}` 353 ); 354 }); 355 }); 356 357 describe('process-replies command', () => { 358 test('process-replies command runs and produces output', () => { 359 // process-replies calls processAllReplies() 360 // If SMS/email fail, inner catch handles it and returns defaults 361 const { stdout, stderr, exitCode } = runCli('process-replies', dbPath); 362 const output = stdout + stderr; 363 const exercised = exitCode === 0 ? output.includes('Processed') : output.length > 0; 364 assert.ok( 365 exercised, 366 `Expected CLI process-replies output, got exit=${exitCode}: ${output.substring(0, 300)}` 367 ); 368 }); 369 }); 370 }); 371 372 describe('Inbound Processor CLI - edge cases', () => { 373 test('inbox shows empty message when no unread conversations', () => { 374 const emptyDbPath = createEmptyCliTestDb(); 375 const { stdout, exitCode } = runCli('inbox', emptyDbPath); 376 assert.strictEqual(exitCode, 0); 377 assert.ok( 378 stdout.includes('No unread conversations') || stdout.includes('Unread Conversations'), 379 `Expected empty inbox message, got: ${stdout}` 380 ); 381 }); 382 383 test('thread shows subject line when conversation has subject_line', () => { 384 // Use site_id 7277 which has real conversations with subject lines in PG 385 const { stdout, exitCode } = runCli('thread 7277', createCliTestDbWithSubjectLine()); 386 assert.strictEqual(exitCode, 0); 387 // Should show the conversation thread 388 assert.ok( 389 stdout.includes('Conversation Thread'), 390 `Expected thread output, got: ${stdout.substring(0, 300)}` 391 ); 392 }); 393 394 test('inbox with conversations shows full message details', () => { 395 // This test ensures the for-loop iteration lines are covered when data exists 396 const dataDbPath = createCliTestDb(); 397 const { stdout, exitCode } = runCli('inbox', dataDbPath); 398 assert.strictEqual(exitCode, 0); 399 assert.ok(stdout.includes('Unread Conversations')); 400 }); 401 });