/ tests / cli / reply-processor.test.js
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  });