/ tests / inbound / inbound-sms-mocked.test.js
inbound-sms-mocked.test.js
  1  /**
  2   * Mocked Unit Tests for Inbound SMS Module
  3   *
  4   * Tests functions that require external API calls (Twilio)
  5   * by mocking the twilio module and using an in-memory database with full schema.
  6   *
  7   * Covers uncovered lines:
  8   * - pollInboundSMS (lines 106-245) - Twilio API polling, STOP/START handling, dedup
  9   * - processPendingReplies (lines 273-302) - Sending operator replies via SMS
 10   */
 11  
 12  import { test, describe, mock, beforeEach } from 'node:test';
 13  import assert from 'node:assert';
 14  import Database from 'better-sqlite3';
 15  import { readFileSync } from 'fs';
 16  import { join, dirname } from 'path';
 17  import { fileURLToPath } from 'url';
 18  import { createPgMock } from '../helpers/pg-mock.js';
 19  
 20  const __filename = fileURLToPath(import.meta.url);
 21  const __dirname = dirname(__filename);
 22  const projectRoot = join(__dirname, '../..');
 23  const schemaPath = join(projectRoot, 'db/schema.sql');
 24  
 25  // === MOCK SETUP ===
 26  
 27  // Mock Twilio messages list and create
 28  const mockMessagesList = mock.fn(async () => []);
 29  const mockMessagesCreate = mock.fn(async () => ({ sid: 'mock-msg-sid' }));
 30  
 31  // Mock the twilio constructor
 32  const mockTwilioClient = {
 33    messages: {
 34      list: mockMessagesList,
 35      create: mockMessagesCreate,
 36    },
 37  };
 38  
 39  const mockTwilioConstructor = mock.fn(() => mockTwilioClient);
 40  
 41  mock.module('twilio', {
 42    defaultExport: mockTwilioConstructor,
 43  });
 44  
 45  // Mock sendSMS used by processPendingReplies
 46  const mockSendSMS = mock.fn(async () => ({ sid: 'mock-sms-sid', status: 'sent' }));
 47  
 48  mock.module('../../src/outreach/sms.js', {
 49    namedExports: {
 50      sendSMS: mockSendSMS,
 51    },
 52  });
 53  
 54  // Shared in-memory database — created once, schema loaded from production schema file
 55  const db = new Database(':memory:');
 56  const schema = readFileSync(schemaPath, 'utf-8');
 57  db.exec(schema);
 58  db.pragma('foreign_keys = ON');
 59  
 60  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 61  
 62  // Import module under test AFTER all mocks
 63  const { findOutreachByPhone, storeInboundSMS, pollInboundSMS, processPendingReplies } =
 64    await import('../../src/inbound/sms.js');
 65  
 66  /**
 67   * Helper: clear test tables between tests
 68   */
 69  function clearTables() {
 70    db.exec('DELETE FROM messages; DELETE FROM sites; DELETE FROM opt_outs; DELETE FROM countries');
 71  }
 72  
 73  /**
 74   * Helper: insert standard test fixtures (site + outreaches + countries)
 75   */
 76  function insertTestData() {
 77    db.prepare(
 78      `INSERT INTO sites (id, domain, landing_page_url, keyword, status)
 79       VALUES (?, ?, ?, ?, ?)`
 80    ).run(1, 'testsite.com', 'https://testsite.com', 'web design', 'outreach_sent');
 81  
 82    db.prepare(
 83      `INSERT INTO sites (id, domain, landing_page_url, keyword, status)
 84       VALUES (?, ?, ?, ?, ?)`
 85    ).run(2, 'another.com', 'https://another.com', 'seo services', 'outreach_sent');
 86  
 87    db.prepare(
 88      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
 89       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-1 hour'))`
 90    ).run(1, 1, 'sms', '+1234567890', 'Great proposal', 'Improve your site', 'outbound', 'sent');
 91  
 92    db.prepare(
 93      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
 94       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-2 hours'))`
 95    ).run(2, 2, 'sms', '+61412345678', 'Another proposal', 'SEO for you', 'outbound', 'sent');
 96  
 97    db.prepare(
 98      `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at)
 99       VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-3 hours'))`
100    ).run(3, 1, 'email', 'owner@testsite.com', 'Email proposal', 'Email subject', 'outbound', 'sent');
101  
102    // Insert country with Twilio phone number so pollInboundSMS finds numbers to poll
103    db.prepare(
104      `INSERT INTO countries (country_code, country_name, google_domain, language_code, timezone,
105         currency_code, currency_symbol, date_format, price_usd, pricing_tier,
106         twilio_phone_number, sms_enabled, is_active)
107       VALUES ('US', 'United States', 'google.com', 'en', 'America/New_York',
108         'USD', '$', 'MM/DD/YYYY', 29700, 'Premium',
109         '+15551234567', 1, 1)`
110    ).run();
111  }
112  
113  describe('Inbound SMS Module - Mocked Tests', () => {
114    beforeEach(() => {
115      clearTables();
116      insertTestData();
117  
118      // Reset mocks
119      mockMessagesList.mock.resetCalls();
120      mockMessagesList.mock.mockImplementation(async () => []);
121      mockMessagesCreate.mock.resetCalls();
122      mockMessagesCreate.mock.mockImplementation(async () => ({ sid: 'mock-msg-sid' }));
123      mockTwilioConstructor.mock.resetCalls();
124      mockSendSMS.mock.resetCalls();
125      mockSendSMS.mock.mockImplementation(async () => ({ sid: 'mock-sms-sid', status: 'sent' }));
126    });
127  
128    describe('pollInboundSMS', () => {
129      test('should throw error when Twilio credentials are missing', async () => {
130        const savedSid = process.env.TWILIO_ACCOUNT_SID;
131        const savedToken = process.env.TWILIO_AUTH_TOKEN;
132        const savedPhone = process.env.TWILIO_PHONE_NUMBER;
133        delete process.env.TWILIO_ACCOUNT_SID;
134        delete process.env.TWILIO_AUTH_TOKEN;
135        delete process.env.TWILIO_PHONE_NUMBER;
136  
137        await assert.rejects(
138          async () => {
139            await pollInboundSMS();
140          },
141          {
142            message: /Missing Twilio credentials/,
143          }
144        );
145  
146        // Restore
147        if (savedSid) process.env.TWILIO_ACCOUNT_SID = savedSid;
148        if (savedToken) process.env.TWILIO_AUTH_TOKEN = savedToken;
149        if (savedPhone) process.env.TWILIO_PHONE_NUMBER = savedPhone;
150      });
151  
152      test('should throw when only TWILIO_ACCOUNT_SID is set', async () => {
153        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
154        delete process.env.TWILIO_AUTH_TOKEN;
155        delete process.env.TWILIO_PHONE_NUMBER;
156  
157        await assert.rejects(
158          async () => {
159            await pollInboundSMS();
160          },
161          {
162            message: /Missing Twilio credentials/,
163          }
164        );
165  
166        delete process.env.TWILIO_ACCOUNT_SID;
167      });
168  
169      test('should return zero counts when no messages returned', async () => {
170        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
171        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
172        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
173  
174        mockMessagesList.mock.mockImplementation(async () => []);
175  
176        const result = await pollInboundSMS();
177  
178        assert.strictEqual(result.processed, 0);
179        assert.strictEqual(result.stored, 0);
180        assert.strictEqual(result.unmatched, 0);
181  
182        // Verify Twilio client was constructed
183        assert.strictEqual(mockTwilioConstructor.mock.calls.length, 1);
184        assert.strictEqual(mockTwilioConstructor.mock.calls[0].arguments[0], 'ACtest123');
185        assert.strictEqual(mockTwilioConstructor.mock.calls[0].arguments[1], 'test-auth-token');
186  
187        // Verify messages.list was called with correct params
188        assert.strictEqual(mockMessagesList.mock.calls.length, 1);
189        const listArgs = mockMessagesList.mock.calls[0].arguments[0];
190        assert.strictEqual(listArgs.to, '+15551234567');
191        assert.strictEqual(listArgs.limit, 100);
192        assert.ok(listArgs.dateSentAfter instanceof Date);
193  
194        delete process.env.TWILIO_ACCOUNT_SID;
195        delete process.env.TWILIO_AUTH_TOKEN;
196        delete process.env.TWILIO_PHONE_NUMBER;
197      });
198  
199      test('should process and store matched inbound SMS messages', async () => {
200        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
201        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
202        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
203  
204        const recentDate = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
205  
206        mockMessagesList.mock.mockImplementation(async () => [
207          {
208            sid: 'SM-msg-001',
209            from: '+1234567890',
210            to: '+15551234567',
211            body: 'Yes, interested!',
212            status: 'received',
213            dateSent: recentDate,
214          },
215        ]);
216  
217        const result = await pollInboundSMS();
218  
219        assert.strictEqual(result.processed, 1);
220        assert.strictEqual(result.stored, 1);
221        assert.strictEqual(result.unmatched, 0);
222  
223        // Verify conversation was stored in DB
224        const conversations = db
225          .prepare("SELECT * FROM messages WHERE direction = 'inbound' AND contact_method = 'sms'")
226          .all();
227  
228        assert.ok(conversations.length >= 1, 'Should have stored at least one conversation');
229  
230        const conv = conversations[conversations.length - 1];
231        assert.strictEqual(conv.site_id, 1);
232        assert.strictEqual(conv.contact_uri, '+1234567890');
233        assert.strictEqual(conv.message_body, 'Yes, interested!');
234        assert.ok(conv.raw_payload, 'Should have raw_payload');
235  
236        const payload = JSON.parse(conv.raw_payload);
237        assert.strictEqual(payload.sid, 'SM-msg-001');
238        assert.strictEqual(payload.from, '+1234567890');
239  
240        delete process.env.TWILIO_ACCOUNT_SID;
241        delete process.env.TWILIO_AUTH_TOKEN;
242        delete process.env.TWILIO_PHONE_NUMBER;
243      });
244  
245      test('should count unmatched messages for unknown phone numbers', async () => {
246        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
247        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
248        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
249  
250        mockMessagesList.mock.mockImplementation(async () => [
251          {
252            sid: 'SM-unknown-001',
253            from: '+9999999999',
254            to: '+15551234567',
255            body: 'Who are you?',
256            status: 'received',
257            dateSent: new Date(),
258          },
259        ]);
260  
261        const result = await pollInboundSMS();
262  
263        assert.strictEqual(result.processed, 1);
264        assert.strictEqual(result.stored, 0);
265        assert.strictEqual(result.unmatched, 1);
266  
267        delete process.env.TWILIO_ACCOUNT_SID;
268        delete process.env.TWILIO_AUTH_TOKEN;
269        delete process.env.TWILIO_PHONE_NUMBER;
270      });
271  
272      test('should process STOP keyword and send opt-out confirmation', async () => {
273        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
274        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
275        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
276  
277        mockMessagesList.mock.mockImplementation(async () => [
278          {
279            sid: 'SM-stop-001',
280            from: '+1234567890',
281            to: '+15551234567',
282            body: 'STOP',
283            status: 'received',
284            dateSent: new Date(),
285          },
286        ]);
287  
288        const result = await pollInboundSMS();
289  
290        assert.strictEqual(result.processed, 1);
291        assert.strictEqual(result.stored, 1); // STOP messages are still stored
292  
293        // Twilio handles STOP auto-reply natively; no confirmation sent by our code
294        assert.strictEqual(mockMessagesCreate.mock.calls.length, 0);
295  
296        // Verify opt-out was recorded in database
297        const optOut = db
298          .prepare("SELECT * FROM opt_outs WHERE phone = ? AND method = 'sms'")
299          .get('+1234567890');
300        assert.ok(optOut, 'Should have opt-out record');
301  
302        delete process.env.TWILIO_ACCOUNT_SID;
303        delete process.env.TWILIO_AUTH_TOKEN;
304        delete process.env.TWILIO_PHONE_NUMBER;
305      });
306  
307      test('should process START keyword and send re-subscription confirmation', async () => {
308        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
309        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
310        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
311  
312        // First, add the phone to opt-outs so START can remove it
313        db.prepare("INSERT INTO opt_outs (phone, method) VALUES (?, 'sms')").run('+1234567890');
314  
315        mockMessagesList.mock.mockImplementation(async () => [
316          {
317            sid: 'SM-start-001',
318            from: '+1234567890',
319            to: '+15551234567',
320            body: 'START',
321            status: 'received',
322            dateSent: new Date(),
323          },
324        ]);
325  
326        const result = await pollInboundSMS();
327  
328        assert.strictEqual(result.processed, 1);
329        assert.strictEqual(result.stored, 1);
330  
331        // Verify re-subscription confirmation was sent
332        // Both STOP and START keyword checks run, so we check for START confirmation
333        const createCalls = mockMessagesCreate.mock.calls;
334        const startConfirmation = createCalls.find(call =>
335          call.arguments[0].body.includes('resubscribed')
336        );
337        assert.ok(startConfirmation, 'Should have sent re-subscription confirmation');
338  
339        // Verify opt-out was removed from database
340        const optOut = db
341          .prepare("SELECT * FROM opt_outs WHERE phone = ? AND method = 'sms'")
342          .get('+1234567890');
343        assert.strictEqual(optOut, undefined, 'Opt-out record should be removed');
344  
345        delete process.env.TWILIO_ACCOUNT_SID;
346        delete process.env.TWILIO_AUTH_TOKEN;
347        delete process.env.TWILIO_PHONE_NUMBER;
348      });
349  
350      test('should skip already stored messages (deduplication by sid)', async () => {
351        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
352        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
353        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
354  
355        // Pre-insert a conversation with the same sid in raw_payload
356        db.prepare(
357          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, raw_payload)
358           VALUES (?, 'inbound', 'sms', ?, ?, ?)`
359        ).run(1, '+1234567890', 'Already stored', JSON.stringify({ sid: 'SM-dup-001' }));
360  
361        mockMessagesList.mock.mockImplementation(async () => [
362          {
363            sid: 'SM-dup-001',
364            from: '+1234567890',
365            to: '+15551234567',
366            body: 'Already stored',
367            status: 'received',
368            dateSent: new Date(),
369          },
370        ]);
371  
372        const result = await pollInboundSMS();
373  
374        // Message should be processed but not stored again
375        assert.strictEqual(result.processed, 1);
376        assert.strictEqual(result.stored, 0);
377        assert.strictEqual(result.unmatched, 0);
378  
379        delete process.env.TWILIO_ACCOUNT_SID;
380        delete process.env.TWILIO_AUTH_TOKEN;
381        delete process.env.TWILIO_PHONE_NUMBER;
382      });
383  
384      test('should handle Twilio API errors gracefully', async () => {
385        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
386        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
387        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
388  
389        mockMessagesList.mock.mockImplementation(async () => {
390          throw new Error('Twilio API error: 429 Too Many Requests');
391        });
392  
393        await assert.rejects(
394          async () => {
395            await pollInboundSMS();
396          },
397          {
398            message: /Twilio API error/,
399          }
400        );
401  
402        delete process.env.TWILIO_ACCOUNT_SID;
403        delete process.env.TWILIO_AUTH_TOKEN;
404        delete process.env.TWILIO_PHONE_NUMBER;
405      });
406  
407      test('should process multiple messages in one poll cycle', async () => {
408        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
409        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
410        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
411  
412        const recentDate = new Date(Date.now() - 60 * 60 * 1000);
413  
414        mockMessagesList.mock.mockImplementation(async () => [
415          {
416            sid: 'SM-multi-001',
417            from: '+1234567890',
418            to: '+15551234567',
419            body: 'First reply',
420            status: 'received',
421            dateSent: recentDate,
422          },
423          {
424            sid: 'SM-multi-002',
425            from: '+61412345678',
426            to: '+15551234567',
427            body: 'Second reply from AU',
428            status: 'received',
429            dateSent: recentDate,
430          },
431          {
432            sid: 'SM-multi-003',
433            from: '+9999888777',
434            to: '+15551234567',
435            body: 'Unknown sender',
436            status: 'received',
437            dateSent: recentDate,
438          },
439        ]);
440  
441        const result = await pollInboundSMS();
442  
443        assert.strictEqual(result.processed, 3);
444        assert.strictEqual(result.stored, 2); // 2 matched
445        assert.strictEqual(result.unmatched, 1); // 1 unknown
446  
447        delete process.env.TWILIO_ACCOUNT_SID;
448        delete process.env.TWILIO_AUTH_TOKEN;
449        delete process.env.TWILIO_PHONE_NUMBER;
450      });
451  
452      test('should handle opt-out confirmation failure gracefully', async () => {
453        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
454        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
455        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
456  
457        // Make the confirmation message send fail
458        mockMessagesCreate.mock.mockImplementation(async () => {
459          throw new Error('Failed to send confirmation');
460        });
461  
462        mockMessagesList.mock.mockImplementation(async () => [
463          {
464            sid: 'SM-stop-fail-001',
465            from: '+1234567890',
466            to: '+15551234567',
467            body: 'STOP',
468            status: 'received',
469            dateSent: new Date(),
470          },
471        ]);
472  
473        // Should not throw even though confirmation failed
474        const result = await pollInboundSMS();
475  
476        assert.strictEqual(result.processed, 1);
477        assert.strictEqual(result.stored, 1); // Still stored despite confirmation failure
478  
479        // Opt-out should still be recorded even if confirmation fails
480        const optOut = db
481          .prepare("SELECT * FROM opt_outs WHERE phone = ? AND method = 'sms'")
482          .get('+1234567890');
483        assert.ok(optOut, 'Opt-out should still be recorded');
484  
485        delete process.env.TWILIO_ACCOUNT_SID;
486        delete process.env.TWILIO_AUTH_TOKEN;
487        delete process.env.TWILIO_PHONE_NUMBER;
488      });
489  
490      test('should handle re-subscription confirmation failure gracefully', async () => {
491        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
492        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
493        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
494  
495        // Make the confirmation message send fail
496        mockMessagesCreate.mock.mockImplementation(async () => {
497          throw new Error('Failed to send confirmation');
498        });
499  
500        mockMessagesList.mock.mockImplementation(async () => [
501          {
502            sid: 'SM-start-fail-001',
503            from: '+1234567890',
504            to: '+15551234567',
505            body: 'START',
506            status: 'received',
507            dateSent: new Date(),
508          },
509        ]);
510  
511        // Should not throw even though confirmation failed
512        const result = await pollInboundSMS();
513  
514        assert.strictEqual(result.processed, 1);
515        assert.strictEqual(result.stored, 1);
516  
517        delete process.env.TWILIO_ACCOUNT_SID;
518        delete process.env.TWILIO_AUTH_TOKEN;
519        delete process.env.TWILIO_PHONE_NUMBER;
520      });
521  
522      test('should handle STOPALL keyword', async () => {
523        process.env.TWILIO_ACCOUNT_SID = 'ACtest123';
524        process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
525        process.env.TWILIO_PHONE_NUMBER = '+15551234567';
526  
527        mockMessagesList.mock.mockImplementation(async () => [
528          {
529            sid: 'SM-stopall-001',
530            from: '+1234567890',
531            to: '+15551234567',
532            body: 'STOPALL',
533            status: 'received',
534            dateSent: new Date(),
535          },
536        ]);
537  
538        const result = await pollInboundSMS();
539  
540        assert.strictEqual(result.processed, 1);
541        assert.strictEqual(result.stored, 1);
542  
543        // Verify opt-out was recorded
544        const optOut = db
545          .prepare("SELECT * FROM opt_outs WHERE phone = ? AND method = 'sms'")
546          .get('+1234567890');
547        assert.ok(optOut, 'Should have opt-out record for STOPALL');
548  
549        delete process.env.TWILIO_ACCOUNT_SID;
550        delete process.env.TWILIO_AUTH_TOKEN;
551        delete process.env.TWILIO_PHONE_NUMBER;
552      });
553    });
554  
555    describe('processPendingReplies', () => {
556      test('should return zero counts when no pending replies', async () => {
557        const result = await processPendingReplies();
558  
559        assert.strictEqual(result.sent, 0);
560        assert.strictEqual(result.failed, 0);
561      });
562  
563      test('should send pending operator SMS replies', async () => {
564        // Insert a pending outbound conversation
565        db.prepare(
566          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
567           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
568        ).run(1, '+1234567890', 'Thanks for your interest!');
569  
570        const result = await processPendingReplies();
571  
572        assert.strictEqual(result.sent, 1);
573        assert.strictEqual(result.failed, 0);
574  
575        // Verify sendSMS was called once with the message id (outreachId)
576        assert.strictEqual(mockSendSMS.mock.calls.length, 1);
577        const callArgs = mockSendSMS.mock.calls[0].arguments;
578        assert.strictEqual(callArgs.length, 1); // sendSMS(outreachId) — single arg
579        assert.ok(typeof callArgs[0] === 'number', 'should be called with numeric message id');
580  
581        // Verify sent_at was set
582        const conv = db
583          .prepare("SELECT * FROM messages WHERE direction = 'outbound' AND contact_method = 'sms'")
584          .get();
585        assert.ok(conv.sent_at, 'sent_at should be set after sending');
586      });
587  
588      test('should handle multiple pending replies', async () => {
589        db.prepare(
590          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
591           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
592        ).run(1, '+1234567890', 'Reply 1');
593  
594        db.prepare(
595          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
596           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
597        ).run(2, '+61412345678', 'Reply 2');
598  
599        const result = await processPendingReplies();
600  
601        assert.strictEqual(result.sent, 2);
602        assert.strictEqual(result.failed, 0);
603        assert.strictEqual(mockSendSMS.mock.calls.length, 2);
604      });
605  
606      test('should handle send failures gracefully and continue processing', async () => {
607        db.prepare(
608          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
609           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
610        ).run(1, '+1234567890', 'Reply that fails');
611  
612        db.prepare(
613          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
614           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
615        ).run(2, '+61412345678', 'Reply that succeeds');
616  
617        // First call fails, second succeeds
618        let callNum = 0;
619        mockSendSMS.mock.mockImplementation(async () => {
620          callNum++;
621          if (callNum === 1) {
622            throw new Error('Twilio error: unverified number');
623          }
624          return { sid: 'mock-sms-sid', status: 'sent' };
625        });
626  
627        const result = await processPendingReplies();
628  
629        assert.strictEqual(result.sent, 1);
630        assert.strictEqual(result.failed, 1);
631        assert.strictEqual(mockSendSMS.mock.calls.length, 2);
632      });
633  
634      test('should not process email outbound conversations', async () => {
635        // Insert an email outbound conversation (should be ignored by SMS processPendingReplies)
636        db.prepare(
637          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body)
638           VALUES (?, 'outbound', 'email', ?, ?)`
639        ).run(1, 'owner@testsite.com', 'Email reply');
640  
641        const result = await processPendingReplies();
642  
643        assert.strictEqual(result.sent, 0);
644        assert.strictEqual(result.failed, 0);
645        assert.strictEqual(mockSendSMS.mock.calls.length, 0);
646      });
647  
648      test('should not re-send already sent replies', async () => {
649        // Insert a conversation that already has sent_at set
650        db.prepare(
651          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, sent_at)
652           VALUES (?, 'outbound', 'sms', ?, ?, CURRENT_TIMESTAMP)`
653        ).run(1, '+1234567890', 'Already sent');
654  
655        const result = await processPendingReplies();
656  
657        assert.strictEqual(result.sent, 0);
658        assert.strictEqual(result.failed, 0);
659        assert.strictEqual(mockSendSMS.mock.calls.length, 0);
660      });
661  
662      test('should handle all replies failing', async () => {
663        db.prepare(
664          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
665           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
666        ).run(1, '+1234567890', 'Fail 1');
667  
668        db.prepare(
669          `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type)
670           VALUES (?, 'outbound', 'sms', ?, ?, 'reply')`
671        ).run(2, '+61412345678', 'Fail 2');
672  
673        mockSendSMS.mock.mockImplementation(async () => {
674          throw new Error('Network timeout');
675        });
676  
677        const result = await processPendingReplies();
678  
679        assert.strictEqual(result.sent, 0);
680        assert.strictEqual(result.failed, 2);
681      });
682    });
683  });