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