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 */