/ tests / inbound / processor.test.js
processor.test.js
  1  /**
  2   * Tests for src/inbound/processor.js — pure DB functions
  3   *
  4   * Tests: getUnreadConversations, getConversationThread,
  5   *        markConversationRead, markThreadRead, getInboundStats
  6   *
  7   * pollAllChannels/processAllReplies require live Twilio/Resend — not tested here.
  8   */
  9  
 10  import { test, describe, before, mock } from 'node:test';
 11  import assert from 'node:assert/strict';
 12  import Database from 'better-sqlite3';
 13  import { createPgMock } from '../helpers/pg-mock.js';
 14  
 15  // Shared in-memory database — created at module level so mock.module() can reference it
 16  const db = new Database(':memory:');
 17  db.pragma('journal_mode = WAL');
 18  db.exec(`
 19    CREATE TABLE IF NOT EXISTS sites (
 20      id INTEGER PRIMARY KEY AUTOINCREMENT,
 21      domain TEXT NOT NULL DEFAULT 'test.com',
 22      keyword TEXT DEFAULT 'web design',
 23      status TEXT DEFAULT 'found',
 24      score REAL,
 25      conversion_score REAL,
 26      landing_page_url TEXT,
 27      updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
 28      rescored_at DATETIME
 29    );
 30  
 31    CREATE TABLE IF NOT EXISTS messages (
 32      id INTEGER PRIMARY KEY AUTOINCREMENT,
 33      site_id INTEGER NOT NULL,
 34      direction TEXT NOT NULL DEFAULT 'outbound',
 35      contact_method TEXT NOT NULL DEFAULT 'sms',
 36      contact_uri TEXT NOT NULL DEFAULT '',
 37      message_body TEXT,
 38      subject_line TEXT,
 39      sentiment TEXT,
 40      intent TEXT,
 41      approval_status TEXT,
 42      delivery_status TEXT,
 43      read_at TEXT,
 44      sent_at TEXT,
 45      delivered_at TEXT,
 46      opened_at TEXT,
 47      tracking_clicked_at TEXT,
 48      created_at TEXT NOT NULL DEFAULT (datetime('now')),
 49      updated_at TEXT NOT NULL DEFAULT (datetime('now')),
 50      message_type TEXT DEFAULT 'outreach',
 51      raw_payload TEXT
 52    );
 53  
 54    CREATE TABLE IF NOT EXISTS countries (
 55      country_code TEXT PRIMARY KEY,
 56      country_name TEXT,
 57      google_domain TEXT,
 58      language_code TEXT,
 59      currency_code TEXT,
 60      is_active INTEGER DEFAULT 1,
 61      sms_enabled INTEGER DEFAULT 1,
 62      requires_gdpr_check INTEGER DEFAULT 0,
 63      twilio_phone_number TEXT
 64    );
 65  `);
 66  
 67  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 68  
 69  const {
 70    getUnreadConversations,
 71    getConversationThread,
 72    markConversationRead,
 73    markThreadRead,
 74    getInboundStats,
 75  } = await import('../../src/inbound/processor.js');
 76  
 77  // ─── Schema + Seed BEFORE tests ───────────────────────────────────────────────
 78  
 79  let siteId, outboundId, inboundId1, inboundId2;
 80  
 81  before(() => {
 82    // Insert a site
 83    db.prepare("INSERT INTO sites (domain, keyword) VALUES ('example.com', 'web design')").run();
 84    siteId = db.prepare('SELECT id FROM sites ORDER BY id DESC LIMIT 1').get().id;
 85  
 86    // Insert an outbound message
 87    db.prepare(
 88      `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status, delivery_status)
 89       VALUES (?, 'outbound', 'sms', '+61412345678', 'Hi, we noticed your website...', 'approved', 'sent')`
 90    ).run(siteId);
 91    outboundId = db.prepare('SELECT id FROM messages ORDER BY id DESC LIMIT 1').get().id;
 92  
 93    // Insert two inbound messages (unread)
 94    db.prepare(
 95      `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, sentiment)
 96       VALUES (?, 'inbound', 'sms', '+61412345678', 'What is this?', 'neutral')`
 97    ).run(siteId);
 98    inboundId1 = db.prepare('SELECT id FROM messages ORDER BY id DESC LIMIT 1').get().id;
 99  
100    db.prepare(
101      `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, sentiment)
102       VALUES (?, 'inbound', 'sms', '+61412345678', 'Tell me more.', 'positive')`
103    ).run(siteId);
104    inboundId2 = db.prepare('SELECT id FROM messages ORDER BY id DESC LIMIT 1').get().id;
105  });
106  
107  // ─── getUnreadConversations ───────────────────────────────────────────────────
108  
109  describe('getUnreadConversations', () => {
110    test('returns array of unread inbound messages', async () => {
111      const convs = await getUnreadConversations();
112      assert.ok(Array.isArray(convs));
113      assert.ok(convs.length >= 1);
114    });
115  
116    test('returns messages with expected fields', async () => {
117      const convs = await getUnreadConversations();
118      const conv = convs.find(c => c.id === inboundId1);
119      assert.ok(conv, 'inbound message should be in results');
120      assert.equal(typeof conv.domain, 'string');
121      assert.equal(typeof conv.message_body, 'string');
122      assert.equal(typeof conv.channel, 'string');
123    });
124  
125    test('respects limit parameter', async () => {
126      const convs = await getUnreadConversations(1);
127      assert.equal(convs.length, 1);
128    });
129  
130    test('returns empty array when no unread messages', async () => {
131      // Mark all as read temporarily
132      db.exec("UPDATE messages SET read_at = CURRENT_TIMESTAMP WHERE direction = 'inbound'");
133  
134      const convs = await getUnreadConversations();
135      assert.equal(convs.length, 0);
136  
137      // Restore
138      db.exec("UPDATE messages SET read_at = NULL WHERE direction = 'inbound'");
139    });
140  });
141  
142  // ─── getConversationThread ────────────────────────────────────────────────────
143  
144  describe('getConversationThread', () => {
145    test('returns outreach + messages for valid site_id', async () => {
146      const thread = await getConversationThread(siteId);
147      assert.ok(typeof thread === 'object');
148      assert.ok('outreach' in thread);
149      assert.ok('messages' in thread);
150      assert.ok(Array.isArray(thread.messages));
151    });
152  
153    test('messages include both inbound and outbound', async () => {
154      const thread = await getConversationThread(siteId);
155      const directions = thread.messages.map(m => m.direction);
156      assert.ok(directions.includes('inbound'), 'should include inbound');
157      assert.ok(directions.includes('outbound'), 'should include outbound');
158    });
159  
160    test('returns null outreach for non-existent site', async () => {
161      const thread = await getConversationThread(999999);
162      assert.ok(thread.outreach == null, `expected null or undefined, got ${thread.outreach}`);
163      assert.deepEqual(thread.messages, []);
164    });
165  });
166  
167  // ─── markConversationRead ─────────────────────────────────────────────────────
168  
169  describe('markConversationRead', () => {
170    test('returns true on success', async () => {
171      const result = await markConversationRead(inboundId1);
172      assert.equal(result, true);
173    });
174  
175    test('message read_at is set after marking read', async () => {
176      await markConversationRead(inboundId1);
177      const row = db.prepare('SELECT read_at FROM messages WHERE id = ?').get(inboundId1);
178      assert.ok(row.read_at !== null);
179    });
180  
181    test('does not throw for non-existent message id', async () => {
182      await assert.doesNotReject(() => markConversationRead(999999));
183    });
184  });
185  
186  // ─── markThreadRead ───────────────────────────────────────────────────────────
187  
188  describe('markThreadRead', () => {
189    test('returns number of messages marked as read', async () => {
190      // Reset both inbound messages to unread
191      db.exec("UPDATE messages SET read_at = NULL WHERE direction = 'inbound'");
192  
193      const changes = await markThreadRead(siteId);
194      assert.ok(typeof changes === 'number');
195      assert.ok(changes >= 1, 'should have marked at least 1 message');
196    });
197  
198    test('returns 0 when all already read', async () => {
199      // All inbound should already be read from previous test
200      const changes = await markThreadRead(siteId);
201      assert.equal(changes, 0);
202    });
203  
204    test('returns 0 for non-existent site_id', async () => {
205      const changes = await markThreadRead(999999);
206      assert.equal(changes, 0);
207    });
208  });
209  
210  // ─── getInboundStats ──────────────────────────────────────────────────────────
211  
212  describe('getInboundStats', () => {
213    test('returns object with unreadCount, byChannel, bySentiment', async () => {
214      const stats = await getInboundStats();
215      assert.ok(typeof stats === 'object');
216      assert.ok('unreadCount' in stats);
217      assert.ok('byChannel' in stats);
218      assert.ok('bySentiment' in stats);
219      assert.ok(Array.isArray(stats.byChannel));
220      assert.ok(Array.isArray(stats.bySentiment));
221    });
222  
223    test('unreadCount is a number', async () => {
224      assert.ok(typeof (await getInboundStats()).unreadCount === 'number');
225    });
226  
227    test('byChannel has sms entries for our test data', async () => {
228      // Reset inbound to unread so there's data
229      db.exec("UPDATE messages SET read_at = NULL WHERE direction = 'inbound'");
230  
231      const stats = await getInboundStats();
232      const smsChannel = stats.byChannel.find(c => c.channel === 'sms');
233      assert.ok(smsChannel, 'should have sms channel stats');
234      assert.ok(smsChannel.total >= 1);
235      assert.ok(smsChannel.unread >= 0);
236    });
237  
238    test('bySentiment groups by sentiment value', async () => {
239      const stats = await getInboundStats();
240      // We have 'neutral' and 'positive' inbound messages
241      const neutralEntry = stats.bySentiment.find(s => s.sentiment === 'neutral');
242      assert.ok(
243        neutralEntry || stats.bySentiment.length === 0,
244        'should have sentiment entries or empty'
245      );
246    });
247  });