@bernierllc/email-tracking 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.eslintrc.cjs +24 -0
  2. package/README.md +478 -0
  3. package/__mocks__/neverhub-adapter.ts +29 -0
  4. package/__tests__/EmailTracking.test.ts +334 -0
  5. package/__tests__/campaign-stats.test.ts +224 -0
  6. package/__tests__/config.test.ts +108 -0
  7. package/__tests__/integration.test.ts +244 -0
  8. package/__tests__/recipient-history.test.ts +210 -0
  9. package/__tests__/status-calculator.test.ts +141 -0
  10. package/coverage/clover.xml +187 -0
  11. package/coverage/coverage-final.json +6 -0
  12. package/coverage/lcov-report/EmailTracking.ts.html +973 -0
  13. package/coverage/lcov-report/base.css +224 -0
  14. package/coverage/lcov-report/block-navigation.js +87 -0
  15. package/coverage/lcov-report/config.ts.html +163 -0
  16. package/coverage/lcov-report/database.ts.html +622 -0
  17. package/coverage/lcov-report/favicon.png +0 -0
  18. package/coverage/lcov-report/index.html +176 -0
  19. package/coverage/lcov-report/prettify.css +1 -0
  20. package/coverage/lcov-report/prettify.js +2 -0
  21. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  22. package/coverage/lcov-report/sorter.js +210 -0
  23. package/coverage/lcov-report/status-calculator.ts.html +214 -0
  24. package/coverage/lcov-report/types.ts.html +430 -0
  25. package/coverage/lcov.info +356 -0
  26. package/dist/EmailTracking.d.ts +22 -0
  27. package/dist/EmailTracking.d.ts.map +1 -0
  28. package/dist/EmailTracking.js +253 -0
  29. package/dist/EmailTracking.js.map +1 -0
  30. package/dist/config.d.ts +3 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +27 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/database.d.ts +22 -0
  35. package/dist/database.d.ts.map +1 -0
  36. package/dist/database.js +156 -0
  37. package/dist/database.js.map +1 -0
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +33 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/status-calculator.d.ts +4 -0
  43. package/dist/status-calculator.d.ts.map +1 -0
  44. package/dist/status-calculator.js +40 -0
  45. package/dist/status-calculator.js.map +1 -0
  46. package/dist/types.d.ts +98 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +22 -0
  49. package/dist/types.js.map +1 -0
  50. package/jest.config.cjs +32 -0
  51. package/package.json +54 -0
  52. package/src/EmailTracking.ts +296 -0
  53. package/src/config.ts +26 -0
  54. package/src/database.ts +179 -0
  55. package/src/index.ts +12 -0
  56. package/src/status-calculator.ts +43 -0
  57. package/src/types.ts +115 -0
  58. package/tsconfig.json +31 -0
