inbound-sms.test.js
1 /** 2 * Tests for Inbound SMS Module 3 * 4 * NOTE: This test file has limitations due to external API dependencies: 5 * - pollInboundSMS requires Twilio client initialization and API calls 6 * - processPendingReplies requires Twilio client for sending messages 7 * 8 * Current test coverage includes: 9 * - findOutreachByPhone with various phone number formats 10 * - storeInboundSMS with valid/invalid data 11 * - processPendingReplies query logic 12 * 13 * Not tested in unit tests (covered by mocked tests): 14 * - pollInboundSMS Twilio API integration (see inbound-sms-mocked.test.js) 15 * - processPendingReplies actual sending (see inbound-sms-mocked.test.js) 16 * - STOP/START keyword handling (tested in compliance.test.js) 17 * - getLastPollTime / updateLastPollTime (internal functions) 18 * 19 * Integration testing: 20 * - tests/inbound-sms.integration.test.js - Real Twilio API calls 21 * - Manual E2E pipeline testing 22 * - Production usage monitoring 23 */ 24 25 import { test, mock } from 'node:test'; 26 import assert from 'node:assert'; 27 import Database from 'better-sqlite3'; 28 import { createPgMock } from '../helpers/pg-mock.js'; 29 30 // Shared in-memory database — minimal schema covering what sms.js needs 31 const db = new Database(':memory:'); 32 db.pragma('foreign_keys = ON'); 33 db.exec(` 34 CREATE TABLE IF NOT EXISTS sites ( 35 id INTEGER PRIMARY KEY, 36 domain TEXT NOT NULL UNIQUE, 37 landing_page_url TEXT, 38 keyword TEXT, 39 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 40 rescored_at DATETIME 41 ); 42 43 CREATE TABLE IF NOT EXISTS messages ( 44 id INTEGER PRIMARY KEY, 45 site_id INTEGER NOT NULL REFERENCES sites(id), 46 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 47 contact_method TEXT CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')), 48 contact_uri TEXT, 49 message_body TEXT, 50 subject_line TEXT, 51 message_type TEXT DEFAULT 'outreach' CHECK(message_type IN ('outreach', 'reply')), 52 approval_status TEXT DEFAULT 'pending', 53 delivery_status TEXT, 54 sentiment TEXT, 55 intent TEXT, 56 raw_payload TEXT, 57 is_read INTEGER DEFAULT 0, 58 read_at TEXT, 59 processed_at TEXT, 60 sent_at DATETIME, 61 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 62 ); 63 64 CREATE TABLE IF NOT EXISTS config ( 65 key TEXT PRIMARY KEY, 66 value TEXT, 67 description TEXT 68 ); 69 70 CREATE TABLE IF NOT EXISTS countries ( 71 country_code TEXT PRIMARY KEY, 72 country_name TEXT, 73 google_domain TEXT, 74 language_code TEXT, 75 currency_code TEXT, 76 is_active INTEGER DEFAULT 1, 77 sms_enabled INTEGER DEFAULT 1, 78 requires_gdpr_check INTEGER DEFAULT 0, 79 twilio_phone_number TEXT 80 ); 81 `); 82 83 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 84 85 const { 86 findOutreachByPhone, 87 storeInboundSMS, 88 processPendingReplies, 89 } = await import('../../src/inbound/sms.js'); 90 91 test('Inbound SMS Module', async t => { 92 await t.test('Setup test database', () => { 93 // Clear and seed test data 94 db.exec('DELETE FROM messages; DELETE FROM sites;'); 95 96 db.prepare('INSERT INTO sites (id, domain, landing_page_url, keyword) VALUES (?, ?, ?, ?)') 97 .run(1, 'testsite.com', 'https://testsite.com', 'test keyword'); 98 99 db.prepare( 100 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) 101 VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 102 ).run(1, 1, 'sms', '+1234567890', 'Test proposal', 'Test subject', 'outbound', 'sent'); 103 104 db.prepare( 105 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) 106 VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 107 ).run(2, 1, 'sms', '+61412345678', 'Test proposal 2', 'Test subject 2', 'outbound', 'sent'); 108 }); 109 110 await t.test('findOutreachByPhone', async t => { 111 await t.test('should find outreach by exact phone match', async () => { 112 const outreach = await findOutreachByPhone('+1234567890'); 113 assert.ok(outreach, 'Should find outreach'); 114 assert.strictEqual(outreach.id, 1); 115 assert.strictEqual(outreach.contact_method, 'sms'); 116 }); 117 118 await t.test('should find outreach by normalized phone (no spaces/dashes)', async () => { 119 const outreach = await findOutreachByPhone('123-456-7890'); 120 assert.ok(outreach, 'Should find outreach'); 121 assert.strictEqual(outreach.id, 1); 122 }); 123 124 await t.test('should find outreach by last 10 digits', async () => { 125 const outreach = await findOutreachByPhone('1234567890'); // Without + 126 assert.ok(outreach, 'Should find outreach'); 127 assert.strictEqual(outreach.id, 1); 128 }); 129 130 await t.test('should find Australian mobile number', async () => { 131 const outreach = await findOutreachByPhone('0412345678'); 132 assert.ok(outreach, 'Should find outreach'); 133 assert.strictEqual(outreach.id, 2); 134 }); 135 136 await t.test('should return undefined for non-existent phone', async () => { 137 const outreach = await findOutreachByPhone('+9999999999'); 138 assert.ok(outreach == null, `expected null or undefined, got ${outreach}`); 139 }); 140 141 await t.test('should return most recent outreach if multiple matches', async () => { 142 // Insert another outreach with same phone 143 db.prepare( 144 `INSERT INTO messages (site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status) 145 VALUES (?, ?, ?, ?, ?, ?, ?)` 146 ).run(1, 'sms', '+1234567890', 'Newer proposal', 'Newer subject', 'outbound', 'sent'); 147 148 const outreach = await findOutreachByPhone('+1234567890'); 149 assert.ok(outreach, 'Should find outreach'); 150 // Should return the most recent one (latest sent_at or created_at) 151 assert.strictEqual(outreach.contact_uri, '+1234567890'); 152 }); 153 }); 154 155 await t.test('storeInboundSMS', async t => { 156 await t.test('should store inbound SMS message', async () => { 157 const conversationId = await storeInboundSMS(1, '+1234567890', 'Hello, I am interested!'); 158 159 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 160 161 assert.ok(conversation, 'Conversation should exist'); 162 assert.strictEqual(conversation.site_id, 1); 163 assert.strictEqual(conversation.direction, 'inbound'); 164 assert.strictEqual(conversation.contact_method, 'sms'); 165 assert.strictEqual(conversation.contact_uri, '+1234567890'); 166 assert.strictEqual(conversation.message_body, 'Hello, I am interested!'); 167 assert.ok(conversation.created_at, 'Should have created_at timestamp'); 168 }); 169 170 await t.test('should handle special characters in message', async () => { 171 const conversationId = await storeInboundSMS( 172 1, 173 '+1234567890', 174 "Yes! Let's discuss. 😊 Price = $500?" 175 ); 176 177 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 178 179 assert.ok(conversation, 'Conversation should exist'); 180 assert.strictEqual(conversation.message_body, "Yes! Let's discuss. 😊 Price = $500?"); 181 }); 182 183 await t.test('should fail with invalid site_id (foreign key)', async () => { 184 await assert.rejects( 185 () => storeInboundSMS(9999, '+1234567890', 'Test message'), 186 { 187 message: /FOREIGN KEY constraint failed/, 188 } 189 ); 190 }); 191 192 await t.test('should deduplicate identical messages from same sender within 5 min', async () => { 193 const uniqueMsg = `Dedup test ${Date.now()}`; 194 const firstId = await storeInboundSMS(1, '+1111111111', uniqueMsg); 195 const secondId = await storeInboundSMS(1, '+1111111111', uniqueMsg); 196 197 // Second call should return the first conversation's ID (dedup hit) 198 assert.strictEqual(secondId, firstId, 'Should return existing conversation ID on dedup'); 199 200 // Verify only one row exists 201 const count = db 202 .prepare( 203 `SELECT COUNT(*) as cnt FROM messages 204 WHERE contact_uri = '+1111111111' AND message_body = ?` 205 ) 206 .get(uniqueMsg); 207 208 assert.strictEqual(count.cnt, 1, 'Should have exactly one conversation, not two'); 209 }); 210 211 await t.test('should allow different messages from same sender', async () => { 212 const id1 = await storeInboundSMS(1, '+2222222222', 'Message A'); 213 const id2 = await storeInboundSMS(1, '+2222222222', 'Message B'); 214 215 assert.notStrictEqual(id1, id2, 'Different messages should create separate conversations'); 216 }); 217 218 await t.test('should allow same message from different senders', async () => { 219 const sameMsg = `Same message ${Date.now()}`; 220 const id1 = await storeInboundSMS(1, '+3333333333', sameMsg); 221 const id2 = await storeInboundSMS(1, '+4444444444', sameMsg); 222 223 assert.notStrictEqual(id1, id2, 'Same message from different senders should both be stored'); 224 }); 225 }); 226 227 await t.test('processPendingReplies', async t => { 228 await t.test('should return zero results when no pending replies', async () => { 229 const result = await processPendingReplies(); 230 231 assert.strictEqual(result.sent, 0); 232 assert.strictEqual(result.failed, 0); 233 }); 234 235 await t.test('should identify pending outbound messages', async () => { 236 const countBefore = db 237 .prepare( 238 `SELECT COUNT(*) as count FROM messages 239 WHERE direction = 'outbound' AND contact_method = 'sms' AND sent_at IS NULL` 240 ) 241 .get().count; 242 243 db.prepare( 244 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body) 245 VALUES (?, 'outbound', 'sms', ?, ?)` 246 ).run(1, 'operator@example.com', 'Thanks for your interest! Let me send you more details.'); 247 248 // Verify the new pending reply was inserted (count increased by 1) 249 const countAfter = db 250 .prepare( 251 `SELECT COUNT(*) as count FROM messages 252 WHERE direction = 'outbound' AND contact_method = 'sms' AND sent_at IS NULL` 253 ) 254 .get().count; 255 256 assert.strictEqual( 257 countAfter, 258 countBefore + 1, 259 'Should have 1 more pending reply after insert' 260 ); 261 }); 262 263 // Note: Testing actual sending requires mocking Twilio (see inbound-sms-mocked.test.js) 264 }); 265 });