/ __quarantined_tests__ / outreach / outreach-email.integration.test.js
outreach-email.integration.test.js
  1  /**
  2   * Integration Tests for Email Outreach Module with Resend API
  3   *
  4   * These tests make actual API calls to Resend using test addresses.
  5   * Requires RESEND_API_KEY or RESEND_TEST_API_KEY in environment.
  6   *
  7   * Run with: npm run test:integration
  8   */
  9  
 10  import { test, describe, before, beforeEach, afterEach } from 'node:test';
 11  import assert from 'node:assert';
 12  import Database from 'better-sqlite3';
 13  import { Resend } from 'resend';
 14  import { join } from 'path';
 15  import { existsSync, rmSync } from 'fs';
 16  import dotenv from 'dotenv';
 17  
 18  dotenv.config();
 19  
 20  const testDbPath = join(process.cwd(), 'test-email-integration.db');
 21  
 22  // Set DATABASE_PATH before importing modules that use it
 23  process.env.DATABASE_PATH = testDbPath;
 24  
 25  // Now dynamically import the email module after setting env vars
 26  const { sendEmail, sendBulkEmails, unsubscribeEmail } = await import('../../src/outreach/email.js');
 27  
 28  // Use test API key if available, otherwise production key
 29  const resendApiKey = process.env.RESEND_TEST_API_KEY || process.env.RESEND_API_KEY;
 30  
 31  // Resend test addresses for different scenarios
 32  const TEST_ADDRESSES = {
 33    delivered: 'delivered@resend.dev',
 34    bounced: 'bounced@resend.dev',
 35    complained: 'complained@resend.dev',
 36  };
 37  
 38  // Skip tests if no API key available
 39  const skipIfNoApiKey = () => {
 40    if (!resendApiKey) {
 41      console.log('⚠️  Skipping Resend integration tests (no API key found)');
 42      console.log('   Set RESEND_API_KEY or RESEND_TEST_API_KEY in .env');
 43      process.exit(0);
 44    }
 45  };
 46  
 47  skipIfNoApiKey();
 48  
 49  describe('Resend API Integration Tests', () => {
 50    let db;
 51    let resend;
 52  
 53    beforeEach(() => {
 54      // Clean up test database
 55      if (existsSync(testDbPath)) {
 56        rmSync(testDbPath);
 57      }
 58  
 59      // Initialize test database
 60      db = new Database(testDbPath);
 61  
 62      // Create schema
 63      db.exec(`
 64        CREATE TABLE IF NOT EXISTS sites (
 65          id INTEGER PRIMARY KEY AUTOINCREMENT,
 66          domain TEXT NOT NULL,
 67          url TEXT NOT NULL,
 68          keyword TEXT,
 69          country_code TEXT DEFAULT 'US',
 70          approval_status TEXT DEFAULT 'pending', delivery_status TEXT,
 71          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 72        );
 73  
 74        CREATE TABLE IF NOT EXISTS messages (
 75          id INTEGER PRIMARY KEY AUTOINCREMENT,
 76          site_id INTEGER NOT NULL,
 77          direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')),
 78          contact_method TEXT NOT NULL CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')),
 79          contact_uri TEXT,
 80          message_body TEXT,
 81          subject_line TEXT,
 82          approval_status TEXT DEFAULT 'pending', delivery_status TEXT,
 83          delivered_at DATETIME,
 84          email_id TEXT,
 85          zb_status TEXT,
 86          error_message TEXT,
 87          retry_at DATETIME,
 88          sent_at DATETIME,
 89          opened_at DATETIME,
 90          tracking_clicked_at DATETIME,
 91          created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 92          updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 93          FOREIGN KEY (site_id) REFERENCES sites(id)
 94        );
 95  
 96        CREATE TABLE IF NOT EXISTS config (
 97          key TEXT PRIMARY KEY,
 98          value TEXT NOT NULL
 99        );
100  
101        CREATE TABLE IF NOT EXISTS unsubscribed_emails (
102          id INTEGER PRIMARY KEY AUTOINCREMENT,
103          email TEXT NOT NULL UNIQUE,
104          message_id INTEGER,
105          source TEXT DEFAULT 'manual',
106          unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
107        );
108  
109        CREATE TABLE IF NOT EXISTS email_validations (
110          email TEXT PRIMARY KEY,
111          status TEXT NOT NULL,
112          sub_status TEXT,
113          free_email INTEGER,
114          mx_found INTEGER,
115          validated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116          expires_at DATETIME DEFAULT (datetime('now', '+90 days'))
117        );
118  
119        -- Pre-seed ZeroBounce cache for test addresses so sendEmail() doesn't call ZB API
120        INSERT OR IGNORE INTO email_validations (email, status) VALUES
121          ('delivered@resend.dev', 'valid'),
122          ('bounced@resend.dev', 'invalid'),
123          ('complained@resend.dev', 'valid');
124  
125        -- Insert test config
126        INSERT INTO config (key, value) VALUES
127          ('email_signature', 'Best regards,\\nTest Team\\nhttps://333method.com'),
128          ('sender_email', '${process.env.SENDER_EMAIL || 'test@333method.com'}'),
129          ('sender_name', '${process.env.SENDER_NAME || 'Test Sender'}');
130      `);
131  
132      // Initialize Resend client
133      resend = new Resend(resendApiKey);
134    });
135  
136    afterEach(() => {
137      if (db) {
138        db.close();
139      }
140      if (existsSync(testDbPath)) {
141        rmSync(testDbPath);
142      }
143      delete process.env.DATABASE_PATH;
144    });
145  
146    describe('Resend API Direct Tests', () => {
147      test('should send email to Resend test address (delivered)', async () => {
148        const result = await resend.emails.send({
149          from: `${process.env.SENDER_NAME || 'Test'} <${process.env.SENDER_EMAIL || 'test@333method.com'}>`,
150          to: TEST_ADDRESSES.delivered,
151          subject: 'Integration Test - Delivered',
152          html: '<p>This is a test email that should be delivered.</p>',
153          text: 'This is a test email that should be delivered.',
154        });
155  
156        assert.ok(result.data?.id, 'Should return Resend ID');
157        assert.ok(typeof result.data.id === 'string', 'Resend ID should be a string');
158        assert.ok(result.data.id.length > 0, 'Resend ID should not be empty');
159        console.log(`✅ Email sent successfully (ID: ${result.data.id})`);
160      });
161  
162      test('should validate email payload structure', async () => {
163        const result = await resend.emails.send({
164          from: `Test <${process.env.SENDER_EMAIL || 'test@333method.com'}>`,
165          to: TEST_ADDRESSES.delivered,
166          subject: 'Payload Validation Test',
167          html: '<p>Testing payload structure</p>',
168          headers: {
169            'List-Unsubscribe': '<https://333method.com/unsubscribe?id=123>',
170          },
171          tags: [
172            { name: 'site_id', value: '123' },
173            { name: 'test', value: 'integration' },
174          ],
175        });
176  
177        assert.ok(result.data.id);
178        console.log(`✅ Payload validation passed (ID: ${result.data.id})`);
179      });
180    });
181  
182    describe('sendEmail() Integration Tests', () => {
183      // Resend rate limit is 2 req/sec — pause between test suites to avoid 429
184      before(async () => await new Promise(r => setTimeout(r, 1500)));
185  
186      test('should send email and update database', async () => {
187        // Insert test data
188        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
189          1,
190          'example.com',
191          'https://example.com',
192          'test keyword'
193        );
194  
195        db.prepare(
196          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status)
197           VALUES (?, ?, ?, ?, ?, ?, ?)`
198        ).run(
199          1,
200          1,
201          'email',
202          TEST_ADDRESSES.delivered,
203          'This is a test proposal for example.com',
204          'Improve Your Website Conversion',
205          'pending'
206        );
207  
208        // Send email
209        const result = await sendEmail(1);
210  
211        // Verify result
212        assert.ok(result.success, 'Should report success');
213        assert.strictEqual(result.outreachId, 1);
214        assert.strictEqual(result.email, TEST_ADDRESSES.delivered);
215        assert.ok(result.resendId, 'Should return Resend ID');
216        assert.ok(typeof result.resendId === 'string', 'Resend ID should be a string');
217        assert.ok(result.resendId.length > 0, 'Resend ID should not be empty');
218  
219        // Verify database update
220        const outreach = db.prepare('SELECT * FROM messages WHERE id = ?').get(1);
221        assert.strictEqual(outreach.delivery_status, 'sent');
222        assert.ok(outreach.delivered_at, 'Should set delivered_at timestamp');
223        assert.strictEqual(outreach.email_id, result.resendId);
224  
225        console.log(`✅ Full sendEmail() workflow test passed (ID: ${result.resendId})`);
226      });
227  
228      test('should reject email to unsubscribed recipient', async () => {
229        // Insert test data
230        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
231          2,
232          'example2.com',
233          'https://example2.com',
234          'test keyword'
235        );
236  
237        db.prepare(
238          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status)
239           VALUES (?, ?, ?, ?, ?, ?, ?)`
240        ).run(
241          2,
242          2,
243          'email',
244          TEST_ADDRESSES.delivered,
245          'This should not be sent',
246          'Should Not Send',
247          'pending'
248        );
249  
250        // Mark recipient as unsubscribed via unsubscribed_emails table
251        db.prepare(
252          'INSERT OR IGNORE INTO unsubscribed_emails (email, message_id, source) VALUES (?, ?, ?)'
253        ).run(TEST_ADDRESSES.delivered, 2, 'manual');
254  
255        // Attempt to send email
256        await assert.rejects(
257          async () => {
258            await sendEmail(2);
259          },
260          {
261            message: /unsubscribed/i,
262          },
263          'Should reject sending to unsubscribed recipient'
264        );
265  
266        console.log('✅ Unsubscribe blocking test passed');
267      });
268  
269      test('should reject email to globally unsubscribed address', async () => {
270        // Insert test data
271        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
272          3,
273          'example3.com',
274          'https://example3.com',
275          'test keyword'
276        );
277  
278        const testEmail = 'globally-unsubscribed@test.com';
279  
280        // Add to global unsubscribe list
281        db.prepare('INSERT INTO unsubscribed_emails (email, source) VALUES (?, ?)').run(
282          testEmail,
283          'manual'
284        );
285  
286        db.prepare(
287          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status)
288           VALUES (?, ?, ?, ?, ?, ?, ?)`
289        ).run(3, 3, 'email', testEmail, 'This should not be sent', 'Should Not Send', 'pending');
290  
291        // Attempt to send email
292        await assert.rejects(
293          async () => {
294            await sendEmail(3);
295          },
296          {
297            message: /globally unsubscribed/i,
298          },
299          'Should reject sending to globally unsubscribed address'
300        );
301  
302        console.log('✅ Global unsubscribe blocking test passed');
303      });
304  
305      test('should handle PENDING_CONTACT_EXTRACTION gracefully', async () => {
306        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
307          4,
308          'example4.com',
309          'https://example4.com',
310          'test keyword'
311        );
312  
313        db.prepare(
314          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status)
315           VALUES (?, ?, ?, ?, ?, ?, ?)`
316        ).run(
317          4,
318          4,
319          'email',
320          'PENDING_CONTACT_EXTRACTION',
321          'This should not be sent',
322          'Should Not Send',
323          'pending'
324        );
325  
326        await assert.rejects(
327          async () => {
328            await sendEmail(4);
329          },
330          {
331            message: /no email address/i,
332          },
333          'Should reject PENDING_CONTACT_EXTRACTION'
334        );
335  
336        console.log('✅ PENDING_CONTACT_EXTRACTION handling test passed');
337      });
338    });
339  
340    describe('sendBulkEmails() Integration Tests', () => {
341      // Resend rate limit is 2 req/sec — pause between test suites to avoid 429
342      before(async () => await new Promise(r => setTimeout(r, 1500)));
343  
344      test('should send multiple emails in sequence', async () => {
345        // Insert test data
346        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
347          5,
348          'bulk1.com',
349          'https://bulk1.com',
350          'test'
351        );
352        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
353          6,
354          'bulk2.com',
355          'https://bulk2.com',
356          'test'
357        );
358  
359        db.prepare(
360          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status)
361           VALUES (?, ?, ?, ?, ?, ?, ?)`
362        ).run(5, 5, 'email', TEST_ADDRESSES.delivered, 'Bulk test 1', 'Bulk Subject 1', 'approved');
363  
364        db.prepare(
365          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status)
366           VALUES (?, ?, ?, ?, ?, ?, ?)`
367        ).run(6, 6, 'email', TEST_ADDRESSES.delivered, 'Bulk test 2', 'Bulk Subject 2', 'approved');
368  
369        // Send bulk emails
370        const results = await sendBulkEmails(2);
371  
372        // Verify results
373        assert.strictEqual(results.length, 2);
374        assert.ok(
375          results.every(r => r.success),
376          'All emails should succeed'
377        );
378        assert.ok(
379          results.every(r => r.resendId),
380          'All should have Resend IDs'
381        );
382  
383        // Verify database updates
384        const sent = db
385          .prepare("SELECT COUNT(*) as count FROM messages WHERE delivery_status = 'sent'")
386          .get();
387        assert.strictEqual(sent.count, 2);
388  
389        console.log(`✅ Bulk send test passed (${results.length} emails)`);
390      });
391  
392      test('should skip unsubscribed emails in bulk send', async () => {
393        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
394          7,
395          'bulk3.com',
396          'https://bulk3.com',
397          'test'
398        );
399        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
400          8,
401          'bulk4.com',
402          'https://bulk4.com',
403          'test'
404        );
405  
406        db.prepare(
407          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status)
408           VALUES (?, ?, ?, ?, ?, ?, ?)`
409        ).run(7, 7, 'email', TEST_ADDRESSES.delivered, 'Should send', 'Bulk Subject 3', 'approved');
410  
411        // This one is unsubscribed
412        db.prepare(
413          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, approval_status)
414           VALUES (?, ?, ?, ?, ?, ?, ?)`
415        ).run(
416          8,
417          8,
418          'email',
419          TEST_ADDRESSES.delivered,
420          'Should NOT send',
421          'Bulk Subject 4',
422          'approved'
423        );
424  
425        // Add to unsubscribed_emails so it gets filtered out
426        db.prepare(
427          'INSERT OR IGNORE INTO unsubscribed_emails (email, message_id, source) VALUES (?, ?, ?)'
428        ).run(TEST_ADDRESSES.delivered, 8, 'manual');
429  
430        // Send bulk emails
431        const results = await sendBulkEmails();
432  
433        // Should only send 1 email (skipping unsubscribed)
434        assert.strictEqual(results.length, 1);
435        assert.ok(results[0].success);
436  
437        console.log('✅ Bulk send with unsubscribe filtering test passed');
438      });
439    });
440  
441    describe('unsubscribeEmail() Integration Tests', () => {
442      test('should mark email as unsubscribed and add to global list', async () => {
443        db.prepare('INSERT INTO sites (id, domain, url, keyword) VALUES (?, ?, ?, ?)').run(
444          9,
445          'unsub1.com',
446          'https://unsub1.com',
447          'test'
448        );
449  
450        const testEmail = 'unsubscribe-test@test.com';
451  
452        db.prepare(
453          `INSERT INTO messages (id, site_id, contact_method, contact_uri, message_body, subject_line, delivery_status)
454           VALUES (?, ?, ?, ?, ?, ?, ?)`
455        ).run(9, 9, 'email', testEmail, 'Test', 'Test Subject', 'sent');
456  
457        // Unsubscribe
458        unsubscribeEmail(9);
459  
460        // Verify added to global unsubscribe list
461        const globalUnsub = db
462          .prepare('SELECT * FROM unsubscribed_emails WHERE email = ?')
463          .get(testEmail);
464        assert.ok(globalUnsub, 'Should be in global unsubscribe list');
465        assert.strictEqual(globalUnsub.message_id, 9);
466        assert.strictEqual(globalUnsub.source, 'manual');
467  
468        console.log('✅ Unsubscribe workflow test passed');
469      });
470    });
471  });
472  
473  describe('Email Format and Compliance Tests', () => {
474    test('should include unsubscribe link in HTML body', () => {
475      const unsubLink = 'https://333method.com/unsubscribe?id=123&token=abc';
476      const html = `<p>Test</p><a href="${unsubLink}">unsubscribe</a>`;
477  
478      assert.ok(html.includes(unsubLink));
479      assert.ok(html.includes('unsubscribe'));
480      console.log('✅ Unsubscribe link inclusion test passed');
481    });
482  
483    test('should format List-Unsubscribe header correctly', () => {
484      const unsubLink = 'https://333method.com/unsubscribe?id=123';
485      const header = `<${unsubLink}>`;
486  
487      assert.ok(header.startsWith('<'));
488      assert.ok(header.endsWith('>'));
489      assert.ok(header.includes(unsubLink));
490      console.log('✅ List-Unsubscribe header format test passed');
491    });
492  
493    test('should validate email address format', () => {
494      const validEmails = [
495        'test@example.com',
496        'user.name@domain.co.uk',
497        'test+tag@example.com',
498        'delivered@resend.dev',
499      ];
500  
501      const invalidEmails = ['invalid', 'test@', '@example.com', 'test @example.com'];
502  
503      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
504  
505      validEmails.forEach(email => {
506        assert.ok(emailRegex.test(email), `${email} should be valid`);
507      });
508  
509      invalidEmails.forEach(email => {
510        assert.ok(!emailRegex.test(email), `${email} should be invalid`);
511      });
512  
513      console.log('✅ Email validation test passed');
514    });
515  });
516  
517  console.log('\n🧪 Running Resend Integration Tests...');
518  console.log(`📧 Using API key: ${resendApiKey.substring(0, 8)}...`);
519  console.log(`📬 Test addresses: ${Object.values(TEST_ADDRESSES).join(', ')}\n`);