reply-processor.test.js
1 /** 2 * Reply Processor Tests 3 * 4 * Tests the revenue-critical reply processing pipeline: 5 * - Inbound channel polling 6 * - Conversation classification (interested/not_interested/question/unsubscribe) 7 * - Payment link generation and delivery 8 * - Report generation for paid conversations 9 * - Compliance: unsubscribe/opt-out handling 10 * - Error isolation (one failure doesn't block the rest) 11 */ 12 13 import { test, describe, mock, beforeEach, afterEach } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 16 // ─── Mock state ─────────────────────────────────────────────────────────────── 17 18 let dbRunCalls = []; 19 let dbQueryResults = new Map(); 20 21 let mockPollAllChannels; 22 let mockClassifyReply; 23 let mockCreatePaymentOrder; 24 let mockGeneratePaymentMessage; 25 let mockGenerateReport; 26 let mockPollPayPalEvents; 27 let mockProcessStopKeyword; 28 let mockSendSMS; 29 let mockSendEmail; 30 31 // ─── Mock db.js (run/getAll) with SQL-keyword routing ───────────────────────── 32 33 mock.module('../../src/utils/db.js', { 34 namedExports: { 35 getPool: () => ({}), 36 closePool: async () => {}, 37 run: async (sql, params = []) => { 38 dbRunCalls.push({ sql, args: params }); 39 return { changes: 1, lastInsertRowid: 1 }; 40 }, 41 getOne: async (sql, params = []) => { 42 for (const [keyword, result] of dbQueryResults) { 43 if (sql.includes(keyword) && result._getResult !== undefined) { 44 return typeof result._getResult === 'function' 45 ? result._getResult(params) 46 : result._getResult; 47 } 48 } 49 return undefined; 50 }, 51 getAll: async (sql, params = []) => { 52 for (const [keyword, result] of dbQueryResults) { 53 if (sql.includes(keyword) && result._allResult !== undefined) { 54 return typeof result._allResult === 'function' 55 ? result._allResult(params) 56 : result._allResult; 57 } 58 } 59 return []; 60 }, 61 query: async (sql, params = []) => { 62 return { rows: [], rowCount: 0 }; 63 }, 64 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 65 createDatabaseConnection: () => ({}), 66 closeDatabaseConnection: async () => {}, 67 }, 68 }); 69 70 // ─── Mock inbound/outreach/payment/report modules ───────────────────────────── 71 72 mock.module('../../src/inbound/processor.js', { 73 namedExports: { pollAllChannels: (...args) => mockPollAllChannels(...args) }, 74 }); 75 76 mock.module('../../src/utils/reply-classifier.js', { 77 namedExports: { classifyReply: (...args) => mockClassifyReply(...args) }, 78 }); 79 80 mock.module('../../src/payment/paypal.js', { 81 namedExports: { 82 createPaymentOrder: (...args) => mockCreatePaymentOrder(...args), 83 generatePaymentMessage: (...args) => mockGeneratePaymentMessage(...args), 84 }, 85 }); 86 87 mock.module('../../src/reports/cro-report-generator.js', { 88 namedExports: { generateReport: (...args) => mockGenerateReport(...args) }, 89 }); 90 91 mock.module('../../src/payment/poll-paypal-events.js', { 92 namedExports: { pollPayPalEvents: (...args) => mockPollPayPalEvents(...args) }, 93 }); 94 95 mock.module('../../src/utils/compliance.js', { 96 namedExports: { processStopKeyword: (...args) => mockProcessStopKeyword(...args) }, 97 }); 98 99 // Dynamic imports inside sendPaymentMessage - mock sms.js and email.js 100 mock.module('../../src/outreach/sms.js', { 101 namedExports: { sendSMS: (...args) => mockSendSMS(...args) }, 102 }); 103 104 mock.module('../../src/outreach/email.js', { 105 namedExports: { sendEmail: (...args) => mockSendEmail(...args) }, 106 }); 107 108 mock.module('dotenv', { 109 defaultExport: { config: () => {} }, 110 namedExports: { config: () => {} }, 111 }); 112 113 // ─── Import module under test ───────────────────────────────────────────────── 114 115 const { processReplies } = await import('../../src/cli/reply-processor.js'); 116 117 // ─── Helpers ────────────────────────────────────────────────────────────────── 118 119 function setupDefaultMocks() { 120 dbRunCalls = []; 121 dbQueryResults = new Map(); 122 123 // Default: nothing to process 124 dbQueryResults.set('intent IS NULL', { _allResult: [] }); 125 dbQueryResults.set("conversation_status = 'qualified'", { _allResult: [] }); 126 dbQueryResults.set("conversation_status = 'paid'", { _allResult: [] }); 127 dbQueryResults.set("conversation_status = 'report_delivered'", { _allResult: [] }); 128 dbQueryResults.set('contact_uri FROM messages', { _getResult: undefined }); 129 130 mockPollAllChannels = async () => ({ sms: { stored: 0 }, email: { stored: 0 } }); 131 mockClassifyReply = async () => ({ classification: 'interested', confidence: 0.9 }); 132 mockCreatePaymentOrder = async () => ({ 133 paymentLink: 'https://paypal.com/pay/abc123', 134 orderId: 'ORDER-123', 135 currency: 'USD', 136 amount: 299, 137 amountUsd: 299, 138 exchangeRate: 1, 139 }); 140 mockGeneratePaymentMessage = () => 'Click here to pay: https://paypal.com/pay/abc123'; 141 mockGenerateReport = async () => {}; 142 mockPollPayPalEvents = async () => ({ successful: 0 }); 143 mockProcessStopKeyword = () => {}; 144 mockSendSMS = async () => {}; 145 mockSendEmail = async () => {}; 146 } 147 148 // ─── Tests ──────────────────────────────────────────────────────────────────── 149 150 describe('Reply Processor', () => { 151 beforeEach(setupDefaultMocks); 152 153 // ── 1. Happy path: nothing to do ────────────────────────────────────────── 154 155 test('returns zero stats when nothing to process', async () => { 156 const stats = await processReplies(); 157 158 assert.equal(stats.polled, 0); 159 assert.equal(stats.classified, 0); 160 assert.equal(stats.paymentsSent, 0); 161 assert.equal(stats.reportsGenerated, 0); 162 assert.equal(stats.reportsDelivered, 0); 163 assert.equal(stats.errors, 0); 164 }); 165 166 // ── 2. Polling ──────────────────────────────────────────────────────────── 167 168 test('polls all inbound channels and counts stored messages', async () => { 169 mockPollAllChannels = async () => ({ sms: { stored: 3 }, email: { stored: 2 } }); 170 171 const stats = await processReplies(); 172 assert.equal(stats.polled, 5); 173 }); 174 175 test('poll failure propagates as error', async () => { 176 mockPollAllChannels = async () => { 177 throw new Error('Twilio connection refused'); 178 }; 179 180 await assert.rejects(() => processReplies(), /Twilio connection refused/); 181 }); 182 183 // ── 3. Classification ───────────────────────────────────────────────────── 184 185 test('classifies inbound messages with null intent', async () => { 186 dbQueryResults.set('intent IS NULL', { 187 _allResult: [ 188 { 189 id: 1, 190 message_body: 'Yes, I am interested!', 191 contact_method: 'email', 192 contact_uri: 'user@example.com', 193 site_id: 100, 194 domain: 'example.com', 195 landing_page_url: 'https://example.com', 196 }, 197 ], 198 }); 199 200 const stats = await processReplies(); 201 assert.equal(stats.classified, 1); 202 203 // Verify sites table was updated with conversation_status = 'qualified' 204 const siteUpdate = dbRunCalls.find( 205 c => 206 c.sql.includes('UPDATE sites') && 207 c.sql.includes('conversation_status') && 208 c.args[0] === 'qualified' 209 ); 210 assert.ok(siteUpdate, 'Should update sites.conversation_status to qualified for interested'); 211 }); 212 213 test('unsubscribe classification triggers opt-out processing', async () => { 214 dbQueryResults.set('intent IS NULL', { 215 _allResult: [ 216 { 217 id: 2, 218 message_body: 'STOP', 219 contact_method: 'sms', 220 contact_uri: '+15005550001', 221 site_id: 200, 222 domain: 'spam.com', 223 landing_page_url: 'https://spam.com', 224 }, 225 ], 226 }); 227 228 mockClassifyReply = async () => ({ classification: 'unsubscribe', confidence: 0.99 }); 229 230 let stopCalled = false; 231 let stopUri; 232 mockProcessStopKeyword = (_body, uri, _db) => { 233 stopCalled = true; 234 stopUri = uri; 235 }; 236 237 const stats = await processReplies(); 238 assert.equal(stats.classified, 1); 239 assert.ok(stopCalled, 'Should call processStopKeyword for unsubscribe'); 240 assert.equal(stopUri, '+15005550001'); 241 242 // Site conversation_status should be 'unsubscribed' for unsubscribe 243 const siteUpdate = dbRunCalls.find( 244 c => 245 c.sql.includes('UPDATE sites') && 246 c.sql.includes('conversation_status') && 247 c.args[0] === 'unsubscribed' 248 ); 249 assert.ok(siteUpdate, 'unsubscribe → unsubscribed on sites table'); 250 }); 251 252 test('classification error marks site as active for human review', async () => { 253 dbQueryResults.set('intent IS NULL', { 254 _allResult: [ 255 { 256 id: 3, 257 message_body: 'garbled reply', 258 contact_method: 'sms', 259 contact_uri: '+15005550001', 260 site_id: 300, 261 domain: 'test.com', 262 landing_page_url: 'https://test.com', 263 }, 264 ], 265 }); 266 267 mockClassifyReply = async () => { 268 throw new Error('LLM timeout'); 269 }; 270 271 // Should not throw — error is isolated per conversation 272 const stats = await processReplies(); 273 assert.equal(stats.classified, 0, 'Failed classification not counted'); 274 assert.equal(stats.errors, 0, 'Conversation errors do not set top-level error count'); 275 276 // Should mark site as 'active' on error (so it shows up for human review) 277 const fallbackUpdate = dbRunCalls.find( 278 c => c.sql.includes('UPDATE sites') && c.sql.includes("conversation_status = 'active'") 279 ); 280 assert.ok(fallbackUpdate, 'Should mark as active on error'); 281 }); 282 283 // ── 4. Classification → status mapping ─────────────────────────────────── 284 285 test('maps interested → qualified', async () => { 286 dbQueryResults.set('intent IS NULL', { 287 _allResult: [ 288 { 289 id: 4, 290 message_body: "I'd like to proceed", 291 contact_method: 'email', 292 contact_uri: 'user@a.com', 293 site_id: 400, 294 domain: 'a.com', 295 landing_page_url: 'https://a.com', 296 }, 297 ], 298 }); 299 mockClassifyReply = async () => ({ classification: 'interested', confidence: 0.95 }); 300 301 await processReplies(); 302 303 const upd = dbRunCalls.find( 304 c => 305 c.sql.includes('UPDATE sites') && 306 c.sql.includes('conversation_status') && 307 c.args[0] === 'qualified' 308 ); 309 assert.ok(upd, 'interested maps to qualified'); 310 }); 311 312 test('maps not_interested → not_interested', async () => { 313 dbQueryResults.set('intent IS NULL', { 314 _allResult: [ 315 { 316 id: 5, 317 message_body: 'No thanks', 318 contact_method: 'sms', 319 contact_uri: '+15005550001', 320 site_id: 500, 321 domain: 'b.com', 322 landing_page_url: 'https://b.com', 323 }, 324 ], 325 }); 326 mockClassifyReply = async () => ({ classification: 'not_interested', confidence: 0.9 }); 327 328 await processReplies(); 329 330 const upd = dbRunCalls.find( 331 c => 332 c.sql.includes('UPDATE sites') && 333 c.sql.includes('conversation_status') && 334 c.args[0] === 'not_interested' 335 ); 336 assert.ok(upd, 'not_interested maps to not_interested'); 337 }); 338 339 test('maps question → active', async () => { 340 dbQueryResults.set('intent IS NULL', { 341 _allResult: [ 342 { 343 id: 6, 344 message_body: 'How long does this take?', 345 contact_method: 'email', 346 contact_uri: 'user@c.com', 347 site_id: 600, 348 domain: 'c.com', 349 landing_page_url: 'https://c.com', 350 }, 351 ], 352 }); 353 mockClassifyReply = async () => ({ classification: 'question', confidence: 0.8 }); 354 355 await processReplies(); 356 357 const upd = dbRunCalls.find( 358 c => 359 c.sql.includes('UPDATE sites') && 360 c.sql.includes('conversation_status') && 361 c.args[0] === 'active' 362 ); 363 assert.ok(upd, 'question maps to active'); 364 }); 365 366 // ── 5. Payment links ────────────────────────────────────────────────────── 367 368 test('sends payment link to qualified conversation via email', async () => { 369 dbQueryResults.set("conversation_status = 'qualified'", { 370 _allResult: [ 371 { 372 id: 7, 373 channel: 'email', 374 site_id: 700, 375 contact_uri: 'prospect@example.com', 376 contact_method: 'email', 377 domain: 'd.com', 378 landing_page_url: 'https://d.com', 379 country_code: 'US', 380 }, 381 ], 382 }); 383 384 let emailSent = false; 385 mockSendEmail = async () => { 386 emailSent = true; 387 }; 388 389 const stats = await processReplies(); 390 assert.equal(stats.paymentsSent, 1); 391 assert.ok(emailSent, 'Should send email with payment link'); 392 393 // Verify DB updated with payment info (short URL stored, not raw PayPal link) 394 const paymentUpdate = dbRunCalls.find(c => c.sql.includes('payment_link')); 395 assert.ok(paymentUpdate, 'Should update conversation with payment link'); 396 assert.ok(paymentUpdate.args[0].includes('/o/700'), 'Should store short URL'); 397 assert.equal(paymentUpdate.args[1], 'ORDER-123'); 398 assert.equal(paymentUpdate.args[2], 'USD'); 399 }); 400 401 test('sends payment link via SMS for sms channel', async () => { 402 dbQueryResults.set("conversation_status = 'qualified'", { 403 _allResult: [ 404 { 405 id: 8, 406 channel: 'sms', 407 site_id: 800, 408 contact_uri: '+15005550006', 409 contact_method: 'sms', 410 domain: 'e.com', 411 landing_page_url: 'https://e.com', 412 country_code: 'US', 413 }, 414 ], 415 }); 416 417 let smsSent = false; 418 mockSendSMS = async () => { 419 smsSent = true; 420 }; 421 422 const stats = await processReplies(); 423 assert.equal(stats.paymentsSent, 1); 424 assert.ok(smsSent, 'Should send SMS with payment link'); 425 }); 426 427 test('payment failure is isolated: other conversations still processed', async () => { 428 dbQueryResults.set("conversation_status = 'qualified'", { 429 _allResult: [ 430 { 431 id: 9, 432 channel: 'email', 433 site_id: 900, 434 contact_uri: 'fail@example.com', 435 contact_method: 'email', 436 domain: 'f.com', 437 landing_page_url: 'https://f.com', 438 country_code: 'US', 439 }, 440 { 441 id: 10, 442 channel: 'email', 443 site_id: 1000, 444 contact_uri: 'ok@example.com', 445 contact_method: 'email', 446 domain: 'g.com', 447 landing_page_url: 'https://g.com', 448 country_code: 'US', 449 }, 450 ], 451 }); 452 453 let callCount = 0; 454 mockCreatePaymentOrder = async ({ email }) => { 455 callCount++; 456 if (email === 'fail@example.com') throw new Error('PayPal API timeout'); 457 return { 458 paymentLink: 'https://paypal.com/ok', 459 orderId: 'OK-456', 460 currency: 'USD', 461 amount: 299, 462 amountUsd: 299, 463 exchangeRate: 1, 464 }; 465 }; 466 467 const stats = await processReplies(); 468 assert.equal(callCount, 2, 'Should attempt both'); 469 assert.equal(stats.paymentsSent, 1, 'Only successful one counted'); 470 assert.equal(stats.errors, 0, 'Payment errors do not propagate'); 471 }); 472 473 // ── 6. PayPal events polling ────────────────────────────────────────────── 474 475 test('PayPal polling failure is non-fatal', async () => { 476 mockPollPayPalEvents = async () => { 477 throw new Error('PayPal API down'); 478 }; 479 480 // Should not throw - PayPal failure is explicitly caught 481 const stats = await processReplies(); 482 assert.equal(stats.paymentsProcessed, 0); 483 assert.equal(stats.errors, 0, 'PayPal failure should not set error count'); 484 }); 485 486 test('counts successful PayPal events', async () => { 487 mockPollPayPalEvents = async () => ({ successful: 3 }); 488 489 const stats = await processReplies(); 490 assert.equal(stats.paymentsProcessed, 3); 491 }); 492 493 // ── 7. Report generation ────────────────────────────────────────────────── 494 495 test('generates reports for paid conversations', async () => { 496 dbQueryResults.set("conversation_status = 'paid'", { 497 _allResult: [ 498 { id: 11, site_id: 1100, domain: 'paid.com' }, 499 { id: 12, site_id: 1200, domain: 'paid2.com' }, 500 ], 501 }); 502 503 const generateCalls = []; 504 mockGenerateReport = async (siteId, convId) => { 505 generateCalls.push({ siteId, convId }); 506 }; 507 508 const stats = await processReplies(); 509 assert.equal(stats.reportsGenerated, 2); 510 assert.equal(generateCalls.length, 2); 511 assert.deepEqual(generateCalls[0], { siteId: 1100, convId: 11 }); 512 }); 513 514 test('report generation failure is isolated', async () => { 515 dbQueryResults.set("conversation_status = 'paid'", { 516 _allResult: [ 517 { id: 13, site_id: 1300, domain: 'fail.com' }, 518 { id: 14, site_id: 1400, domain: 'ok.com' }, 519 ], 520 }); 521 522 let callCount = 0; 523 mockGenerateReport = async (_siteId, convId) => { 524 callCount++; 525 if (convId === 13) throw new Error('Report gen failed'); 526 }; 527 528 const stats = await processReplies(); 529 assert.equal(callCount, 2, 'Both attempted'); 530 assert.equal(stats.reportsGenerated, 1, 'Only successful counted'); 531 }); 532 533 // ── 8. Report delivery ──────────────────────────────────────────────────── 534 535 test('delivers reports via email and marks as delivered', async () => { 536 dbQueryResults.set("conversation_status = 'report_delivered'", { 537 _allResult: [ 538 { 539 id: 15, 540 site_id: 150, 541 report_url: 'https://reports.example.com/15', 542 contact_uri: 'buyer@example.com', 543 domain: 'deliver.com', 544 }, 545 ], 546 }); 547 548 let deliveryCalled = false; 549 let deliverySubject; 550 mockSendEmail = async (_outreachId, _body, subject, _to) => { 551 deliveryCalled = true; 552 deliverySubject = subject; 553 }; 554 555 const stats = await processReplies(); 556 assert.equal(stats.reportsDelivered, 1); 557 assert.ok(deliveryCalled, 'Should send email'); 558 assert.ok(deliverySubject.includes('deliver.com'), 'Subject should include domain'); 559 560 // Should INSERT outbound delivery message 561 const markDelivered = dbRunCalls.find( 562 c => c.sql.includes('INSERT INTO messages') && c.sql.includes('sent_at') 563 ); 564 assert.ok(markDelivered, 'Should log outbound delivery message'); 565 }); 566 567 // ── 9. Full pipeline stats accuracy ───────────────────────────────────── 568 569 test('stats aggregate correctly across all steps', async () => { 570 // Setup: 3 polled, 1 classified, 1 payment sent, 2 PayPal events, 1 report 571 mockPollAllChannels = async () => ({ sms: { stored: 2 }, email: { stored: 1 } }); 572 573 dbQueryResults.set('intent IS NULL', { 574 _allResult: [ 575 { 576 id: 17, 577 message_body: 'Interested', 578 contact_method: 'email', 579 contact_uri: 'user@i.com', 580 site_id: 1700, 581 domain: 'i.com', 582 landing_page_url: 'https://i.com', 583 }, 584 ], 585 }); 586 587 mockPollPayPalEvents = async () => ({ successful: 2 }); 588 589 dbQueryResults.set("conversation_status = 'paid'", { 590 _allResult: [{ id: 18, site_id: 1800, domain: 'j.com' }], 591 }); 592 593 const stats = await processReplies(); 594 595 assert.equal(stats.polled, 3); 596 assert.equal(stats.classified, 1); 597 assert.equal(stats.paymentsProcessed, 2); 598 assert.equal(stats.reportsGenerated, 1); 599 assert.equal(stats.errors, 0); 600 }); 601 });