/ tests / compliance / sync-unsubscribes.test.js
sync-unsubscribes.test.js
  1  /**
  2   * Unit Tests for Sync Unsubscribes Module
  3   *
  4   * Tests database operations and API integration for unsubscribe syncing.
  5   * Uses mock.module() to intercept db.js imports and route SQL through SQLite.
  6   */
  7  
  8  import { describe, it, beforeEach, mock } from 'node:test';
  9  import assert from 'node:assert';
 10  import Database from 'better-sqlite3';
 11  import { createPgMock } from '../helpers/pg-mock.js';
 12  
 13  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 14  
 15  const db = new Database(':memory:');
 16  
 17  db.exec(`
 18    CREATE TABLE sites (
 19      id INTEGER PRIMARY KEY AUTOINCREMENT,
 20      domain TEXT NOT NULL,
 21      landing_page_url TEXT,
 22      keyword TEXT,
 23      status TEXT DEFAULT 'proposals_drafted',
 24      rescored_at DATETIME
 25    );
 26  
 27    CREATE TABLE messages (
 28      id INTEGER PRIMARY KEY AUTOINCREMENT,
 29      site_id INTEGER NOT NULL,
 30      contact_uri TEXT,
 31      contact_method TEXT,
 32      message_body TEXT,
 33      delivery_status TEXT,
 34      direction TEXT NOT NULL DEFAULT 'outbound',
 35      message_type TEXT DEFAULT 'outreach',
 36      raw_payload TEXT,
 37      read_at TEXT,
 38      FOREIGN KEY (site_id) REFERENCES sites(id)
 39    );
 40  
 41    CREATE TABLE unsubscribed_emails (
 42      id INTEGER PRIMARY KEY AUTOINCREMENT,
 43      email TEXT NOT NULL UNIQUE COLLATE NOCASE,
 44      message_id INTEGER,
 45      source TEXT DEFAULT 'web',
 46      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 47    );
 48  `);
 49  
 50  // ─── Mock db.js BEFORE importing sync-unsubscribes.js ────────────────────────
 51  
 52  mock.module('../../src/utils/db.js', {
 53    namedExports: createPgMock(db),
 54  });
 55  
 56  // Set worker URL before import
 57  process.env.UNSUBSCRIBE_WORKER_URL = 'https://test-worker.example.com';
 58  
 59  // Mock fetch globally
 60  const mockFetch = mock.fn();
 61  global.fetch = mockFetch;
 62  
 63  // Import AFTER mock.module
 64  const { syncUnsubscribes, isEmailUnsubscribed, getUnsubscribeCount } =
 65    await import('../../src/utils/sync-unsubscribes.js');
 66  
 67  // ─── Helpers ──────────────────────────────────────────────────────────────────
 68  
 69  function clearDb() {
 70    db.prepare('DELETE FROM unsubscribed_emails').run();
 71    db.prepare('DELETE FROM messages').run();
 72    db.prepare('DELETE FROM sites').run();
 73  }
 74  
 75  function insertSite() {
 76    return db
 77      .prepare(
 78        `INSERT INTO sites (domain, landing_page_url, keyword, status)
 79         VALUES (?, ?, ?, ?)`
 80      )
 81      .run('example.com', 'https://example.com', 'test keyword', 'proposals_drafted')
 82      .lastInsertRowid;
 83  }
 84  
 85  function insertMessage(siteId, contactUri, contactMethod = 'email', deliveryStatus = 'sent') {
 86    return db
 87      .prepare(
 88        `INSERT INTO messages (site_id, contact_uri, contact_method, message_body, delivery_status)
 89         VALUES (?, ?, ?, ?, ?)`
 90      )
 91      .run(siteId, contactUri, contactMethod, 'Test proposal', deliveryStatus)
 92      .lastInsertRowid;
 93  }
 94  
 95  // ─── Tests ───────────────────────────────────────────────────────────────────
 96  
 97  describe('Sync Unsubscribes Module', () => {
 98    beforeEach(() => {
 99      clearDb();
100      mockFetch.mock.resetCalls();
101    });
102  
103    describe('isEmailUnsubscribed', () => {
104      it('should return false for email not in unsubscribe list', async () => {
105        const result = await isEmailUnsubscribed('test@example.com');
106        assert.strictEqual(result, false);
107      });
108  
109      it('should return true for email in unsubscribe list', async () => {
110        db.prepare(
111          `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
112           VALUES (?, ?, ?)`
113        ).run('test@example.com', 'web', new Date().toISOString());
114  
115        const result = await isEmailUnsubscribed('test@example.com');
116        assert.strictEqual(result, true);
117      });
118  
119      it('should be case-insensitive', async () => {
120        db.prepare(
121          `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
122           VALUES (?, ?, ?)`
123        ).run('test@example.com', 'web', new Date().toISOString());
124  
125        const result = await isEmailUnsubscribed('TEST@EXAMPLE.COM');
126        assert.strictEqual(result, true);
127      });
128  
129      it('should handle emails with special characters', async () => {
130        db.prepare(
131          `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
132           VALUES (?, ?, ?)`
133        ).run('test+filter@example.com', 'web', new Date().toISOString());
134  
135        const result = await isEmailUnsubscribed('test+filter@example.com');
136        assert.strictEqual(result, true);
137      });
138    });
139  
140    describe('getUnsubscribeCount', () => {
141      it('should return 0 when no unsubscribes exist', async () => {
142        const count = await getUnsubscribeCount();
143        assert.strictEqual(count, 0);
144      });
145  
146      it('should return correct count with single unsubscribe', async () => {
147        db.prepare(
148          `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
149           VALUES (?, ?, ?)`
150        ).run('test@example.com', 'web', new Date().toISOString());
151  
152        const count = await getUnsubscribeCount();
153        assert.strictEqual(count, 1);
154      });
155  
156      it('should return correct count with multiple unsubscribes', async () => {
157        const emails = ['test1@example.com', 'test2@example.com', 'test3@example.com'];
158  
159        for (const email of emails) {
160          db.prepare(
161            `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
162             VALUES (?, ?, ?)`
163          ).run(email, 'web', new Date().toISOString());
164        }
165  
166        const count = await getUnsubscribeCount();
167        assert.strictEqual(count, 3);
168      });
169  
170      it('should not count duplicate emails (UNIQUE constraint)', async () => {
171        const email = 'test@example.com';
172  
173        db.prepare(
174          `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
175           VALUES (?, ?, ?)`
176        ).run(email, 'web', new Date().toISOString());
177  
178        // Try to insert again (should be ignored due to UNIQUE constraint)
179        try {
180          db.prepare(
181            `INSERT INTO unsubscribed_emails (email, source, unsubscribed_at)
182             VALUES (?, ?, ?)`
183          ).run(email, 'web', new Date().toISOString());
184        } catch {
185          // Expected to fail due to UNIQUE constraint
186        }
187  
188        const count = await getUnsubscribeCount();
189        assert.strictEqual(count, 1);
190      });
191    });
192  
193    describe('syncUnsubscribes', () => {
194      it('should handle empty unsubscribe list from worker', async () => {
195        mockFetch.mock.mockImplementation(() =>
196          Promise.resolve({
197            ok: true,
198            json: () => Promise.resolve([]),
199          })
200        );
201  
202        const stats = await syncUnsubscribes();
203  
204        assert.strictEqual(stats.processed, 0);
205        assert.strictEqual(stats.skipped, 0);
206        assert.strictEqual(stats.errors, 0);
207        assert.strictEqual(mockFetch.mock.callCount(), 1);
208      });
209  
210      it('should process valid unsubscribe from worker', async () => {
211        const siteId = insertSite();
212        const outreachId = insertMessage(siteId, 'contact@example.com', 'email', 'sent');
213  
214        mockFetch.mock.mockImplementation(() =>
215          Promise.resolve({
216            ok: true,
217            json: () =>
218              Promise.resolve([
219                {
220                  outreachId,
221                  timestamp: new Date().toISOString(),
222                },
223              ]),
224          })
225        );
226  
227        const stats = await syncUnsubscribes();
228  
229        assert.strictEqual(stats.processed, 1);
230        assert.strictEqual(stats.skipped, 0);
231        assert.strictEqual(stats.errors, 0);
232  
233        // Verify email was added to unsubscribe list
234        assert.strictEqual(await isEmailUnsubscribed('contact@example.com'), true);
235      });
236  
237      it('should skip non-existent outreach IDs', async () => {
238        mockFetch.mock.mockImplementation(() =>
239          Promise.resolve({
240            ok: true,
241            json: () =>
242              Promise.resolve([
243                {
244                  outreachId: 99999,
245                  timestamp: new Date().toISOString(),
246                },
247              ]),
248          })
249        );
250  
251        const stats = await syncUnsubscribes();
252  
253        assert.strictEqual(stats.processed, 0);
254        assert.strictEqual(stats.skipped, 1);
255        assert.strictEqual(stats.errors, 0);
256      });
257  
258      it('should skip non-email outreaches (SMS, forms, etc)', async () => {
259        const siteId = insertSite();
260        const outreachId = insertMessage(siteId, 'tel:+61412345678', 'sms', 'sent');
261  
262        mockFetch.mock.mockImplementation(() =>
263          Promise.resolve({
264            ok: true,
265            json: () =>
266              Promise.resolve([
267                {
268                  outreachId,
269                  timestamp: new Date().toISOString(),
270                },
271              ]),
272          })
273        );
274  
275        const stats = await syncUnsubscribes();
276  
277        assert.strictEqual(stats.processed, 0);
278        assert.strictEqual(stats.skipped, 1);
279        assert.strictEqual(stats.errors, 0);
280      });
281  
282      it('should skip invalid emails (PENDING_CONTACT_EXTRACTION)', async () => {
283        const siteId = insertSite();
284        const outreachId = insertMessage(siteId, 'PENDING_CONTACT_EXTRACTION', 'email', 'sent');
285  
286        mockFetch.mock.mockImplementation(() =>
287          Promise.resolve({
288            ok: true,
289            json: () =>
290              Promise.resolve([
291                {
292                  outreachId,
293                  timestamp: new Date().toISOString(),
294                },
295              ]),
296          })
297        );
298  
299        const stats = await syncUnsubscribes();
300  
301        assert.strictEqual(stats.processed, 0);
302        assert.strictEqual(stats.skipped, 1);
303        assert.strictEqual(stats.errors, 0);
304      });
305  
306      it('should skip emails without @ symbol', async () => {
307        const siteId = insertSite();
308        const outreachId = insertMessage(siteId, 'invalid-email', 'email', 'sent');
309  
310        mockFetch.mock.mockImplementation(() =>
311          Promise.resolve({
312            ok: true,
313            json: () =>
314              Promise.resolve([
315                {
316                  outreachId,
317                  timestamp: new Date().toISOString(),
318                },
319              ]),
320          })
321        );
322  
323        const stats = await syncUnsubscribes();
324  
325        assert.strictEqual(stats.processed, 0);
326        assert.strictEqual(stats.skipped, 1);
327        assert.strictEqual(stats.errors, 0);
328      });
329  
330      it('should handle duplicate unsubscribes (already processed)', async () => {
331        const siteId = insertSite();
332        const outreachId = insertMessage(siteId, 'contact@example.com', 'email', 'sent');
333  
334        // Already insert into unsubscribe list
335        db.prepare(
336          `INSERT INTO unsubscribed_emails (email, message_id, source, unsubscribed_at)
337           VALUES (?, ?, ?, ?)`
338        ).run('contact@example.com', outreachId, 'web', new Date().toISOString());
339  
340        mockFetch.mock.mockImplementation(() =>
341          Promise.resolve({
342            ok: true,
343            json: () =>
344              Promise.resolve([
345                {
346                  outreachId,
347                  timestamp: new Date().toISOString(),
348                },
349              ]),
350          })
351        );
352  
353        const stats = await syncUnsubscribes();
354  
355        // Should skip (already processed via INSERT OR IGNORE / ON CONFLICT DO NOTHING)
356        assert.strictEqual(stats.processed, 0);
357        assert.strictEqual(stats.skipped, 1);
358        assert.strictEqual(stats.errors, 0);
359  
360        // Verify still only 1 entry in unsubscribe list
361        assert.strictEqual(await getUnsubscribeCount(), 1);
362      });
363  
364      it('should process multiple unsubscribes in batch', async () => {
365        const siteId = insertSite();
366  
367        const outreachIds = [];
368        const emails = ['contact1@example.com', 'contact2@example.com', 'contact3@example.com'];
369  
370        for (const email of emails) {
371          outreachIds.push(insertMessage(siteId, email, 'email', 'sent'));
372        }
373  
374        mockFetch.mock.mockImplementation(() =>
375          Promise.resolve({
376            ok: true,
377            json: () =>
378              Promise.resolve(
379                outreachIds.map(id => ({
380                  outreachId: id,
381                  timestamp: new Date().toISOString(),
382                }))
383              ),
384          })
385        );
386  
387        const stats = await syncUnsubscribes();
388  
389        assert.strictEqual(stats.processed, 3);
390        assert.strictEqual(stats.skipped, 0);
391        assert.strictEqual(stats.errors, 0);
392  
393        // Verify all 3 emails were added to unsubscribe list
394        assert.strictEqual(await getUnsubscribeCount(), 3);
395      });
396  
397      it('should throw error when UNSUBSCRIBE_WORKER_URL not configured', async () => {
398        // NOTE: This specific test case is handled in sync-unsubscribes-no-config.test.js
399        // due to ES module caching limitations. That separate file doesn't set the env var
400        // before importing, allowing us to test the error case properly.
401        //
402        // This placeholder test documents that the error handling is tested separately.
403        assert.ok(
404          true,
405          'Error handling for missing UNSUBSCRIBE_WORKER_URL is tested in sync-unsubscribes-no-config.test.js'
406        );
407      });
408  
409      it('should throw error when worker returns non-200 status', async () => {
410        mockFetch.mock.mockImplementation(() =>
411          Promise.resolve({
412            ok: false,
413            status: 500,
414            statusText: 'Internal Server Error',
415          })
416        );
417  
418        await assert.rejects(async () => await syncUnsubscribes(), {
419          message: /Failed to fetch unsubscribes: 500 Internal Server Error/,
420        });
421      });
422  
423      it('should handle malformed JSON response from worker', async () => {
424        mockFetch.mock.mockImplementation(() =>
425          Promise.resolve({
426            ok: true,
427            json: () => Promise.resolve(null), // Not an array
428          })
429        );
430  
431        const stats = await syncUnsubscribes();
432  
433        // Should handle non-array response gracefully
434        assert.strictEqual(stats.processed, 0);
435        assert.strictEqual(stats.skipped, 0);
436        assert.strictEqual(stats.errors, 0);
437      });
438  
439      it('should handle worker returning object instead of array', async () => {
440        mockFetch.mock.mockImplementation(() =>
441          Promise.resolve({
442            ok: true,
443            json: () => Promise.resolve({ error: 'Something went wrong' }), // Object, not array
444          })
445        );
446  
447        const stats = await syncUnsubscribes();
448  
449        // Should convert to empty array and process gracefully
450        assert.strictEqual(stats.processed, 0);
451        assert.strictEqual(stats.skipped, 0);
452        assert.strictEqual(stats.errors, 0);
453      });
454  
455      it('should handle network errors from worker', async () => {
456        mockFetch.mock.mockImplementation(() =>
457          Promise.reject(new Error('Network error: ECONNREFUSED'))
458        );
459  
460        await assert.rejects(async () => await syncUnsubscribes(), {
461          message: /Network error: ECONNREFUSED/,
462        });
463      });
464    });
465  
466    describe('Module Exports', () => {
467      it('should export all required functions', async () => {
468        const module = await import('../../src/utils/sync-unsubscribes.js');
469  
470        assert.strictEqual(typeof module.syncUnsubscribes, 'function');
471        assert.strictEqual(typeof module.isEmailUnsubscribed, 'function');
472        assert.strictEqual(typeof module.getUnsubscribeCount, 'function');
473        assert.ok(module.default);
474        assert.strictEqual(typeof module.default.syncUnsubscribes, 'function');
475        assert.strictEqual(typeof module.default.isEmailUnsubscribed, 'function');
476        assert.strictEqual(typeof module.default.getUnsubscribeCount, 'function');
477      });
478    });
479  });
480  
481  /*
482   * NOTE: Internal functions not tested directly:
483   * - fetchUnsubscribes() - Tested indirectly through syncUnsubscribes()
484   * - processUnsubscribes() - Tested indirectly through syncUnsubscribes()
485   * - _clearProcessedUnsubscribes() - Placeholder function, not implemented
486   *
487   * These internal functions are covered through integration testing of the
488   * main exported functions.
489   */