inbound-sms.integration.test.js
1 /** 2 * Integration Tests for Inbound SMS Module with Twilio API 3 * 4 * These tests verify webhook handling and SMS polling functionality. 5 * Note: Many Twilio API operations don't work with test credentials, 6 * so these tests focus on simulated webhook scenarios and basic API connectivity. 7 * 8 * Requires TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in environment. 9 * 10 * Run with: npm run test:integration 11 */ 12 13 import { test, describe, mock, before, after } from 'node:test'; 14 import assert from 'node:assert'; 15 import Database from 'better-sqlite3'; 16 import { createLazyPgMock } from '../helpers/pg-mock.js'; 17 import twilio from 'twilio'; 18 import dotenv from 'dotenv'; 19 20 dotenv.config(); 21 22 // Use test API credentials if available, otherwise production credentials 23 const twilioAccountSid = process.env.TWILIO_TEST_ACCOUNT_SID || process.env.TWILIO_ACCOUNT_SID; 24 const twilioAuthToken = process.env.TWILIO_TEST_AUTH_TOKEN || process.env.TWILIO_AUTH_TOKEN; 25 26 // Twilio Magic Test Phone Numbers 27 const TEST_NUMBERS = { 28 validFrom: '+15005550006', 29 validTo: '+15005550006', 30 }; 31 32 // Skip tests if no API credentials available 33 if (!twilioAccountSid || !twilioAuthToken) { 34 console.log('Skipping Twilio inbound integration tests (no credentials found)'); 35 process.exit(0); 36 } 37 38 // Set environment variables BEFORE importing SMS module 39 process.env.TWILIO_ACCOUNT_SID = twilioAccountSid; 40 process.env.TWILIO_AUTH_TOKEN = twilioAuthToken; 41 process.env.TWILIO_PHONE_NUMBER = TEST_NUMBERS.validTo; 42 43 // ── Lazy DB mock: allows per-describe DB swapping ───────────────────────── 44 let _currentDb = null; 45 mock.module('../../src/utils/db.js', { namedExports: createLazyPgMock(() => _currentDb) }); 46 47 const { findOutreachByPhone, storeInboundSMS } = await import('../../src/inbound/sms.js'); 48 49 const SCHEMA_SQL = ` 50 CREATE TABLE IF NOT EXISTS sites ( 51 id INTEGER PRIMARY KEY AUTOINCREMENT, 52 domain TEXT NOT NULL, 53 landing_page_url TEXT NOT NULL, 54 keyword TEXT, 55 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 56 rescored_at DATETIME 57 ); 58 59 CREATE TABLE IF NOT EXISTS messages ( 60 id INTEGER PRIMARY KEY AUTOINCREMENT, 61 site_id INTEGER NOT NULL, 62 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 63 contact_method TEXT NOT NULL CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')), 64 contact_uri TEXT, 65 message_body TEXT, 66 subject_line TEXT, 67 approval_status TEXT, 68 delivery_status TEXT, 69 error_message TEXT, 70 sentiment TEXT, 71 intent TEXT, 72 raw_payload TEXT, 73 message_type TEXT DEFAULT 'outreach', 74 sent_at DATETIME, 75 delivered_at DATETIME, 76 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 77 read_at TEXT, 78 FOREIGN KEY (site_id) REFERENCES sites(id) 79 ); 80 81 CREATE TABLE IF NOT EXISTS countries ( 82 country_code TEXT PRIMARY KEY, 83 country_name TEXT, 84 google_domain TEXT, 85 language_code TEXT, 86 currency_code TEXT, 87 is_active INTEGER DEFAULT 1, 88 sms_enabled INTEGER DEFAULT 1, 89 requires_gdpr_check INTEGER DEFAULT 0, 90 twilio_phone_number TEXT 91 ); 92 93 CREATE TABLE IF NOT EXISTS config ( 94 key TEXT PRIMARY KEY, 95 value TEXT NOT NULL, 96 description TEXT 97 ); 98 `; 99 100 function createDb() { 101 const db = new Database(':memory:'); 102 db.pragma('foreign_keys = ON'); 103 db.exec(SCHEMA_SQL); 104 105 db.prepare( 106 'INSERT INTO sites (id, domain, landing_page_url, keyword) VALUES (?, ?, ?, ?)' 107 ).run(1, 'testsite.com', 'https://testsite.com', 'test keyword'); 108 109 db.prepare( 110 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, direction, delivery_status, sent_at) 111 VALUES (1, 1, 'sms', ?, 'Test proposal', 'outbound', 'sent', datetime('now', '-1 day'))` 112 ).run(TEST_NUMBERS.validFrom); 113 114 db.prepare( 115 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, direction, delivery_status, sent_at) 116 VALUES (2, 1, 'sms', '0412345678', 'Test proposal 2', 'outbound', 'sent', datetime('now', '-1 day'))` 117 ).run(); 118 119 db.prepare( 120 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, direction, delivery_status, sent_at) 121 VALUES (3, 1, 'sms', '5551234567', 'Test proposal 3', 'outbound', 'sent', datetime('now', '-1 day'))` 122 ).run(); 123 124 return db; 125 } 126 127 describe('Twilio Inbound SMS Integration Tests', () => { 128 let db; 129 let twilioClient; 130 131 before(() => { 132 db = createDb(); 133 _currentDb = db; 134 twilioClient = twilio(twilioAccountSid, twilioAuthToken); 135 }); 136 137 after(() => { 138 _currentDb = null; 139 db?.close(); 140 }); 141 142 describe('Webhook Payload Handling (Simulated)', () => { 143 test('should validate Twilio webhook payload structure', () => { 144 const mockWebhookPayload = { 145 MessageSid: 'SM1234567890abcdef1234567890abcdef', 146 AccountSid: twilioAccountSid, 147 From: TEST_NUMBERS.validFrom, 148 To: TEST_NUMBERS.validTo, 149 Body: 'YES, I am interested!', 150 NumMedia: '0', 151 NumSegments: '1', 152 SmsStatus: 'received', 153 SmsSid: 'SM1234567890abcdef1234567890abcdef', 154 }; 155 156 assert.ok(mockWebhookPayload.MessageSid, 'Should have MessageSid'); 157 assert.ok(mockWebhookPayload.From, 'Should have From number'); 158 assert.ok(mockWebhookPayload.To, 'Should have To number'); 159 assert.ok(mockWebhookPayload.Body, 'Should have Body text'); 160 assert.strictEqual(mockWebhookPayload.SmsStatus, 'received'); 161 assert.match(mockWebhookPayload.MessageSid, /^SM[a-f0-9]{32}$/); 162 }); 163 164 test('should find outreach by phone number (exact match)', async () => { 165 const outreach = await findOutreachByPhone(TEST_NUMBERS.validFrom); 166 assert.ok(outreach, 'Should find matching outreach'); 167 assert.strictEqual(outreach.id, 1); 168 assert.strictEqual(outreach.contact_method, 'sms'); 169 }); 170 171 test('should store inbound SMS in conversations table', async () => { 172 const outreachId = 1; 173 const fromNumber = TEST_NUMBERS.validFrom; 174 const messageBody = 'Yes, please send me more info'; 175 176 const conversationId = await storeInboundSMS(outreachId, fromNumber, messageBody); 177 178 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 179 180 assert.ok(conversation, 'Conversation should exist'); 181 assert.strictEqual(conversation.site_id, outreachId); 182 assert.strictEqual(conversation.direction, 'inbound'); 183 assert.strictEqual(conversation.contact_method, 'sms'); 184 assert.strictEqual(conversation.contact_uri, fromNumber); 185 assert.strictEqual(conversation.message_body, messageBody); 186 }); 187 188 test('should handle opt-out keywords', async () => { 189 const optOutKeywords = ['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']; 190 191 for (const keyword of optOutKeywords) { 192 const conversationId = await storeInboundSMS(1, TEST_NUMBERS.validFrom, keyword); 193 assert.ok(conversationId > 0, `Should store ${keyword} message`); 194 } 195 }); 196 197 test('should handle messages with special characters', async () => { 198 const messageWithEmoji = "Yes! I'm interested Price = $500?"; 199 const conversationId = await storeInboundSMS(1, TEST_NUMBERS.validFrom, messageWithEmoji); 200 201 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 202 203 assert.ok(conversation); 204 assert.strictEqual(conversation.message_body, messageWithEmoji); 205 }); 206 207 test('should handle multi-segment long messages', async () => { 208 const longMessage = 'A'.repeat(500); 209 const conversationId = await storeInboundSMS(1, TEST_NUMBERS.validFrom, longMessage); 210 211 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 212 213 assert.ok(conversation); 214 assert.strictEqual(conversation.message_body.length, 500); 215 }); 216 }); 217 218 describe('Phone Number Matching', () => { 219 test('should match exact E.164 format', async () => { 220 const outreach = await findOutreachByPhone('+15005550006'); 221 assert.ok(outreach); 222 assert.strictEqual(outreach.id, 1); 223 }); 224 225 test('should match without + prefix', async () => { 226 const outreach = await findOutreachByPhone('15005550006'); 227 assert.ok(outreach); 228 }); 229 230 test('should match Australian mobile formats', async () => { 231 const formats = ['0412345678', '61412345678', '+61412345678']; 232 233 for (const format of formats) { 234 const outreach = await findOutreachByPhone(format); 235 if (outreach) { 236 assert.strictEqual(outreach.id, 2, `Should match ${format}`); 237 } 238 } 239 }); 240 241 test('should match US 10-digit formats', async () => { 242 const formats = ['5551234567', '15551234567', '+15551234567']; 243 244 for (const format of formats) { 245 const outreach = await findOutreachByPhone(format); 246 if (outreach) { 247 assert.strictEqual(outreach.id, 3, `Should match ${format}`); 248 } 249 } 250 }); 251 252 test('should return undefined for non-existent number', async () => { 253 const outreach = await findOutreachByPhone('+19999999999'); 254 assert.ok(outreach == null, `Expected null/undefined, got ${outreach}`); 255 }); 256 }); 257 258 describe('Database Conversation Storage', () => { 259 test('should store multiple messages from same sender', async () => { 260 await storeInboundSMS(1, TEST_NUMBERS.validFrom, 'First message'); 261 await storeInboundSMS(1, TEST_NUMBERS.validFrom, 'Second message'); 262 await storeInboundSMS(1, TEST_NUMBERS.validFrom, 'Third message'); 263 264 const count = db 265 .prepare( 266 `SELECT COUNT(*) as count FROM messages 267 WHERE site_id = 1 AND direction = 'inbound' AND message_body IN ('First message','Second message','Third message')` 268 ) 269 .get(); 270 271 assert.strictEqual(count.count, 3); 272 }); 273 274 test('should set created_at timestamp automatically', async () => { 275 const conversationId = await storeInboundSMS(1, TEST_NUMBERS.validFrom, 'Timestamp test'); 276 277 const conversation = db.prepare('SELECT * FROM messages WHERE id = ?').get(conversationId); 278 279 assert.ok(conversation.created_at); 280 const timestamp = new Date(conversation.created_at); 281 assert.ok(!isNaN(timestamp.getTime())); 282 }); 283 }); 284 285 describe('Twilio Client Initialization', () => { 286 test('should initialize Twilio client with credentials', () => { 287 assert.ok(twilioClient, 'Twilio client should be initialized'); 288 assert.ok(twilioClient.messages, 'Client should have messages resource'); 289 }); 290 291 test('should validate test phone number formats', () => { 292 Object.values(TEST_NUMBERS).forEach(number => { 293 assert.ok(number.startsWith('+'), `${number} should start with +`); 294 assert.match(number, /^\+\d+$/, `${number} should be E.164 format`); 295 }); 296 }); 297 }); 298 299 describe('Webhook Validation', () => { 300 test('should validate webhook request signature format', () => { 301 const validateRequest = 302 typeof twilio.validateRequest === 'function' || 303 typeof twilio.validateExpressRequest === 'function'; 304 305 assert.ok(validateRequest, 'Twilio should provide signature validation'); 306 }); 307 308 test('should handle webhook authentication headers', () => { 309 const mockHeaders = { 310 'X-Twilio-Signature': 'mock_signature_value', 311 }; 312 313 assert.ok(mockHeaders['X-Twilio-Signature']); 314 assert.strictEqual(typeof mockHeaders['X-Twilio-Signature'], 'string'); 315 }); 316 }); 317 });