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