/ tests / inbound / inbound-processor.test.js
inbound-processor.test.js
  1  /**
  2   * Tests for Inbound Processor Module - Enhanced
  3   */
  4  
  5  import { test, mock } from 'node:test';
  6  import assert from 'node:assert';
  7  import Database from 'better-sqlite3';
  8  import { createPgMock } from '../helpers/pg-mock.js';
  9  
 10  // Shared in-memory database — minimal schema covering what processor.js needs
 11  const db = new Database(':memory:');
 12  db.pragma('foreign_keys = ON');
 13  db.exec(`
 14    CREATE TABLE IF NOT EXISTS sites (
 15      id INTEGER PRIMARY KEY,
 16      domain TEXT NOT NULL UNIQUE,
 17      landing_page_url TEXT,
 18      keyword TEXT,
 19      score REAL,
 20      grade TEXT,
 21      country_code TEXT,
 22      status TEXT DEFAULT 'found',
 23      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 24      rescored_at DATETIME
 25    );
 26  
 27    CREATE TABLE IF NOT EXISTS messages (
 28      id INTEGER PRIMARY KEY,
 29      site_id INTEGER NOT NULL REFERENCES sites(id),
 30      direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')),
 31      contact_method TEXT CHECK(contact_method IN ('sms','email','form','x','linkedin')),
 32      contact_uri TEXT,
 33      message_body TEXT,
 34      subject_line TEXT,
 35      approval_status TEXT DEFAULT 'pending',
 36      delivery_status TEXT,
 37      sent_at DATETIME,
 38      delivered_at DATETIME,
 39      opened_at DATETIME,
 40      tracking_clicked_at DATETIME,
 41      sentiment TEXT,
 42      intent TEXT,
 43      is_read INTEGER DEFAULT 0,
 44      read_at TEXT,
 45      processed_at TEXT,
 46      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 47      message_type TEXT DEFAULT 'outreach',
 48      raw_payload TEXT
 49    );
 50  
 51    CREATE TABLE IF NOT EXISTS countries (
 52      country_code TEXT PRIMARY KEY,
 53      country_name TEXT,
 54      google_domain TEXT,
 55      language_code TEXT,
 56      currency_code TEXT,
 57      is_active INTEGER DEFAULT 1,
 58      sms_enabled INTEGER DEFAULT 1,
 59      requires_gdpr_check INTEGER DEFAULT 0,
 60      twilio_phone_number TEXT
 61    );
 62  `);
 63  
 64  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 65  
 66  const {
 67    getUnreadConversations,
 68    getConversationThread,
 69    markConversationRead,
 70    markThreadRead,
 71    getInboundStats,
 72    pollAllChannels,
 73    processAllReplies,
 74  } = await import('../../src/inbound/processor.js');
 75  
 76  function setupTestData() {
 77    db.exec('DELETE FROM messages; DELETE FROM sites;');
 78    db.prepare(
 79      'INSERT INTO sites (id, domain, landing_page_url, keyword, score) VALUES (?, ?, ?, ?, ?)'
 80    ).run(1, 'testsite1.com', 'https://testsite1.com', 'keyword1', 75);
 81    db.prepare(
 82      'INSERT INTO sites (id, domain, landing_page_url, keyword, score) VALUES (?, ?, ?, ?, ?)'
 83    ).run(2, 'testsite2.com', 'https://testsite2.com', 'keyword2', 65);
 84    db.prepare(
 85      'INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
 86    ).run(1, 1, 'email', 'owner@testsite1.com', 'Proposal 1', 'Subject 1', 'outbound', 'sent');
 87    db.prepare(
 88      'INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
 89    ).run(2, 2, 'sms', '+1234567890', 'Proposal 2', 'Subject 2', 'outbound', 'sent');
 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@testsite1.com', 'Yes, I am interested!', 'positive');
 93    db.prepare(
 94      "INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, sentiment) VALUES (?, ?, 'inbound', ?, ?, ?, ?)"
 95    ).run(11, 2, 'sms', '+1234567890', 'Tell me more', 'neutral');
 96    db.prepare(
 97      "INSERT INTO messages (id, site_id, direction, contact_method, contact_uri, message_body, sentiment) VALUES (?, ?, 'inbound', ?, ?, ?, ?)"
 98    ).run(12, 1, 'email', 'owner@testsite1.com', 'Not interested', 'objection');
 99  }