@@ -0,0 +1,334 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { EmailTracking } from '../src/EmailTracking';
10
+ import { EmailDeliveryStatus } from '../src/types';
11
+
12
+ describe('EmailTracking', () => {
13
+ let tracking: EmailTracking;
14
+
15
+ beforeEach(async () => {
16
+ tracking = new EmailTracking({
17
+ enableOpenTracking: true,
18
+ enableClickTracking: true,
19
+ eventRetentionDays: 90,
20
+ aggregateStats: true
21
+ });
22
+ await tracking.initialize();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await tracking.clearDatabase();
27
+ });
28
+
29
+ describe('recordEmailSent', () => {
30
+ it('should record email send and create tracking ID', async () => {
31
+ const result = await tracking.recordEmailSent({
32
+ messageId: 'msg_123',
33
+ provider: 'sendgrid',
34
+ from: 'sender@example.com',
35
+ to: ['recipient@example.com'],
36
+ subject: 'Test Email'
37
+ });
38
+
39
+ expect(result.success).toBe(true);
40
+ expect(result.data?.id).toMatch(/^track_/);
41
+ expect(result.data?.currentStatus).toBe(EmailDeliveryStatus.SENT);
42
+ expect(result.data?.messageId).toBe('msg_123');
43
+ expect(result.data?.provider).toBe('sendgrid');
44
+ expect(result.data?.from).toBe('sender@example.com');
45
+ expect(result.data?.to).toEqual(['recipient@example.com']);
46
+ expect(result.data?.subject).toBe('Test Email');
47
+ });
48
+
49
+ it('should record email with CC and BCC', async () => {
50
+ const result = await tracking.recordEmailSent({
51
+ messageId: 'msg_456',
52
+ provider: 'mailgun',
53
+ from: 'sender@example.com',
54
+ to: ['recipient@example.com'],
55
+ cc: ['cc@example.com'],
56
+ bcc: ['bcc@example.com'],
57
+ subject: 'Test Email with CC/BCC'
58
+ });
59
+
60
+ expect(result.success).toBe(true);
61
+ expect(result.data?.cc).toEqual(['cc@example.com']);
62
+ expect(result.data?.bcc).toEqual(['bcc@example.com']);
63
+ });
64
+
65
+ it('should record email with campaign ID and metadata', async () => {
66
+ const result = await tracking.recordEmailSent({
67
+ messageId: 'msg_789',
68
+ provider: 'ses',
69
+ from: 'sender@example.com',
70
+ to: ['recipient@example.com'],
71
+ subject: 'Campaign Email',
72
+ campaignId: 'campaign_123',
73
+ metadata: { source: 'marketing', segment: 'premium' }
74
+ });
75
+
76
+ expect(result.success).toBe(true);
77
+ expect(result.data?.campaignId).toBe('campaign_123');
78
+ expect(result.data?.metadata).toEqual({ source: 'marketing', segment: 'premium' });
79
+ });
80
+
81
+ it('should handle errors gracefully', async () => {
82
+ // Force an error by using invalid provider
83
+ const result = await tracking.recordEmailSent({
84
+ messageId: '',
85
+ provider: '',
86
+ from: '',
87
+ to: [],
88
+ subject: ''
89
+ });
90
+
91
+ // Should still return a result (even if it's an empty tracking record)
92
+ expect(result.success).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe('processWebhookEvent', () => {
97
+ it('should link webhook event to send record', async () => {
98
+ // Record send
99
+ const sendResult = await tracking.recordEmailSent({
100
+ messageId: 'msg_123',
101
+ provider: 'sendgrid',
102
+ from: 'sender@example.com',
103
+ to: ['recipient@example.com'],
104
+ subject: 'Test Email'
105
+ });
106
+
107
+ // Process webhook event
108
+ const webhookResult = await tracking.processWebhookEvent({
109
+ messageId: 'msg_123',
110
+ eventType: 'delivered',
111
+ timestamp: new Date().toISOString(),
112
+ metadata: { smtpResponse: '250 OK' }
113
+ });
114
+
115
+ expect(webhookResult.success).toBe(true);
116
+
117
+ // Check timeline
118
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
119
+ expect(timeline.success).toBe(true);
120
+ expect(timeline.data?.events).toHaveLength(2); // sent + delivered
121
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.DELIVERED);
122
+ });
123
+
124
+ it('should handle missing send record gracefully', async () => {
125
+ const result = await tracking.processWebhookEvent({
126
+ messageId: 'unknown_msg',
127
+ eventType: 'delivered',
128
+ timestamp: new Date().toISOString()
129
+ });
130
+
131
+ expect(result.success).toBe(false);
132
+ expect(result.error).toBe('Send record not found');
133
+ });
134
+
135
+ it('should map webhook event types correctly', async () => {
136
+ const sendResult = await tracking.recordEmailSent({
137
+ messageId: 'msg_456',
138
+ provider: 'sendgrid',
139
+ from: 'sender@example.com',
140
+ to: ['recipient@example.com'],
141
+ subject: 'Test Email'
142
+ });
143
+
144
+ // Test bounce event
145
+ await tracking.processWebhookEvent({
146
+ messageId: 'msg_456',
147
+ eventType: 'bounce',
148
+ timestamp: new Date().toISOString()
149
+ });
150
+
151
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
152
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.BOUNCED);
153
+ });
154
+
155
+ it('should handle spam report event', async () => {
156
+ const sendResult = await tracking.recordEmailSent({
157
+ messageId: 'msg_789',
158
+ provider: 'sendgrid',
159
+ from: 'sender@example.com',
160
+ to: ['recipient@example.com'],
161
+ subject: 'Test Email'
162
+ });
163
+
164
+ await tracking.processWebhookEvent({
165
+ messageId: 'msg_789',
166
+ eventType: 'spamreport',
167
+ timestamp: new Date().toISOString()
168
+ });
169
+
170
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
171
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.COMPLAINED);
172
+ });
173
+
174
+ it('should handle unsubscribe event', async () => {
175
+ const sendResult = await tracking.recordEmailSent({
176
+ messageId: 'msg_101',
177
+ provider: 'sendgrid',
178
+ from: 'sender@example.com',
179
+ to: ['recipient@example.com'],
180
+ subject: 'Test Email'
181
+ });
182
+
183
+ await tracking.processWebhookEvent({
184
+ messageId: 'msg_101',
185
+ eventType: 'unsubscribe',
186
+ timestamp: new Date().toISOString()
187
+ });
188
+
189
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
190
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.UNSUBSCRIBED);
191
+ });
192
+ });
193
+
194
+ describe('status priority', () => {
195
+ it('should handle status priority correctly', async () => {
196
+ const sendResult = await tracking.recordEmailSent({
197
+ messageId: 'msg_456',
198
+ provider: 'sendgrid',
199
+ from: 'sender@example.com',
200
+ to: ['recipient@example.com'],
201
+ subject: 'Test Email'
202
+ });
203
+
204
+ // Delivered
205
+ await tracking.processWebhookEvent({
206
+ messageId: 'msg_456',
207
+ eventType: 'delivered',
208
+ timestamp: new Date().toISOString()
209
+ });
210
+
211
+ let timeline = await tracking.getTimeline(sendResult.data!.id);
212
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.DELIVERED);
213
+
214
+ // Opened (higher priority than delivered)
215
+ await tracking.processWebhookEvent({
216
+ messageId: 'msg_456',
217
+ eventType: 'open',
218
+ timestamp: new Date(Date.now() + 1000).toISOString()
219
+ });
220
+
221
+ timeline = await tracking.getTimeline(sendResult.data!.id);
222
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.OPENED);
223
+
224
+ // Clicked (higher priority than opened)
225
+ await tracking.processWebhookEvent({
226
+ messageId: 'msg_456',
227
+ eventType: 'click',
228
+ timestamp: new Date(Date.now() + 2000).toISOString()
229
+ });
230
+
231
+ timeline = await tracking.getTimeline(sendResult.data!.id);
232
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.CLICKED);
233
+ });
234
+
235
+ it('should not downgrade status to lower priority', async () => {
236
+ const sendResult = await tracking.recordEmailSent({
237
+ messageId: 'msg_999',
238
+ provider: 'sendgrid',
239
+ from: 'sender@example.com',
240
+ to: ['recipient@example.com'],
241
+ subject: 'Test Email'
242
+ });
243
+
244
+ // Clicked (high priority)
245
+ await tracking.processWebhookEvent({
246
+ messageId: 'msg_999',
247
+ eventType: 'click',
248
+ timestamp: new Date().toISOString()
249
+ });
250
+
251
+ // Try to set to delivered (lower priority) - should not change
252
+ await tracking.processWebhookEvent({
253
+ messageId: 'msg_999',
254
+ eventType: 'delivered',
255
+ timestamp: new Date(Date.now() + 1000).toISOString()
256
+ });
257
+
258
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
259
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.CLICKED);
260
+ });
261
+
262
+ it('should prioritize complained status highest', async () => {
263
+ const sendResult = await tracking.recordEmailSent({
264
+ messageId: 'msg_888',
265
+ provider: 'sendgrid',
266
+ from: 'sender@example.com',
267
+ to: ['recipient@example.com'],
268
+ subject: 'Test Email'
269
+ });
270
+
271
+ // Clicked
272
+ await tracking.processWebhookEvent({
273
+ messageId: 'msg_888',
274
+ eventType: 'click',
275
+ timestamp: new Date().toISOString()
276
+ });
277
+
278
+ // Spam complaint (highest priority)
279
+ await tracking.processWebhookEvent({
280
+ messageId: 'msg_888',
281
+ eventType: 'spamreport',
282
+ timestamp: new Date(Date.now() + 1000).toISOString()
283
+ });
284
+
285
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
286
+ expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.COMPLAINED);
287
+ });
288
+ });
289
+
290
+ describe('getTimeline', () => {
291
+ it('should retrieve complete event timeline', async () => {
292
+ const sendResult = await tracking.recordEmailSent({
293
+ messageId: 'msg_timeline',
294
+ provider: 'sendgrid',
295
+ from: 'sender@example.com',
296
+ to: ['recipient@example.com'],
297
+ subject: 'Timeline Test'
298
+ });
299
+
300
+ // Add multiple events
301
+ await tracking.processWebhookEvent({
302
+ messageId: 'msg_timeline',
303
+ eventType: 'delivered',
304
+ timestamp: new Date(Date.now() + 1000).toISOString()
305
+ });
306
+
307
+ await tracking.processWebhookEvent({
308
+ messageId: 'msg_timeline',
309
+ eventType: 'open',
310
+ timestamp: new Date(Date.now() + 2000).toISOString()
311
+ });
312
+
313
+ await tracking.processWebhookEvent({
314
+ messageId: 'msg_timeline',
315
+ eventType: 'click',
316
+ timestamp: new Date(Date.now() + 3000).toISOString()
317
+ });
318
+
319
+ const timeline = await tracking.getTimeline(sendResult.data!.id);
320
+ expect(timeline.success).toBe(true);
321
+ expect(timeline.data?.events).toHaveLength(4); // sent + delivered + open + click
322
+ expect(timeline.data?.events[0].eventType).toBe(EmailDeliveryStatus.SENT);
323
+ expect(timeline.data?.events[1].eventType).toBe(EmailDeliveryStatus.DELIVERED);
324
+ expect(timeline.data?.events[2].eventType).toBe(EmailDeliveryStatus.OPENED);
325
+ expect(timeline.data?.events[3].eventType).toBe(EmailDeliveryStatus.CLICKED);
326
+ });
327
+
328
+ it('should return error for unknown tracking ID', async () => {
329
+ const timeline = await tracking.getTimeline('unknown_id');
330
+ expect(timeline.success).toBe(false);
331
+ expect(timeline.error).toBe('Send record not found');
332
+ });
333
+ });
334
+ });
@@ -0,0 +1,224 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { EmailTracking } from '../src/EmailTracking';
10
+
11
+ describe('CampaignStatistics', () => {
12
+ let tracking: EmailTracking;
13
+
14
+ beforeEach(async () => {
15
+ tracking = new EmailTracking();
16
+ await tracking.initialize();
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await tracking.clearDatabase();
21
+ });
22
+
23
+ it('should calculate campaign statistics correctly', async () => {
24
+ const campaignId = 'campaign_123';
25
+
26
+ // Send 10 emails for campaign
27
+ for (let i = 0; i < 10; i++) {
28
+ await tracking.recordEmailSent({
29
+ messageId: `msg_camp_${i}`,
30
+ provider: 'sendgrid',
31
+ from: 'sender@example.com',
32
+ to: [`recipient${i}@example.com`],
33
+ subject: 'Campaign Email',
34
+ campaignId
35
+ });
36
+
37
+ // 8 delivered
38
+ if (i < 8) {
39
+ await tracking.processWebhookEvent({
40
+ messageId: `msg_camp_${i}`,
41
+ eventType: 'delivered',
42
+ timestamp: new Date(Date.now() + i * 1000).toISOString()
43
+ });
44
+ }
45
+
46
+ // 2 bounced
47
+ if (i >= 8) {
48
+ await tracking.processWebhookEvent({
49
+ messageId: `msg_camp_${i}`,
50
+ eventType: 'bounce',
51
+ timestamp: new Date(Date.now() + i * 1000).toISOString()
52
+ });
53
+ }
54
+
55
+ // 5 opened
56
+ if (i < 5) {
57
+ await tracking.processWebhookEvent({
58
+ messageId: `msg_camp_${i}`,
59
+ eventType: 'open',
60
+ timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
61
+ });
62
+ }
63
+
64
+ // 2 clicked
65
+ if (i < 2) {
66
+ await tracking.processWebhookEvent({
67
+ messageId: `msg_camp_${i}`,
68
+ eventType: 'click',
69
+ timestamp: new Date(Date.now() + (i + 2) * 1000).toISOString()
70
+ });
71
+ }
72
+ }
73
+
74
+ const stats = await tracking.getCampaignStats(campaignId);
75
+ expect(stats.success).toBe(true);
76
+ expect(stats.data?.campaignId).toBe(campaignId);
77
+ expect(stats.data?.totalSent).toBe(10);
78
+ expect(stats.data?.totalDelivered).toBe(8); // includes opened and clicked
79
+ expect(stats.data?.totalBounced).toBe(2);
80
+ expect(stats.data?.totalOpened).toBe(5); // includes clicked
81
+ expect(stats.data?.totalClicked).toBe(2);
82
+ expect(stats.data?.deliveryRate).toBe(80); // 8/10 * 100
83
+ expect(stats.data?.openRate).toBe(62.5); // 5/8 * 100
84
+ expect(stats.data?.clickRate).toBe(40); // 2/5 * 100
85
+ });
86
+
87
+ it('should handle campaign with no deliveries', async () => {
88
+ const campaignId = 'campaign_fail';
89
+
90
+ // All bounces
91
+ for (let i = 0; i < 5; i++) {
92
+ await tracking.recordEmailSent({
93
+ messageId: `msg_fail_${i}`,
94
+ provider: 'sendgrid',
95
+ from: 'sender@example.com',
96
+ to: [`bad${i}@example.com`],
97
+ subject: 'Failed Campaign',
98
+ campaignId
99
+ });
100
+
101
+ await tracking.processWebhookEvent({
102
+ messageId: `msg_fail_${i}`,
103
+ eventType: 'bounce',
104
+ timestamp: new Date(Date.now() + i * 1000).toISOString()
105
+ });
106
+ }
107
+
108
+ const stats = await tracking.getCampaignStats(campaignId);
109
+ expect(stats.success).toBe(true);
110
+ expect(stats.data?.totalSent).toBe(5);
111
+ expect(stats.data?.totalDelivered).toBe(0);
112
+ expect(stats.data?.totalBounced).toBe(5);
113
+ expect(stats.data?.deliveryRate).toBe(0);
114
+ expect(stats.data?.openRate).toBe(0);
115
+ expect(stats.data?.clickRate).toBe(0);
116
+ });
117
+
118
+ it('should handle empty campaign', async () => {
119
+ const stats = await tracking.getCampaignStats('empty_campaign');
120
+ expect(stats.success).toBe(true);
121
+ expect(stats.data?.totalSent).toBe(0);
122
+ expect(stats.data?.deliveryRate).toBe(0);
123
+ });
124
+
125
+ it('should track spam complaints in campaign', async () => {
126
+ const campaignId = 'campaign_spam';
127
+
128
+ for (let i = 0; i < 5; i++) {
129
+ await tracking.recordEmailSent({
130
+ messageId: `msg_spam_${i}`,
131
+ provider: 'sendgrid',
132
+ from: 'sender@example.com',
133
+ to: [`user${i}@example.com`],
134
+ subject: 'Spam Test',
135
+ campaignId
136
+ });
137
+
138
+ await tracking.processWebhookEvent({
139
+ messageId: `msg_spam_${i}`,
140
+ eventType: 'delivered',
141
+ timestamp: new Date(Date.now() + i * 1000).toISOString()
142
+ });
143
+
144
+ // 2 complaints
145
+ if (i < 2) {
146
+ await tracking.processWebhookEvent({
147
+ messageId: `msg_spam_${i}`,
148
+ eventType: 'spamreport',
149
+ timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
150
+ });
151
+ }
152
+ }
153
+
154
+ const stats = await tracking.getCampaignStats(campaignId);
155
+ expect(stats.success).toBe(true);
156
+ expect(stats.data?.totalComplained).toBe(2);
157
+ });
158
+
159
+ it('should track unsubscribes in campaign', async () => {
160
+ const campaignId = 'campaign_unsub';
161
+
162
+ for (let i = 0; i < 5; i++) {
163
+ await tracking.recordEmailSent({
164
+ messageId: `msg_unsub_${i}`,
165
+ provider: 'sendgrid',
166
+ from: 'sender@example.com',
167
+ to: [`user${i}@example.com`],
168
+ subject: 'Unsubscribe Test',
169
+ campaignId
170
+ });
171
+
172
+ await tracking.processWebhookEvent({
173
+ messageId: `msg_unsub_${i}`,
174
+ eventType: 'delivered',
175
+ timestamp: new Date(Date.now() + i * 1000).toISOString()
176
+ });
177
+
178
+ // 1 unsubscribe
179
+ if (i === 0) {
180
+ await tracking.processWebhookEvent({
181
+ messageId: `msg_unsub_${i}`,
182
+ eventType: 'unsubscribe',
183
+ timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
184
+ });
185
+ }
186
+ }
187
+
188
+ const stats = await tracking.getCampaignStats(campaignId);
189
+ expect(stats.success).toBe(true);
190
+ expect(stats.data?.totalUnsubscribed).toBe(1);
191
+ });
192
+
193
+ it('should only count emails for specified campaign', async () => {
194
+ // Campaign A
195
+ for (let i = 0; i < 3; i++) {
196
+ await tracking.recordEmailSent({
197
+ messageId: `msg_a_${i}`,
198
+ provider: 'sendgrid',
199
+ from: 'sender@example.com',
200
+ to: [`user${i}@example.com`],
201
+ subject: 'Campaign A',
202
+ campaignId: 'campaign_a'
203
+ });
204
+ }
205
+
206
+ // Campaign B
207
+ for (let i = 0; i < 5; i++) {
208
+ await tracking.recordEmailSent({
209
+ messageId: `msg_b_${i}`,
210
+ provider: 'sendgrid',
211
+ from: 'sender@example.com',
212
+ to: [`user${i}@example.com`],
213
+ subject: 'Campaign B',
214
+ campaignId: 'campaign_b'
215
+ });
216
+ }
217
+
218
+ const statsA = await tracking.getCampaignStats('campaign_a');
219
+ const statsB = await tracking.getCampaignStats('campaign_b');
220
+
221
+ expect(statsA.data?.totalSent).toBe(3);
222
+ expect(statsB.data?.totalSent).toBe(5);
223
+ });
224
+ });
@@ -0,0 +1,108 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { loadConfig } from '../src/config';
10
+
11
+ describe('Config', () => {
12
+ const originalEnv = process.env;
13
+
14
+ beforeEach(() => {
15
+ jest.resetModules();
16
+ process.env = { ...originalEnv };
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.env = originalEnv;
21
+ });
22
+
23
+ it('should use default values when no config provided', () => {
24
+ const config = loadConfig();
25
+ expect(config.enableOpenTracking).toBe(true);
26
+ expect(config.enableClickTracking).toBe(true);
27
+ expect(config.eventRetentionDays).toBe(90);
28
+ expect(config.aggregateStats).toBe(true);
29
+ });
30
+
31
+ it('should use user config over defaults', () => {
32
+ const config = loadConfig({
33
+ enableOpenTracking: false,
34
+ enableClickTracking: false,
35
+ eventRetentionDays: 30,
36
+ aggregateStats: false
37
+ });
38
+
39
+ expect(config.enableOpenTracking).toBe(false);
40
+ expect(config.enableClickTracking).toBe(false);
41
+ expect(config.eventRetentionDays).toBe(30);
42
+ expect(config.aggregateStats).toBe(false);
43
+ });
44
+
45
+ it('should read from environment variables', () => {
46
+ process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
47
+ process.env.EMAIL_TRACKING_ENABLE_CLICK_TRACKING = 'false';
48
+ process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '60';
49
+ process.env.EMAIL_TRACKING_AGGREGATE_STATS = 'false';
50
+
51
+ const config = loadConfig();
52
+ expect(config.enableOpenTracking).toBe(false);
53
+ expect(config.enableClickTracking).toBe(false);
54
+ expect(config.eventRetentionDays).toBe(60);
55
+ expect(config.aggregateStats).toBe(false);
56
+ });
57
+
58
+ it('should prioritize user config over environment variables', () => {
59
+ process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
60
+ process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '30';
61
+
62
+ const config = loadConfig({
63
+ enableOpenTracking: true,
64
+ eventRetentionDays: 120
65
+ });
66
+
67
+ expect(config.enableOpenTracking).toBe(true);
68
+ expect(config.eventRetentionDays).toBe(120);
69
+ });
70
+
71
+ it('should handle partial user config', () => {
72
+ const config = loadConfig({
73
+ enableOpenTracking: false
74
+ });
75
+
76
+ expect(config.enableOpenTracking).toBe(false);
77
+ expect(config.enableClickTracking).toBe(true); // Default
78
+ expect(config.eventRetentionDays).toBe(90); // Default
79
+ expect(config.aggregateStats).toBe(true); // Default
80
+ });
81
+
82
+ it('should parse retention days as integer', () => {
83
+ process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '45';
84
+ const config = loadConfig();
85
+ expect(config.eventRetentionDays).toBe(45);
86
+ expect(typeof config.eventRetentionDays).toBe('number');
87
+ });
88
+
89
+ it('should handle invalid retention days gracefully', () => {
90
+ process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = 'invalid';
91
+ const config = loadConfig();
92
+ expect(isNaN(config.eventRetentionDays)).toBe(true);
93
+ });
94
+
95
+ it('should handle boolean environment variables correctly', () => {
96
+ process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'true';
97
+ const config1 = loadConfig();
98
+ expect(config1.enableOpenTracking).toBe(true);
99
+
100
+ process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
101
+ const config2 = loadConfig();
102
+ expect(config2.enableOpenTracking).toBe(false);
103
+
104
+ process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'anything';
105
+ const config3 = loadConfig();
106
+ expect(config3.enableOpenTracking).toBe(false);
107
+ });
108
+ });