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 });