/ tests / inbound / inbound-sms.integration.test.js
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  });