100  
101  test('Inbound Processor Module', async t => {
102    await t.test('Setup test database', () => {
103      setupTestData();
104    });
105  
106    await t.test('getUnreadConversations: returns all unread', async () => {
107      setupTestData();
108      const convs = await getUnreadConversations();
109      assert.strictEqual(convs.length, 3);
110      assert.ok(convs[0].domain);
111      assert.ok(convs[0].keyword);
112    });
113  
114    await t.test('getUnreadConversations: respects limit', async () => {
115      setupTestData();
116      const convs = await getUnreadConversations(2);
117      assert.strictEqual(convs.length, 2);
118    });
119  
120    await t.test('getUnreadConversations: empty when all read', async () => {
121      setupTestData();
122      db.prepare("UPDATE messages SET read_at = CURRENT_TIMESTAMP WHERE direction = ?").run('inbound');
123      assert.strictEqual((await getUnreadConversations()).length, 0);
124    });
125  
126    await t.test('getUnreadConversations: returns correct fields', async () => {
127      setupTestData();
128      const convs = await getUnreadConversations(1);
129      assert.ok('id' in convs[0]);
130      assert.ok('site_id' in convs[0]);
131      assert.ok('channel' in convs[0]);
132      assert.ok('message_body' in convs[0]);
133      assert.ok('domain' in convs[0]);
134      assert.ok('contact_uri' in convs[0]);
135      assert.ok('sentiment' in convs[0]);
136    });
137  
138    await t.test('getConversationThread: returns thread with outreach and messages', async () => {
139      setupTestData();
140      db.prepare(
141        "INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body) VALUES (1, 'outbound', 'email', 'op@example.com', 'Thanks!')"
142      ).run();
143      const thread = await getConversationThread(1);
144      assert.ok(thread.outreach);
145      assert.strictEqual(thread.outreach.domain, 'testsite1.com');
146      assert.strictEqual(thread.outreach.keyword, 'keyword1');
147      assert.strictEqual(thread.outreach.conversion_score, 75);
148      assert.ok(thread.messages.length >= 2);
149    });
150  
151    await t.test('getConversationThread: returns undefined outreach for missing', async () => {
152      const thread = await getConversationThread(9999);
153      assert.ok(thread.outreach == null, `expected null or undefined, got ${thread.outreach}`);
154      assert.strictEqual(thread.messages.length, 0);
155    });
156  
157    await t.test('getConversationThread: outreach includes tracking fields', async () => {
158      setupTestData();
159      const thread = await getConversationThread(1);
160      assert.ok(thread.outreach);
161      assert.ok('sent_at' in thread.outreach);
162      assert.ok('delivered_at' in thread.outreach);
163      assert.ok('opened_at' in thread.outreach);
164      // field is returned as 'conversion_score' (score alias), not tracking_clicked_at
165      assert.ok('conversion_score' in thread.outreach);
166      assert.ok('landing_page_url' in thread.outreach);
167      assert.ok('message_body' in thread.outreach);
168      assert.ok('contact_method' in thread.outreach);
169    });
170  
171    await t.test('getConversationThread: messages have required fields', async () => {
172      setupTestData();
173      const thread = await getConversationThread(1);
174      if (thread.messages.length > 0) {
175        const msg = thread.messages[0];
176        assert.ok('id' in msg);
177        assert.ok('direction' in msg);
178        assert.ok('channel' in msg);
179        assert.ok('message_body' in msg);
180        assert.ok('read_at' in msg);
181        assert.ok('sent_at' in msg);
182      }
183    });
184  
185    await t.test('getConversationThread: outreach for sms site', async () => {
186      setupTestData();
187      const thread = await getConversationThread(2);
188      assert.ok(thread.outreach);
189      assert.strictEqual(thread.outreach.domain, 'testsite2.com');
190      assert.strictEqual(thread.outreach.contact_method, 'sms');
191      assert.ok(thread.messages.length >= 1);
192    });
193  
194    await t.test('markConversationRead: marks conversation as read', async () => {
195      setupTestData();
196      const result = await markConversationRead(10);
197      assert.ok(result);
198      const conv = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(10);
199      assert.ok(conv.read_at);
200    });
201  
202    await t.test('markConversationRead: returns true for non-existent id', async () => {
203      assert.ok(await markConversationRead(9999));
204    });
205  
206    await t.test('markConversationRead: only marks specified conversation', async () => {
207      setupTestData();
208      await markConversationRead(10);
209      const c1 = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(10);
210      const c2 = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(11);
211      assert.ok(c1.read_at);
212      assert.strictEqual(c2.read_at, null);
213    });
214  
215    await t.test('markConversationRead: can mark multiple different convos', async () => {
216      setupTestData();
217      await markConversationRead(10);
218      await markConversationRead(11);
219      const c1 = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(10);
220      const c2 = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(11);
221      assert.ok(c1.read_at);
222      assert.ok(c2.read_at);
223    });
224  
225    await t.test('markThreadRead: marks all inbound thread messages', async () => {
226      setupTestData();
227      const count = await markThreadRead(1);
228      assert.ok(count >= 1);
229      const unread = db
230        .prepare(
231          "SELECT COUNT(*) as count FROM messages WHERE site_id = 1 AND direction = 'inbound' AND read_at IS NULL"
232        )
233        .get();
234      assert.strictEqual(unread.count, 0);
235    });
236  
237    await t.test('markThreadRead: returns 0 for non-existent outreach', async () => {
238      assert.strictEqual(await markThreadRead(9999), 0);
239    });
240  
241    await t.test('markThreadRead: returns 0 when already all read', async () => {
242      setupTestData();
243      await markThreadRead(1);
244      assert.strictEqual(await markThreadRead(1), 0);
245    });
246  
247    await t.test('markThreadRead: does not mark outbound messages', async () => {
248      setupTestData();
249      db.prepare(
250        "INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body) VALUES (1, 'outbound', 'email', 'op@ex.com', 'Hello')"
251      ).run();
252      await markThreadRead(1);
253      const outbound = db
254        .prepare("SELECT read_at FROM messages WHERE site_id = 1 AND direction = 'outbound'")
255        .get();
256      assert.strictEqual(outbound.read_at, null);
257    });
258  
259    await t.test('markThreadRead: thread 2 independently', async () => {
260      setupTestData();
261      const count = await markThreadRead(2);
262      assert.ok(count >= 1);
263      const inbound1 = db
264        .prepare(
265          "SELECT COUNT(*) as count FROM messages WHERE site_id = 1 AND direction = 'inbound' AND read_at IS NULL"
266        )
267        .get();
268      // Thread 1 should still be unread
269      assert.ok(inbound1.count > 0);
270    });
271  
272    await t.test('getInboundStats: returns stats with correct structure', async () => {
273      setupTestData();
274      const stats = await getInboundStats();
275      assert.ok(stats.unreadCount > 0);
276      assert.ok(Array.isArray(stats.byChannel));
277      assert.ok(Array.isArray(stats.bySentiment));
278    });
279  
280    await t.test('getInboundStats: byChannel has correct fields', async () => {
281      setupTestData();
282      const stats = await getInboundStats();
283      for (const ch of stats.byChannel) {
284        assert.ok('channel' in ch);
285        assert.ok('total' in ch);
286        assert.ok('unread' in ch);
287      }
288    });
289  
290    await t.test('getInboundStats: bySentiment has correct fields', async () => {
291      setupTestData();
292      const stats = await getInboundStats();
293      for (const s of stats.bySentiment) {
294        assert.ok('sentiment' in s);
295        assert.ok('count' in s);
296      }
297    });
298  
299    await t.test('getInboundStats: unreadCount is zero when all read', async () => {
300      setupTestData();
301      db.prepare("UPDATE messages SET read_at = CURRENT_TIMESTAMP WHERE direction = 'inbound'").run();
302      assert.strictEqual((await getInboundStats()).unreadCount, 0);
303    });
304  
305    await t.test('getInboundStats: total >= unread for each channel', async () => {
306      setupTestData();
307      const stats = await getInboundStats();
308      for (const ch of stats.byChannel) {
309        assert.ok(
310          ch.total >= ch.unread,
311          `Channel ${ch.channel}: total ${ch.total} >= unread ${ch.unread}`
312        );
313      }
314    });
315  
316    await t.test('getInboundStats: email and sms channels present', async () => {
317      setupTestData();
318      const stats = await getInboundStats();
319      const channels = stats.byChannel.map(c => c.channel);
320      assert.ok(channels.includes('email'), 'Should have email channel');
321      assert.ok(channels.includes('sms'), 'Should have sms channel');
322    });
323  
324    await t.test('pollAllChannels: returns object with sms and email keys', async () => {
325      const results = await pollAllChannels();
326      assert.ok(typeof results === 'object');
327      assert.ok('sms' in results);
328      assert.ok('email' in results);
329    });
330  
331    await t.test('pollAllChannels: sms result has numeric fields', async () => {
332      const results = await pollAllChannels();
333      assert.ok(typeof results.sms === 'object');
334    });
335  
336    await t.test('pollAllChannels: email result has numeric fields', async () => {
337      const results = await pollAllChannels();
338      assert.ok(typeof results.email === 'object');
339    });
340  
341    await t.test('processAllReplies: returns object with sms and email keys', async () => {
342      const results = await processAllReplies();
343      assert.ok(typeof results === 'object');
344      assert.ok('sms' in results);
345      assert.ok('email' in results);
346    });
347  
348    await t.test('processAllReplies: sms result is object', async () => {
349      const results = await processAllReplies();
350      assert.ok(typeof results.sms === 'object');
351    });
352  
353    await t.test('processAllReplies: email result is object', async () => {
354      const results = await processAllReplies();
355      assert.ok(typeof results.email === 'object');
356    });
357  });