inbound-email-mocked.test.js
1 /** 2 * Mocked Unit Tests for Inbound Email Module 3 * 4 * Tests functions that require external API calls (Resend, Cloudflare Worker) 5 * by mocking fetch() and using an in-memory database with full schema. 6 * 7 * Covers uncovered lines: 8 * - pollInboundEmails (lines 207-322) - Cloudflare Worker + Resend API polling 9 * - processPendingReplies (lines 329-388) - Sending operator replies via email 10 * - fetchReceivedEmail (lines 143-166) - Resend API email fetch 11 */ 12 13 import { test, describe, mock, beforeEach, afterEach } from 'node:test'; 14 import assert from 'node:assert'; 15 import Database from 'better-sqlite3'; 16 import { readFileSync } from 'fs'; 17 import { join, dirname } from 'path'; 18 import { fileURLToPath } from 'url'; 19 import { createPgMock } from '../helpers/pg-mock.js'; 20 21 const __filename = fileURLToPath(import.meta.url); 22 const __dirname = dirname(__filename); 23 const projectRoot = join(__dirname, '../..'); 24 const schemaPath = join(projectRoot, 'db/schema.sql'); 25 26 // Store original fetch 27 const originalFetch = globalThis.fetch; 28 29 // Mock the sendEmail function used by processPendingReplies 30 const mockSendEmail = mock.fn(async () => ({ id: 'mock-email-id', status: 'sent' })); 31 32 mock.module('../../src/outreach/email.js', { 33 namedExports: { 34 sendEmail: mockSendEmail, 35 }, 36 }); 37 38 // In-memory DB seeded BEFORE mock.module for db.js 39 const db = new Database(':memory:'); 40 const schema = readFileSync(schemaPath, 'utf-8'); 41 db.exec(schema); 42 db.pragma('foreign_keys = ON'); 43 44 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 45 46 // Import module under test AFTER setting mocks 47 const { 48 findOutreachByEmail, 49 parseEmailBody, 50 detectSentiment, 51 fetchReceivedEmail, 52 storeInboundEmail, 53 pollInboundEmails, 54 processPendingReplies, 55 } = await import('../../src/inbound/email.js'); 56 57 // ─── Test helpers ───────────────────────────────────────────────────────────── 58 59 function clearTables() { 60 db.exec('DELETE FROM messages; DELETE FROM sites'); 61 } 62 63 /** 64 * Helper: insert standard test fixtures (site + outreaches) 65 */ 66 function insertTestData() { 67 db.prepare( 68 `INSERT INTO sites (id, domain, landing_page_url, keyword, status) 69 VALUES (?, ?, ?, ?, ?)` 70 ).run(1, 'testsite.com', 'https://testsite.com', 'web design', 'outreach_sent'); 71 72 db.prepare( 73 `INSERT INTO sites (id, domain, landing_page_url, keyword, status) 74 VALUES (?, ?, ?, ?, ?)` 75 ).run(2, 'another.com', 'https://another.com', 'seo services', 'outreach_sent'); 76 77 db.prepare( 78 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at) 79 VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-1 hour'))` 80 ).run( 81 1, 82 1, 83 'email', 84 'owner@testsite.com', 85 'Great proposal', 86 'Improve your site', 87 'outbound', 88 'sent' 89 ); 90 91 db.prepare( 92 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at) 93 VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-2 hours'))` 94 ).run( 95 2, 96 2, 97 'email', 98 'contact@another.com', 99 'Another proposal', 100 'SEO for you', 101 'outbound', 102 'sent' 103 ); 104 105 db.prepare( 106 `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, direction, delivery_status, sent_at) 107 VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '-3 hours'))` 108 ).run(3, 1, 'sms', '+1234567890', 'SMS proposal', 'SMS subject', 'outbound', 'sent'); 109 } 110 111 describe('Inbound Email Module - Mocked Tests', () => { 112 beforeEach(() => { 113 clearTables(); 114 insertTestData(); 115 116 // Reset mocks 117 mockSendEmail.mock.resetCalls(); 118 mockSendEmail.mock.mockImplementation(async () => ({ 119 id: 'mock-email-id', 120 status: 'sent', 121 })); 122 }); 123 124 afterEach(() => { 125 // Restore original fetch 126 globalThis.fetch = originalFetch; 127 }); 128 129 describe('fetchReceivedEmail', () => { 130 test('should throw error when RESEND_API_KEY is not configured', async () => { 131 const savedKey = process.env.RESEND_API_KEY; 132 delete process.env.RESEND_API_KEY; 133 134 await assert.rejects( 135 async () => { 136 await fetchReceivedEmail('test-email-id-123'); 137 }, 138 { 139 message: 'RESEND_API_KEY not configured', 140 } 141 ); 142 143 // Restore 144 if (savedKey) process.env.RESEND_API_KEY = savedKey; 145 }); 146 147 test('should fetch email details from Resend API successfully', async () => { 148 process.env.RESEND_API_KEY = 'test-api-key-123'; 149 150 const mockEmailData = { 151 id: 'email-id-456', 152 from: 'owner@testsite.com', 153 to: ['sender@example.com'], 154 subject: 'Re: Improve your site', 155 text: 'Yes, I am interested!', 156 html: '<p>Yes, I am interested!</p>', 157 created_at: '2024-01-01T00:00:00.000Z', 158 }; 159 160 globalThis.fetch = mock.fn(async () => ({ 161 ok: true, 162 json: async () => mockEmailData, 163 })); 164 165 const result = await fetchReceivedEmail('email-id-456'); 166 167 assert.deepStrictEqual(result, mockEmailData); 168 assert.strictEqual(globalThis.fetch.mock.calls.length, 1); 169 assert.ok(globalThis.fetch.mock.calls[0].arguments[0].includes('email-id-456')); 170 171 // Verify auth header 172 const { headers } = globalThis.fetch.mock.calls[0].arguments[1]; 173 assert.strictEqual(headers.Authorization, 'Bearer test-api-key-123'); 174 175 delete process.env.RESEND_API_KEY; 176 }); 177 178 test('should return null on 404 response from Resend API', async () => { 179 process.env.RESEND_API_KEY = 'test-api-key-123'; 180 181 globalThis.fetch = mock.fn(async () => ({ 182 ok: false, 183 status: 404, 184 statusText: 'Not Found', 185 })); 186 187 const result = await fetchReceivedEmail('nonexistent-email-id'); 188 assert.strictEqual(result, null, 'Should return null for 404'); 189 190 delete process.env.RESEND_API_KEY; 191 }); 192 193 test('should throw error on 500 server error', async () => { 194 process.env.RESEND_API_KEY = 'test-api-key-123'; 195 196 globalThis.fetch = mock.fn(async () => ({ 197 ok: false, 198 status: 500, 199 statusText: 'Internal Server Error', 200 })); 201 202 await assert.rejects( 203 async () => { 204 await fetchReceivedEmail('some-email-id'); 205 }, 206 { 207 message: /Failed to fetch email: 500 Internal Server Error/, 208 } 209 ); 210 211 delete process.env.RESEND_API_KEY; 212 }); 213 }); 214 215 describe('pollInboundEmails', () => { 216 test('should throw error when EMAIL_EVENTS_WORKER_URL is not configured', async () => { 217 const savedUrl = process.env.EMAIL_EVENTS_WORKER_URL; 218 delete process.env.EMAIL_EVENTS_WORKER_URL; 219 220 await assert.rejects( 221 async () => { 222 await pollInboundEmails(); 223 }, 224 { 225 message: /EMAIL_EVENTS_WORKER_URL not configured/, 226 } 227 ); 228 229 if (savedUrl) process.env.EMAIL_EVENTS_WORKER_URL = savedUrl; 230 }); 231 232 test('should return zero counts when no received events', async () => { 233 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 234 235 globalThis.fetch = mock.fn(async () => ({ 236 ok: true, 237 json: async () => [], 238 })); 239 240 const result = await pollInboundEmails(); 241 242 assert.strictEqual(result.processed, 0); 243 assert.strictEqual(result.stored, 0); 244 assert.strictEqual(result.unmatched, 0); 245 246 delete process.env.EMAIL_EVENTS_WORKER_URL; 247 }); 248 249 test('should return zero counts when events exist but none are email.received type', async () => { 250 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 251 252 globalThis.fetch = mock.fn(async () => ({ 253 ok: true, 254 json: async () => [ 255 { 256 type: 'email.sent', 257 created_at: new Date().toISOString(), 258 data: { from: 'test@test.com', email_id: 'id-1' }, 259 }, 260 { 261 type: 'email.delivered', 262 created_at: new Date().toISOString(), 263 data: { from: 'test@test.com', email_id: 'id-2' }, 264 }, 265 ], 266 })); 267 268 const result = await pollInboundEmails(); 269 270 assert.strictEqual(result.processed, 0); 271 assert.strictEqual(result.stored, 0); 272 assert.strictEqual(result.unmatched, 0); 273 274 delete process.env.EMAIL_EVENTS_WORKER_URL; 275 }); 276 277 test('should throw error when worker URL returns non-OK response', async () => { 278 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 279 280 globalThis.fetch = mock.fn(async () => ({ 281 ok: false, 282 status: 503, 283 statusText: 'Service Unavailable', 284 })); 285 286 await assert.rejects( 287 async () => { 288 await pollInboundEmails(); 289 }, 290 { 291 message: /Failed to fetch events: 503 Service Unavailable/, 292 } 293 ); 294 295 delete process.env.EMAIL_EVENTS_WORKER_URL; 296 }); 297 298 test('should process and store matched inbound email events', async () => { 299 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 300 process.env.RESEND_API_KEY = 'test-api-key-123'; 301 302 const now = new Date(); 303 const recentDate = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago 304 305 // Mock the worker URL fetch (returns events list) 306 // Mock the Resend API fetch (returns email details) 307 globalThis.fetch = mock.fn(async url => { 308 if (url.includes('email-events.json')) { 309 // Worker URL - return events 310 return { 311 ok: true, 312 json: async () => [ 313 { 314 type: 'email.received', 315 created_at: recentDate.toISOString(), 316 data: { 317 from: 'owner@testsite.com', 318 subject: 'Re: Improve your site', 319 email_id: 'received-email-001', 320 }, 321 }, 322 ], 323 }; 324 } 325 // Resend API - return email details 326 return { 327 ok: true, 328 json: async () => ({ 329 id: 'received-email-001', 330 from: 'owner@testsite.com', 331 to: ['sender@example.com'], 332 subject: 'Re: Improve your site', 333 text: 'Yes, I am interested in your services!', 334 html: '<p>Yes, I am interested in your services!</p>', 335 }), 336 }; 337 }); 338 339 const result = await pollInboundEmails(); 340 341 assert.strictEqual(result.stored, 1); 342 assert.strictEqual(result.unmatched, 0); 343 assert.strictEqual(result.processed, 1); 344 345 // Verify conversation was stored in DB 346 const conversations = db.prepare('SELECT * FROM messages WHERE site_id = 1').all(); 347 348 assert.ok(conversations.length >= 1, 'Should have stored at least one conversation'); 349 350 const conv = conversations[conversations.length - 1]; 351 assert.strictEqual(conv.direction, 'inbound'); 352 assert.strictEqual(conv.contact_method, 'email'); 353 assert.strictEqual(conv.contact_uri, 'owner@testsite.com'); 354 assert.strictEqual(conv.message_body, 'Yes, I am interested in your services!'); 355 assert.strictEqual(conv.sentiment, 'positive'); 356 357 delete process.env.EMAIL_EVENTS_WORKER_URL; 358 delete process.env.RESEND_API_KEY; 359 }); 360 361 test('should count unmatched events for unknown senders', async () => { 362 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 363 process.env.RESEND_API_KEY = 'test-api-key-123'; 364 365 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 366 367 globalThis.fetch = mock.fn(async url => { 368 if (url.includes('email-events.json')) { 369 return { 370 ok: true, 371 json: async () => [ 372 { 373 type: 'email.received', 374 created_at: recentDate.toISOString(), 375 data: { 376 from: 'unknown@stranger.com', 377 subject: 'Random email', 378 email_id: 'unknown-email-001', 379 }, 380 }, 381 ], 382 }; 383 } 384 return { ok: true, json: async () => ({}) }; 385 }); 386 387 const result = await pollInboundEmails(); 388 389 // Unmatched emails without site_id cannot be stored (messages.site_id is NOT NULL), 390 // so stored=0 and unmatched=1. 391 assert.strictEqual(result.stored, 0); 392 assert.strictEqual(result.unmatched, 1); 393 assert.strictEqual(result.processed, 1); 394 395 delete process.env.EMAIL_EVENTS_WORKER_URL; 396 delete process.env.RESEND_API_KEY; 397 }); 398 399 test('should skip events with missing from or email_id', async () => { 400 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 401 402 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 403 404 globalThis.fetch = mock.fn(async () => ({ 405 ok: true, 406 json: async () => [ 407 { 408 type: 'email.received', 409 created_at: recentDate.toISOString(), 410 data: { from: null, subject: 'No from', email_id: null }, 411 }, 412 { 413 type: 'email.received', 414 created_at: recentDate.toISOString(), 415 data: {}, 416 }, 417 ], 418 })); 419 420 const result = await pollInboundEmails(); 421 422 assert.strictEqual(result.stored, 0); 423 assert.strictEqual(result.unmatched, 2); 424 assert.strictEqual(result.processed, 2); 425 426 delete process.env.EMAIL_EVENTS_WORKER_URL; 427 }); 428 429 test('should skip already stored emails (deduplication by email_id)', async () => { 430 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 431 process.env.RESEND_API_KEY = 'test-api-key-123'; 432 433 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 434 435 // Pre-insert a conversation with the same email_id in raw_payload 436 db.prepare( 437 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, raw_payload) 438 VALUES (?, 'inbound', 'email', ?, ?, ?)` 439 ).run( 440 1, 441 'owner@testsite.com', 442 'Already stored', 443 JSON.stringify({ email_id: 'dup-email-001' }) 444 ); 445 446 globalThis.fetch = mock.fn(async url => { 447 if (url.includes('email-events.json')) { 448 return { 449 ok: true, 450 json: async () => [ 451 { 452 type: 'email.received', 453 created_at: recentDate.toISOString(), 454 data: { 455 from: 'owner@testsite.com', 456 subject: 'Re: Duplicate', 457 email_id: 'dup-email-001', 458 }, 459 }, 460 ], 461 }; 462 } 463 return { 464 ok: true, 465 json: async () => ({ 466 id: 'dup-email-001', 467 text: 'Duplicate message', 468 }), 469 }; 470 }); 471 472 const result = await pollInboundEmails(); 473 474 // Should be processed but not stored (duplicate) 475 assert.strictEqual(result.stored, 0); 476 assert.strictEqual(result.unmatched, 0); 477 assert.strictEqual(result.processed, 1); 478 479 delete process.env.EMAIL_EVENTS_WORKER_URL; 480 delete process.env.RESEND_API_KEY; 481 }); 482 483 test('should handle errors for individual events gracefully', async () => { 484 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 485 process.env.RESEND_API_KEY = 'test-api-key-123'; 486 487 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 488 489 globalThis.fetch = mock.fn(async url => { 490 if (url.includes('email-events.json')) { 491 return { 492 ok: true, 493 json: async () => [ 494 { 495 type: 'email.received', 496 created_at: recentDate.toISOString(), 497 data: { 498 from: 'owner@testsite.com', 499 subject: 'Error test', 500 email_id: 'error-email-001', 501 }, 502 }, 503 ], 504 }; 505 } 506 // Resend API call fails 507 return { 508 ok: false, 509 status: 500, 510 statusText: 'Internal Server Error', 511 }; 512 }); 513 514 const result = await pollInboundEmails(); 515 516 // Event found outreach but Resend API failed, so it counts as unmatched (error) 517 assert.strictEqual(result.stored, 0); 518 assert.strictEqual(result.unmatched, 1); 519 assert.strictEqual(result.processed, 1); 520 521 delete process.env.EMAIL_EVENTS_WORKER_URL; 522 delete process.env.RESEND_API_KEY; 523 }); 524 525 test('should filter out events older than 24 hours', async () => { 526 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 527 528 const oldDate = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48 hours ago 529 530 globalThis.fetch = mock.fn(async () => ({ 531 ok: true, 532 json: async () => [ 533 { 534 type: 'email.received', 535 created_at: oldDate.toISOString(), 536 data: { 537 from: 'owner@testsite.com', 538 subject: 'Old email', 539 email_id: 'old-email-001', 540 }, 541 }, 542 ], 543 })); 544 545 const result = await pollInboundEmails(); 546 547 // Old event should be filtered out 548 assert.strictEqual(result.processed, 0); 549 assert.strictEqual(result.stored, 0); 550 assert.strictEqual(result.unmatched, 0); 551 552 delete process.env.EMAIL_EVENTS_WORKER_URL; 553 }); 554 555 test('should handle email with no subject gracefully', async () => { 556 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 557 process.env.RESEND_API_KEY = 'test-api-key-123'; 558 559 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 560 561 globalThis.fetch = mock.fn(async url => { 562 if (url.includes('email-events.json')) { 563 return { 564 ok: true, 565 json: async () => [ 566 { 567 type: 'email.received', 568 created_at: recentDate.toISOString(), 569 data: { 570 from: 'owner@testsite.com', 571 subject: null, 572 email_id: 'no-subject-001', 573 }, 574 }, 575 ], 576 }; 577 } 578 return { 579 ok: true, 580 json: async () => ({ 581 id: 'no-subject-001', 582 text: 'Interested!', 583 }), 584 }; 585 }); 586 587 const result = await pollInboundEmails(); 588 589 assert.strictEqual(result.stored, 1); 590 591 // Verify subject was stored as '(no subject)' 592 const conv = db 593 .prepare("SELECT * FROM messages WHERE raw_payload LIKE '%no-subject-001%'") 594 .get(); 595 assert.ok(conv); 596 assert.strictEqual(conv.subject_line, '(no subject)'); 597 598 delete process.env.EMAIL_EVENTS_WORKER_URL; 599 delete process.env.RESEND_API_KEY; 600 }); 601 602 test('should use html content when text is not available', async () => { 603 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 604 process.env.RESEND_API_KEY = 'test-api-key-123'; 605 606 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 607 608 globalThis.fetch = mock.fn(async url => { 609 if (url.includes('email-events.json')) { 610 return { 611 ok: true, 612 json: async () => [ 613 { 614 type: 'email.received', 615 created_at: recentDate.toISOString(), 616 data: { 617 from: 'owner@testsite.com', 618 subject: 'HTML only', 619 email_id: 'html-only-001', 620 }, 621 }, 622 ], 623 }; 624 } 625 return { 626 ok: true, 627 json: async () => ({ 628 id: 'html-only-001', 629 text: null, 630 html: '<p>Please call me to discuss</p>', 631 }), 632 }; 633 }); 634 635 const result = await pollInboundEmails(); 636 637 assert.strictEqual(result.stored, 1); 638 639 delete process.env.EMAIL_EVENTS_WORKER_URL; 640 delete process.env.RESEND_API_KEY; 641 }); 642 643 test('should process multiple events in one poll cycle', async () => { 644 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 645 process.env.RESEND_API_KEY = 'test-api-key-123'; 646 647 const recentDate = new Date(Date.now() - 60 * 60 * 1000); 648 649 globalThis.fetch = mock.fn(async url => { 650 if (url.includes('email-events.json')) { 651 return { 652 ok: true, 653 json: async () => [ 654 { 655 type: 'email.received', 656 created_at: recentDate.toISOString(), 657 data: { 658 from: 'owner@testsite.com', 659 subject: 'First email', 660 email_id: 'multi-001', 661 }, 662 }, 663 { 664 type: 'email.received', 665 created_at: recentDate.toISOString(), 666 data: { 667 from: 'contact@another.com', 668 subject: 'Second email', 669 email_id: 'multi-002', 670 }, 671 }, 672 { 673 type: 'email.received', 674 created_at: recentDate.toISOString(), 675 data: { 676 from: 'nobody@nowhere.com', 677 subject: 'Unknown sender', 678 email_id: 'multi-003', 679 }, 680 }, 681 ], 682 }; 683 } 684 // Resend API for each email 685 const emailId = url.split('/').pop(); 686 return { 687 ok: true, 688 json: async () => ({ 689 id: emailId, 690 text: `Reply from ${emailId}`, 691 }), 692 }; 693 }); 694 695 const result = await pollInboundEmails(); 696 697 assert.strictEqual(result.processed, 3); 698 assert.strictEqual(result.stored, 2); // 2 matched stored, unmatched without site_id cannot be stored 699 assert.strictEqual(result.unmatched, 1); // 1 unknown sender 700 701 delete process.env.EMAIL_EVENTS_WORKER_URL; 702 delete process.env.RESEND_API_KEY; 703 }); 704 }); 705 706 describe('processPendingReplies', () => { 707 test('should return zero counts when no pending replies', async () => { 708 const result = await processPendingReplies(); 709 710 assert.strictEqual(result.sent, 0); 711 assert.strictEqual(result.failed, 0); 712 }); 713 714 test('should send pending operator email replies', async () => { 715 // Insert a pending outbound conversation 716 db.prepare( 717 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type) 718 VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')` 719 ).run(1, 'owner@testsite.com', 'Thanks for your interest!', 'Re: Improve your site'); 720 721 const result = await processPendingReplies(); 722 723 assert.strictEqual(result.sent, 1); 724 assert.strictEqual(result.failed, 0); 725 726 // Verify sendEmail was called once with the message id (outreachId) 727 assert.strictEqual(mockSendEmail.mock.calls.length, 1); 728 const callArgs = mockSendEmail.mock.calls[0].arguments; 729 assert.strictEqual(callArgs.length, 1); // sendEmail(outreachId) — single arg 730 assert.ok(typeof callArgs[0] === 'number', 'should be called with numeric message id'); 731 732 // Verify sent_at was set 733 const conv = db 734 .prepare("SELECT * FROM messages WHERE direction = 'outbound' AND contact_method = 'email' AND message_type = 'reply'") 735 .get(); 736 assert.ok(conv.sent_at, 'sent_at should be set after sending'); 737 }); 738 739 test('should handle multiple pending replies', async () => { 740 // Insert two pending outbound conversations 741 db.prepare( 742 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type) 743 VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')` 744 ).run(1, 'owner@testsite.com', 'Reply 1', 'Re: Subject 1'); 745 746 db.prepare( 747 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type) 748 VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')` 749 ).run(2, 'contact@another.com', 'Reply 2', 'Re: Subject 2'); 750 751 const result = await processPendingReplies(); 752 753 assert.strictEqual(result.sent, 2); 754 assert.strictEqual(result.failed, 0); 755 assert.strictEqual(mockSendEmail.mock.calls.length, 2); 756 }); 757 758 test('should handle send failures gracefully and continue processing', async () => { 759 // Insert two pending outbound conversations 760 db.prepare( 761 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type) 762 VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')` 763 ).run(1, 'owner@testsite.com', 'Reply that fails', 'Re: Fail'); 764 765 db.prepare( 766 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, message_type) 767 VALUES (?, 'outbound', 'email', ?, ?, ?, 'reply')` 768 ).run(2, 'contact@another.com', 'Reply that succeeds', 'Re: Success'); 769 770 // First call fails, second succeeds 771 let callNum = 0; 772 mockSendEmail.mock.mockImplementation(async () => { 773 callNum++; 774 if (callNum === 1) { 775 throw new Error('Resend API error: rate limited'); 776 } 777 return { id: 'mock-email-id', status: 'sent' }; 778 }); 779 780 const result = await processPendingReplies(); 781 782 assert.strictEqual(result.sent, 1); 783 assert.strictEqual(result.failed, 1); 784 assert.strictEqual(mockSendEmail.mock.calls.length, 2); 785 }); 786 787 test('should not process SMS outbound conversations', async () => { 788 // Insert an SMS outbound conversation (should be ignored by email processPendingReplies) 789 db.prepare( 790 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body) 791 VALUES (?, 'outbound', 'sms', ?, ?)` 792 ).run(1, '+1234567890', 'SMS reply'); 793 794 const result = await processPendingReplies(); 795 796 assert.strictEqual(result.sent, 0); 797 assert.strictEqual(result.failed, 0); 798 assert.strictEqual(mockSendEmail.mock.calls.length, 0); 799 }); 800 801 test('should not re-send already sent replies', async () => { 802 // Insert a conversation that already has sent_at set 803 db.prepare( 804 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, subject_line, sent_at) 805 VALUES (?, 'outbound', 'email', ?, ?, ?, CURRENT_TIMESTAMP)` 806 ).run(1, 'owner@testsite.com', 'Already sent', 'Re: Already done'); 807 808 const result = await processPendingReplies(); 809 810 assert.strictEqual(result.sent, 0); 811 assert.strictEqual(result.failed, 0); 812 assert.strictEqual(mockSendEmail.mock.calls.length, 0); 813 }); 814 815 test('should use default subject when subject_line is null', async () => { 816 // Insert conversation without subject_line 817 db.prepare( 818 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, message_type) 819 VALUES (?, 'outbound', 'email', ?, ?, 'reply')` 820 ).run(1, 'owner@testsite.com', 'Reply without subject'); 821 822 const result = await processPendingReplies(); 823 824 assert.strictEqual(result.sent, 1); 825 826 // sendEmail is called with just the message id (outreachId) 827 // subject_line fallback logic lives inside sendEmail, not here 828 assert.strictEqual(mockSendEmail.mock.calls.length, 1); 829 const callArgs = mockSendEmail.mock.calls[0].arguments; 830 assert.ok(typeof callArgs[0] === 'number', 'should be called with numeric message id'); 831 }); 832 }); 833 });