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 });