/ tests / utils / sync-email-events.test.js
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  });