/ tests / utils / sync-unsubscribes-sync.test.js
sync-unsubscribes-sync.test.js
  1  /**
  2   * Tests for syncUnsubscribes() in src/utils/sync-unsubscribes.js
  3   *
  4   * Tests the syncUnsubscribes() export and processUnsubscribes() internal path
  5   * by mocking the global fetch. Uses pg-mock pattern.
  6   *
  7   * NOTE: requires --experimental-test-module-mocks flag
  8   */
  9  
 10  import { test, describe, mock } from 'node:test';
 11  import assert from 'node:assert/strict';
 12  import Database from 'better-sqlite3';
 13  import { createPgMock } from '../helpers/pg-mock.js';
 14  
 15  process.env.UNSUBSCRIBE_WORKER_URL = 'https://worker.example.com';
 16  
 17  // ─── Create DB schema BEFORE importing module ─────────────────────────────────
 18  
 19  const db = new Database(':memory:');
 20  db.exec(`
 21    CREATE TABLE IF NOT EXISTS sites (
 22      id INTEGER PRIMARY KEY AUTOINCREMENT,
 23      domain TEXT NOT NULL,
 24      rescored_at DATETIME
 25    );
 26  
 27    CREATE TABLE IF NOT EXISTS messages (
 28      id INTEGER PRIMARY KEY AUTOINCREMENT,
 29      site_id INTEGER NOT NULL REFERENCES sites(id),
 30      direction TEXT NOT NULL DEFAULT 'outbound',
 31      contact_method TEXT NOT NULL DEFAULT 'email',
 32      contact_uri TEXT NOT NULL DEFAULT '',
 33      created_at TEXT NOT NULL DEFAULT (datetime('now')),
 34      message_type TEXT DEFAULT 'outreach',
 35      raw_payload TEXT,
 36      read_at TEXT
 37    );
 38  
 39    CREATE TABLE IF NOT EXISTS unsubscribed_emails (
 40      id INTEGER PRIMARY KEY AUTOINCREMENT,
 41      email TEXT NOT NULL UNIQUE,
 42      message_id INTEGER,
 43      source TEXT,
 44      unsubscribed_at TEXT DEFAULT (datetime('now'))
 45    );
 46  `);
 47  
 48  // Insert a site and an email outbound message for testing
 49  db.exec(`INSERT INTO sites (domain) VALUES ('example.com')`);
 50  const siteId = db.prepare('SELECT id FROM sites LIMIT 1').get().id;
 51  db.prepare(
 52    `INSERT INTO messages (site_id, direction, contact_method, contact_uri) VALUES (?, 'outbound', 'email', ?)`
 53  ).run(siteId, 'john@example.com');
 54  db.prepare(
 55    `INSERT INTO messages (site_id, direction, contact_method, contact_uri) VALUES (?, 'outbound', 'email', ?)`
 56  ).run(siteId, 'pending@example.com');
 57  
 58  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 59  
 60  // ─── Import AFTER schema setup ────────────────────────────────────────────────
 61  
 62  const { syncUnsubscribes } = await import('../../src/utils/sync-unsubscribes.js');
 63  
 64  // ─── Helper: get message IDs from test DB ────────────────────────────────────
 65  
 66  function getMessageId(email) {
 67    const row = db.prepare('SELECT id FROM messages WHERE contact_uri = ?').get(email);
 68    return row?.id;
 69  }
 70  
 71  function isUnsubscribed(email) {
 72    const row = db
 73      .prepare('SELECT 1 FROM unsubscribed_emails WHERE email = ? COLLATE NOCASE')
 74      .get(email);
 75    return !!row;
 76  }
 77  
 78  function clearUnsubscribes() {
 79    db.exec('DELETE FROM unsubscribed_emails');
 80  }
 81  
 82  // ─── syncUnsubscribes tests ───────────────────────────────────────────────────
 83  
 84  describe('syncUnsubscribes — processUnsubscribes path', () => {
 85    test('throws when UNSUBSCRIBE_WORKER_URL not set', async () => {
 86      const origUrl = process.env.UNSUBSCRIBE_WORKER_URL;
 87      delete process.env.UNSUBSCRIBE_WORKER_URL;
 88  
 89      await assert.rejects(() => syncUnsubscribes(), /UNSUBSCRIBE_WORKER_URL not configured/);
 90  
 91      process.env.UNSUBSCRIBE_WORKER_URL = origUrl;
 92    });
 93  
 94    test('returns 0 processed when worker returns empty array', async () => {
 95      globalThis.fetch = mock.fn(async () => ({
 96        ok: true,
 97        json: async () => [],
 98      }));
 99  
100      const stats = await syncUnsubscribes();
101      assert.equal(stats.processed, 0);
102      assert.equal(stats.skipped, 0);
103      assert.equal(stats.errors, 0);
104  
105      mock.reset();
106    });
107  
108    test('returns 0 processed when worker returns non-array (normalised to [])', async () => {
109      globalThis.fetch = mock.fn(async () => ({
110        ok: true,
111        json: async () => ({ message: 'no data' }),
112      }));
113  
114      const stats = await syncUnsubscribes();
115      assert.equal(stats.processed, 0);
116  
117      mock.reset();
118    });
119  
120    test('throws when fetch returns non-ok status', async () => {
121      globalThis.fetch = mock.fn(async () => ({
122        ok: false,
123        status: 500,
124        statusText: 'Server Error',
125      }));
126  
127      // retryWithBackoff will retry 3 times then throw
128      await assert.rejects(() => syncUnsubscribes(), /Failed to fetch unsubscribes/);
129  
130      mock.reset();
131    });
132  
133    test('skips sentinel message IDs (>= 999999000)', async () => {
134      clearUnsubscribes();
135  
136      globalThis.fetch = mock.fn(async () => ({
137        ok: true,
138        json: async () => [
139          { outreachId: 999999001, timestamp: new Date().toISOString() },
140          { outreachId: 999999000, timestamp: new Date().toISOString() },
141          { outreachId: null, timestamp: new Date().toISOString() },
142        ],
143      }));
144  
145      const stats = await syncUnsubscribes();
146      assert.equal(stats.processed, 0);
147      assert.equal(stats.skipped, 3);
148  
149      mock.reset();
150    });
151  
152    test('skips message IDs not found in messages table', async () => {
153      clearUnsubscribes();
154  
155      globalThis.fetch = mock.fn(async () => ({
156        ok: true,
157        json: async () => [
158          { outreachId: 999998, timestamp: new Date().toISOString() }, // non-existent
159        ],
160      }));
161  
162      const stats = await syncUnsubscribes();
163      assert.equal(stats.processed, 0);
164      assert.equal(stats.skipped, 1);
165  
166      mock.reset();
167    });
168  
169    test('processes valid unsubscribe and inserts into unsubscribed_emails', async () => {
170      clearUnsubscribes();
171      const messageId = getMessageId('john@example.com');
172  
173      globalThis.fetch = mock.fn(async () => ({
174        ok: true,
175        json: async () => [{ outreachId: messageId, timestamp: new Date().toISOString() }],
176      }));
177  
178      const stats = await syncUnsubscribes();
179      assert.equal(stats.processed, 1);
180      assert.equal(stats.skipped, 0);
181      assert.equal(stats.errors, 0);
182      assert.equal(isUnsubscribed('john@example.com'), true);
183  
184      mock.reset();
185      clearUnsubscribes();
186    });
187  
188    test('skips already-unsubscribed email (INSERT OR IGNORE)', async () => {
189      clearUnsubscribes();
190      const messageId = getMessageId('john@example.com');
191  
192      // Insert the email first
193      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('john@example.com');
194  
195      globalThis.fetch = mock.fn(async () => ({
196        ok: true,
197        json: async () => [{ outreachId: messageId, timestamp: new Date().toISOString() }],
198      }));
199  
200      const stats = await syncUnsubscribes();
201      assert.equal(stats.processed, 0);
202      assert.equal(stats.skipped, 1); // already unsubscribed
203  
204      mock.reset();
205      clearUnsubscribes();
206    });
207  
208    test('skips PENDING_CONTACT_EXTRACTION email', async () => {
209      clearUnsubscribes();
210      const pendingMsgId = getMessageId('pending@example.com');
211  
212      // Update the contact_uri to the sentinel value
213      db.prepare('UPDATE messages SET contact_uri = ? WHERE id = ?').run(
214        'PENDING_CONTACT_EXTRACTION',
215        pendingMsgId
216      );
217  
218      globalThis.fetch = mock.fn(async () => ({
219        ok: true,
220        json: async () => [{ outreachId: pendingMsgId, timestamp: new Date().toISOString() }],
221      }));
222  
223      const stats = await syncUnsubscribes();
224      assert.equal(stats.processed, 0);
225      assert.equal(stats.skipped, 1);
226  
227      // Restore contact_uri
228      db.prepare('UPDATE messages SET contact_uri = ? WHERE id = ?').run(
229        'pending@example.com',
230        pendingMsgId
231      );
232  
233      mock.reset();
234    });
235  
236    test('throws and re-throws on network error', async () => {
237      globalThis.fetch = mock.fn(async () => {
238        throw new Error('Network down');
239      });
240  
241      await assert.rejects(() => syncUnsubscribes(), /Network down/);
242  
243      mock.reset();
244    });
245  });