sync-email-events.test.js
1 /** 2 * Tests for src/utils/sync-email-events.js 3 */ 4 import { test, describe, before, beforeEach, mock } from 'node:test'; 5 import assert from 'node:assert/strict'; 6 import Database from 'better-sqlite3'; 7 import { createPgMock } from '../helpers/pg-mock.js'; 8 9 process.env.EMAIL_EVENTS_WORKER_URL = 'https://worker.example.com'; 10 11 // Initialize schema 12 const db = new Database(':memory:'); 13 db.exec(` 14 CREATE TABLE IF NOT EXISTS messages ( 15 id INTEGER PRIMARY KEY AUTOINCREMENT, 16 site_id INTEGER, 17 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 18 contact_uri TEXT, 19 contact_method TEXT DEFAULT 'email', 20 approval_status TEXT, 21 delivery_status TEXT DEFAULT 'sent', 22 opened_at TEXT, 23 tracking_clicked_at TEXT, 24 created_at TEXT DEFAULT (datetime('now')), 25 message_type TEXT DEFAULT 'outreach', 26 raw_payload TEXT, 27 read_at TEXT 28 ); 29 CREATE TABLE IF NOT EXISTS unsubscribed_emails ( 30 id INTEGER PRIMARY KEY AUTOINCREMENT, 31 email TEXT NOT NULL, 32 message_id INTEGER, 33 site_id INTEGER, 34 source TEXT, 35 created_at TEXT DEFAULT (datetime('now')), 36 UNIQUE(email) 37 ); 38 `); 39 40 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 41 42 const { extractTagValue, syncEmailEvents } = await import('../../src/utils/sync-email-events.js'); 43 44 function clearTables() { 45 db.exec('DELETE FROM messages; DELETE FROM unsubscribed_emails'); 46 } 47 48 function insertOutreach(overrides = {}) { 49 const result = db 50 .prepare( 51 ` 52 INSERT INTO messages (contact_uri, contact_method, delivery_status) 53 VALUES (?, ?, ?) 54 ` 55 ) 56 .run( 57 overrides.contact_uri ?? 'test@example.com', 58 overrides.contact_method ?? 'email', 59 overrides.status ?? 'sent' 60 ); 61 return result.lastInsertRowid; 62 } 63 64 function mockFetch(events, { clearFails = false } = {}) { 65 let callCount = 0; 66 globalThis.fetch = async (url, opts) => { 67 callCount++; 68 if (opts?.method === 'DELETE') { 69 if (clearFails) return { ok: false, status: 500, statusText: 'Error' }; 70 return { ok: true }; 71 } 72 return { 73 ok: true, 74 json: async () => events, 75 }; 76 }; 77 return () => callCount; 78 } 79 80 // ── extractTagValue (already tested, keep a few) ───────────────────────────── 81 82 describe('extractTagValue', () => { 83 test('extracts from array format [{name, value}]', () => { 84 const tags = [{ name: 'site_id', value: '42' }]; 85 assert.equal(extractTagValue(tags, 'site_id'), '42'); 86 }); 87 88 test('extracts from object format {key: value}', () => { 89 const tags = { site_id: '42' }; 90 assert.equal(extractTagValue(tags, 'site_id'), '42'); 91 }); 92 93 test('returns undefined for null tags', () => { 94 assert.equal(extractTagValue(null, 'site_id'), undefined); 95 }); 96 97 test('returns undefined for undefined tags', () => { 98 assert.equal(extractTagValue(undefined, 'site_id'), undefined); 99 }); 100 101 test('returns undefined when key not found in array', () => { 102 const tags = [{ name: 'other', value: 'val' }]; 103 assert.equal(extractTagValue(tags, 'site_id'), undefined); 104 }); 105 106 test('returns undefined when key not found in object', () => { 107 const tags = { other: 'val' }; 108 assert.equal(extractTagValue(tags, 'site_id'), undefined); 109 }); 110 111 test('handles array with multiple tags', () => { 112 const tags = [ 113 { name: 'campaign', value: 'spring' }, 114 { name: 'site_id', value: '99' }, 115 ]; 116 assert.equal(extractTagValue(tags, 'site_id'), '99'); 117 }); 118 }); 119 120 // ── syncEmailEvents ─────────────────────────────────────────────────────────── 121 122 describe('syncEmailEvents - no events', () => { 123 test('returns zero counts when no events', async () => { 124 mockFetch([]); 125 const result = await syncEmailEvents(); 126 assert.equal(result.processed, 0); 127 assert.equal(result.opened, 0); 128 }); 129 }); 130 131 describe('syncEmailEvents - email.opened', () => { 132 beforeEach(() => clearTables()); 133 134 test('marks outreach as opened', async () => { 135 const id = insertOutreach(); 136 mockFetch([ 137 { 138 type: 'email.opened', 139 created_at: '2024-01-01T10:00:00Z', 140 data: { tags: [{ name: 'outreach_id', value: String(id) }], email_id: 'msg1' }, 141 }, 142 ]); 143 144 const result = await syncEmailEvents(); 145 assert.equal(result.opened, 1); 146 147 const row = db.prepare('SELECT opened_at FROM messages WHERE id = ?').get(id); 148 assert.ok(row.opened_at); 149 }); 150 151 test('does not double-count already opened outreach', async () => { 152 const id = insertOutreach(); 153 const event = { 154 type: 'email.opened', 155 created_at: '2024-01-01T10:00:00Z', 156 data: { tags: [{ name: 'outreach_id', value: String(id) }], email_id: 'msg1' }, 157 }; 158 159 mockFetch([event]); 160 await syncEmailEvents(); 161 162 mockFetch([event]); 163 const result = await syncEmailEvents(); 164 assert.equal(result.opened, 0); // Already marked 165 }); 166 167 test('skips when no site_id tag', async () => { 168 mockFetch([ 169 { 170 type: 'email.opened', 171 created_at: '2024-01-01T10:00:00Z', 172 data: { tags: [], email_id: 'msg1' }, 173 }, 174 ]); 175 const result = await syncEmailEvents(); 176 assert.equal(result.opened, 0); 177 }); 178 179 test('skips when outreach not found', async () => { 180 mockFetch([ 181 { 182 type: 'email.opened', 183 created_at: '2024-01-01T10:00:00Z', 184 data: { tags: [{ name: 'outreach_id', value: '99999' }], email_id: 'msg1' }, 185 }, 186 ]); 187 const result = await syncEmailEvents(); 188 assert.equal(result.opened, 0); 189 }); 190 }); 191 192 describe('syncEmailEvents - email.clicked', () => { 193 beforeEach(() => clearTables()); 194 195 test('marks outreach as clicked', async () => { 196 const id = insertOutreach(); 197 mockFetch([ 198 { 199 type: 'email.clicked', 200 created_at: '2024-01-02T10:00:00Z', 201 data: { tags: [{ name: 'outreach_id', value: String(id) }] }, 202 }, 203 ]); 204 205 const result = await syncEmailEvents(); 206 assert.equal(result.clicked, 1); 207 208 const row = db.prepare('SELECT tracking_clicked_at FROM messages WHERE id = ?').get(id); 209 assert.ok(row.tracking_clicked_at); 210 }); 211 212 test('does not double-count already clicked outreach', async () => { 213 const id = insertOutreach(); 214 const event = { 215 type: 'email.clicked', 216 created_at: '2024-01-02T10:00:00Z', 217 data: { tags: [{ name: 'outreach_id', value: String(id) }] }, 218 }; 219 mockFetch([event]); 220 await syncEmailEvents(); 221 mockFetch([event]); 222 const result = await syncEmailEvents(); 223 assert.equal(result.clicked, 0); 224 }); 225 226 test('skips when no site_id', async () => { 227 mockFetch([{ type: 'email.clicked', created_at: '2024-01-02T10:00:00Z', data: { tags: [] } }]); 228 const result = await syncEmailEvents(); 229 assert.equal(result.clicked, 0); 230 }); 231 232 test('skips when outreach not found', async () => { 233 mockFetch([ 234 { 235 type: 'email.clicked', 236 created_at: '2024-01-02T10:00:00Z', 237 data: { tags: [{ name: 'outreach_id', value: '99998' }] }, 238 }, 239 ]); 240 const result = await syncEmailEvents(); 241 assert.equal(result.clicked, 0); 242 }); 243 }); 244 245 describe('syncEmailEvents - email.bounced', () => { 246 beforeEach(() => clearTables()); 247 248 test('marks outreach as bounced', async () => { 249 const id = insertOutreach({ contact_uri: 'bounce@example.com' }); 250 mockFetch([ 251 { 252 type: 'email.bounced', 253 created_at: '2024-01-03T10:00:00Z', 254 data: { tags: [{ name: 'outreach_id', value: String(id) }], type: 'soft_bounce' }, 255 }, 256 ]); 257 258 const result = await syncEmailEvents(); 259 assert.equal(result.bounced, 1); 260 261 const row = db.prepare('SELECT delivery_status FROM messages WHERE id = ?').get(id); 262 assert.equal(row.delivery_status, 'bounced'); 263 }); 264 265 test('adds hard bounces to unsubscribe list', async () => { 266 const id = insertOutreach({ contact_uri: 'hard@example.com' }); 267 mockFetch([ 268 { 269 type: 'email.bounced', 270 created_at: '2024-01-03T10:00:00Z', 271 data: { tags: [{ name: 'outreach_id', value: String(id) }], type: 'hard_bounce' }, 272 }, 273 ]); 274 275 await syncEmailEvents(); 276 277 const row = db 278 .prepare("SELECT * FROM unsubscribed_emails WHERE email = 'hard@example.com'") 279 .get(); 280 assert.ok(row, 'Should be in unsubscribe list'); 281 assert.equal(row.source, 'bounce'); 282 }); 283 284 test('soft bounce does NOT add to unsubscribe list', async () => { 285 const id = insertOutreach({ contact_uri: 'soft@example.com' }); 286 mockFetch([ 287 { 288 type: 'email.bounced', 289 created_at: '2024-01-03T10:00:00Z', 290 data: { tags: [{ name: 'outreach_id', value: String(id) }], type: 'soft_bounce' }, 291 }, 292 ]); 293 294 await syncEmailEvents(); 295 296 const row = db 297 .prepare("SELECT * FROM unsubscribed_emails WHERE email = 'soft@example.com'") 298 .get(); 299 assert.equal(row, undefined); 300 }); 301 302 test('skips when no site_id', async () => { 303 mockFetch([{ type: 'email.bounced', created_at: '2024-01-03T10:00:00Z', data: { tags: [] } }]); 304 const result = await syncEmailEvents(); 305 assert.equal(result.bounced, 0); 306 }); 307 }); 308 309 describe('syncEmailEvents - email.complained', () => { 310 beforeEach(() => clearTables()); 311 312 test('adds complaint to unsubscribe list', async () => { 313 const id = insertOutreach({ contact_uri: 'complainer@example.com' }); 314 mockFetch([ 315 { 316 type: 'email.complained', 317 created_at: '2024-01-04T10:00:00Z', 318 data: { tags: [{ name: 'outreach_id', value: String(id) }] }, 319 }, 320 ]); 321 322 const result = await syncEmailEvents(); 323 assert.equal(result.complained, 1); 324 325 const row = db 326 .prepare("SELECT * FROM unsubscribed_emails WHERE email = 'complainer@example.com'") 327 .get(); 328 assert.ok(row); 329 assert.equal(row.source, 'complaint'); 330 }); 331 332 test('skips when no site_id', async () => { 333 mockFetch([ 334 { type: 'email.complained', created_at: '2024-01-04T10:00:00Z', data: { tags: [] } }, 335 ]); 336 const result = await syncEmailEvents(); 337 assert.equal(result.complained, 0); 338 }); 339 340 test('skips when outreach not found', async () => { 341 mockFetch([ 342 { 343 type: 'email.complained', 344 created_at: '2024-01-04T10:00:00Z', 345 data: { tags: [{ name: 'outreach_id', value: '88888' }] }, 346 }, 347 ]); 348 const result = await syncEmailEvents(); 349 assert.equal(result.complained, 0); 350 }); 351 }); 352 353 describe('syncEmailEvents - email.received', () => { 354 beforeEach(() => clearTables()); 355 356 test('logs but does not count received events', async () => { 357 mockFetch([ 358 { 359 type: 'email.received', 360 created_at: '2024-01-05T10:00:00Z', 361 data: { email_id: 'msg99' }, 362 }, 363 ]); 364 365 const result = await syncEmailEvents(); 366 assert.equal(result.received, 0); // processReceivedEvent returns false 367 }); 368 }); 369 370 describe('syncEmailEvents - email.delivered', () => { 371 test('ignores delivered events', async () => { 372 mockFetch([ 373 { 374 type: 'email.delivered', 375 created_at: '2024-01-06T10:00:00Z', 376 data: {}, 377 }, 378 ]); 379 const result = await syncEmailEvents(); 380 assert.equal(result.processed, 1); 381 assert.equal(result.opened, 0); 382 assert.equal(result.clicked, 0); 383 }); 384 }); 385 386 describe('syncEmailEvents - unknown event type', () => { 387 test('handles unknown event type gracefully', async () => { 388 mockFetch([ 389 { 390 type: 'email.unknown_event', 391 created_at: '2024-01-07T10:00:00Z', 392 data: {}, 393 }, 394 ]); 395 const result = await syncEmailEvents(); 396 assert.equal(result.processed, 1); 397 }); 398 }); 399 400 describe('syncEmailEvents - fetch failures', () => { 401 test('throws when EMAIL_EVENTS_WORKER_URL not configured', async () => { 402 const orig = process.env.EMAIL_EVENTS_WORKER_URL; 403 delete process.env.EMAIL_EVENTS_WORKER_URL; 404 try { 405 await assert.rejects(() => syncEmailEvents(), /EMAIL_EVENTS_WORKER_URL not configured/); 406 } finally { 407 process.env.EMAIL_EVENTS_WORKER_URL = orig; 408 } 409 }); 410 411 test('throws when fetch fails completely', async () => { 412 globalThis.fetch = async () => { 413 throw new Error('Network down'); 414 }; 415 await assert.rejects(() => syncEmailEvents(), /Network down/); 416 }); 417 418 test('throws when fetch returns non-OK', async () => { 419 globalThis.fetch = async () => ({ ok: false, status: 500, statusText: 'Server Error' }); 420 await assert.rejects(() => syncEmailEvents(), /Failed to fetch events: 500/); 421 }); 422 423 test('throws when clearEmailEvents fails (non-OK DELETE response)', async () => { 424 // Empty events → returns early before clearEmailEvents, so use a non-empty batch 425 globalThis.fetch = async (url, opts) => { 426 if (opts?.method === 'DELETE') { 427 return { ok: false, status: 500, statusText: 'Internal Error' }; 428 } 429 return { 430 ok: true, 431 json: async () => [{ type: 'email.delivered', created_at: '2024-01-01', data: {} }], 432 }; 433 }; 434 await assert.rejects(() => syncEmailEvents(), /Failed to clear events: 500/); 435 }); 436 }); 437 438 describe('syncEmailEvents - mixed event batch', () => { 439 beforeEach(() => clearTables()); 440 441 test('processes multiple event types in one batch', async () => { 442 const openId = insertOutreach({ contact_uri: 'open@ex.com' }); 443 const clickId = insertOutreach({ contact_uri: 'click@ex.com' }); 444 const bounceId = insertOutreach({ contact_uri: 'bounce@ex.com' }); 445 446 mockFetch([ 447 { 448 type: 'email.opened', 449 created_at: '2024-01-01T10:00:00Z', 450 data: { tags: [{ name: 'outreach_id', value: String(openId) }] }, 451 }, 452 { 453 type: 'email.clicked', 454 created_at: '2024-01-01T10:00:00Z', 455 data: { tags: [{ name: 'outreach_id', value: String(clickId) }] }, 456 }, 457 { 458 type: 'email.bounced', 459 created_at: '2024-01-01T10:00:00Z', 460 data: { tags: [{ name: 'outreach_id', value: String(bounceId) }], type: 'soft_bounce' }, 461 }, 462 ]); 463 464 const result = await syncEmailEvents(); 465 assert.equal(result.opened, 1); 466 assert.equal(result.clicked, 1); 467 assert.equal(result.bounced, 1); 468 assert.equal(result.processed, 3); 469 }); 470 });