@bernierllc/email-webhook-events 1.0.0 ā 1.2.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.
- package/CHANGELOG.md +50 -0
- package/README.md +42 -0
- package/__tests__/fixtures/sendgrid-webhooks/.gitkeep +0 -0
- package/__tests__/fixtures/sendgrid-webhooks/README.md +63 -0
- package/__tests__/fixtures/sendgrid-webhooks/deferred.json +31 -0
- package/__tests__/fixtures/sendgrid-webhooks/delivered.json +83 -0
- package/__tests__/fixtures/sendgrid-webhooks/open.json +83 -0
- package/__tests__/integration/ngrok-tunnel-manager.ts +88 -0
- package/__tests__/integration/sendgrid-webhook-integration.test.ts +272 -0
- package/__tests__/integration/webhook-server.ts +188 -0
- package/__tests__/mocks/README.md +172 -0
- package/__tests__/mocks/index.ts +94 -0
- package/__tests__/mocks/sendgrid-api-responses.ts +391 -0
- package/__tests__/mocks/sendgrid-webhook-payloads.ts +463 -0
- package/jest.config.cjs +16 -7
- package/package.json +23 -15
- package/coverage/clover.xml +0 -328
- package/coverage/coverage-final.json +0 -10
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -161
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/src/EmailWebhookEventsService.ts.html +0 -826
- package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +0 -775
- package/coverage/lcov-report/src/analytics/index.html +0 -116
- package/coverage/lcov-report/src/index.html +0 -131
- package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +0 -301
- package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +0 -301
- package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +0 -436
- package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +0 -247
- package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +0 -274
- package/coverage/lcov-report/src/normalizers/index.html +0 -176
- package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +0 -289
- package/coverage/lcov-report/src/persistence/index.html +0 -116
- package/coverage/lcov-report/src/types.ts.html +0 -823
- package/coverage/lcov.info +0 -710
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* SendGrid Webhook Integration Tests
|
|
11
|
+
*
|
|
12
|
+
* These tests use REAL SendGrid API and webhooks to:
|
|
13
|
+
* - Send emails via @bernierllc/email-manager
|
|
14
|
+
* - Receive webhooks from SendGrid via ngrok tunnel
|
|
15
|
+
* - Process webhooks through @bernierllc/email-webhook-events
|
|
16
|
+
* - Capture real webhook payloads for mock generation
|
|
17
|
+
*
|
|
18
|
+
* Prerequisites:
|
|
19
|
+
* - .env.test file with valid API keys and configuration
|
|
20
|
+
* - SendGrid webhook URL configured to ngrok URL
|
|
21
|
+
* - RUN_INTEGRATION_TESTS=true environment variable set
|
|
22
|
+
*
|
|
23
|
+
* Run with: npm test -- sendgrid-webhook-integration
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as dotenv from 'dotenv';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
// Jest provides describe, it, expect, beforeAll, afterAll, beforeEach as globals
|
|
29
|
+
import { EmailManager } from '@bernierllc/email-manager';
|
|
30
|
+
import { EmailWebhookEventsService, EmailProvider, EmailEventType } from '../../src/index';
|
|
31
|
+
import { WebhookTestServer, CapturedWebhook } from './webhook-server';
|
|
32
|
+
import { NgrokTunnelManager } from './ngrok-tunnel-manager';
|
|
33
|
+
|
|
34
|
+
// Load test environment variables from .env.test (at repo root)
|
|
35
|
+
dotenv.config({ path: path.join(__dirname, '..', '..', '..', '..', '..', '.env.test') });
|
|
36
|
+
|
|
37
|
+
// Skip these tests if not explicitly enabled or if required env vars are missing
|
|
38
|
+
const SKIP_INTEGRATION_TESTS =
|
|
39
|
+
!process.env.RUN_INTEGRATION_TESTS ||
|
|
40
|
+
!process.env.SENDGRID_API_KEY ||
|
|
41
|
+
!process.env.SENDGRID_FROM_EMAIL ||
|
|
42
|
+
!process.env.NGROK_AUTH_TOKEN ||
|
|
43
|
+
!process.env.TEST_EMAIL;
|
|
44
|
+
|
|
45
|
+
const describeOrSkip = SKIP_INTEGRATION_TESTS ? describe.skip : describe;
|
|
46
|
+
|
|
47
|
+
describeOrSkip('SendGrid Webhook Integration Tests', () => {
|
|
48
|
+
let webhookServer: WebhookTestServer;
|
|
49
|
+
let ngrokTunnel: NgrokTunnelManager;
|
|
50
|
+
let webhookUrl: string;
|
|
51
|
+
let emailManager: EmailManager;
|
|
52
|
+
let webhookService: EmailWebhookEventsService;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
// Start webhook server
|
|
56
|
+
const port = parseInt(process.env.WEBHOOK_SERVER_PORT || '3000', 10);
|
|
57
|
+
webhookServer = new WebhookTestServer(port);
|
|
58
|
+
await webhookServer.start();
|
|
59
|
+
|
|
60
|
+
// Start ngrok tunnel programmatically
|
|
61
|
+
ngrokTunnel = new NgrokTunnelManager();
|
|
62
|
+
webhookUrl = await ngrokTunnel.start({
|
|
63
|
+
port,
|
|
64
|
+
domain: process.env.NGROK_RESERVED_DOMAIN,
|
|
65
|
+
authtoken: process.env.NGROK_AUTH_TOKEN
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log(`\nš” Webhook URL: ${webhookUrl}/webhooks/sendgrid`);
|
|
69
|
+
console.log(`š Configure SendGrid webhook URL to: ${webhookUrl}/webhooks/sendgrid\n`);
|
|
70
|
+
|
|
71
|
+
// Initialize email manager
|
|
72
|
+
emailManager = new EmailManager({
|
|
73
|
+
providers: [],
|
|
74
|
+
scheduling: {
|
|
75
|
+
enabled: false,
|
|
76
|
+
checkIntervalMs: 60000,
|
|
77
|
+
maxRetries: 3,
|
|
78
|
+
retryDelayMs: 1000
|
|
79
|
+
} // Disable scheduler for tests
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Manually add provider and await to ensure it's registered before sending
|
|
83
|
+
const addResult = await emailManager.addProvider({
|
|
84
|
+
id: 'sendgrid-test',
|
|
85
|
+
name: 'SendGrid Test',
|
|
86
|
+
type: 'sendgrid',
|
|
87
|
+
config: {
|
|
88
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
89
|
+
fromEmail: process.env.SENDGRID_FROM_EMAIL!,
|
|
90
|
+
fromName: 'Webhook Integration Test'
|
|
91
|
+
},
|
|
92
|
+
isActive: true,
|
|
93
|
+
priority: 1
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!addResult.success) {
|
|
97
|
+
throw new Error(`Failed to add SendGrid provider: ${addResult.errors?.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('ā
SendGrid provider added successfully');
|
|
101
|
+
|
|
102
|
+
// Initialize webhook service
|
|
103
|
+
webhookService = new EmailWebhookEventsService({
|
|
104
|
+
providers: [{
|
|
105
|
+
provider: EmailProvider.SENDGRID,
|
|
106
|
+
enabled: true,
|
|
107
|
+
webhookUrl: '/webhooks/sendgrid',
|
|
108
|
+
secretKey: process.env.SENDGRID_WEBHOOK_SECRET || '',
|
|
109
|
+
signatureHeader: 'X-Twilio-Email-Event-Webhook-Signature',
|
|
110
|
+
signatureAlgorithm: 'sha256'
|
|
111
|
+
}],
|
|
112
|
+
persistence: {
|
|
113
|
+
enabled: true,
|
|
114
|
+
adapter: 'memory'
|
|
115
|
+
},
|
|
116
|
+
analytics: {
|
|
117
|
+
realTimeEnabled: true,
|
|
118
|
+
aggregationInterval: 5000,
|
|
119
|
+
retentionDays: 90
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
await webhookService.initialize();
|
|
123
|
+
}, 60000); // 60 second timeout for setup
|
|
124
|
+
|
|
125
|
+
afterAll(async () => {
|
|
126
|
+
await ngrokTunnel.stop();
|
|
127
|
+
await webhookServer.stop();
|
|
128
|
+
}, 30000);
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
webhookServer.clearCapturedWebhooks();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Helper function to wait for webhook
|
|
135
|
+
async function waitForWebhook(
|
|
136
|
+
eventType: string,
|
|
137
|
+
timeout: number = 60000
|
|
138
|
+
): Promise<CapturedWebhook | null> {
|
|
139
|
+
const startTime = Date.now();
|
|
140
|
+
|
|
141
|
+
while (Date.now() - startTime < timeout) {
|
|
142
|
+
const webhooks = webhookServer.getCapturedWebhooks();
|
|
143
|
+
const matching = webhooks.find(w => w.body.event === eventType);
|
|
144
|
+
|
|
145
|
+
if (matching) {
|
|
146
|
+
return matching;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('should receive and process delivered webhook', async () => {
|
|
156
|
+
const testEmail = process.env.TEST_EMAIL!;
|
|
157
|
+
|
|
158
|
+
// Send email
|
|
159
|
+
const sendResult = await emailManager.sendEmail({
|
|
160
|
+
to: testEmail,
|
|
161
|
+
subject: 'Test Email for Webhook Testing - Delivered',
|
|
162
|
+
html: '<h1>Test</h1><p>This is a test email for delivered webhook testing.</p>',
|
|
163
|
+
text: 'Test: This is a test email for delivered webhook testing.'
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(sendResult.success).toBe(true);
|
|
167
|
+
expect(sendResult.messageId).toBeDefined();
|
|
168
|
+
|
|
169
|
+
// Wait for webhook (with timeout)
|
|
170
|
+
const deliveredWebhook = await waitForWebhook('delivered', 60000);
|
|
171
|
+
|
|
172
|
+
expect(deliveredWebhook).toBeDefined();
|
|
173
|
+
expect(deliveredWebhook?.body.event).toBe('delivered');
|
|
174
|
+
expect(deliveredWebhook?.body.email).toBe(testEmail);
|
|
175
|
+
|
|
176
|
+
// Process webhook through service
|
|
177
|
+
const result = await webhookService.processWebhook({
|
|
178
|
+
provider: EmailProvider.SENDGRID,
|
|
179
|
+
signature: deliveredWebhook!.headers['x-twilio-email-event-webhook-signature'] || '',
|
|
180
|
+
payload: deliveredWebhook!.body,
|
|
181
|
+
headers: deliveredWebhook!.headers
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.success).toBe(true);
|
|
185
|
+
expect(result.event).toBeDefined();
|
|
186
|
+
expect(result.event!.type).toBe(EmailEventType.DELIVERED);
|
|
187
|
+
expect(result.event!.recipient).toBe(testEmail);
|
|
188
|
+
}, 120000); // 2 minute timeout for email delivery
|
|
189
|
+
|
|
190
|
+
it('should capture webhook payload structure', async () => {
|
|
191
|
+
const testEmail = process.env.TEST_EMAIL!;
|
|
192
|
+
|
|
193
|
+
// Send email
|
|
194
|
+
await emailManager.sendEmail({
|
|
195
|
+
to: testEmail,
|
|
196
|
+
subject: 'Test Email for Payload Capture',
|
|
197
|
+
html: '<h1>Payload Test</h1><p>Capturing webhook payload structure.</p>',
|
|
198
|
+
text: 'Payload Test: Capturing webhook payload structure.'
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Wait for delivered webhook
|
|
202
|
+
const deliveredWebhook = await waitForWebhook('delivered', 60000);
|
|
203
|
+
|
|
204
|
+
expect(deliveredWebhook).toBeDefined();
|
|
205
|
+
|
|
206
|
+
// Verify payload structure
|
|
207
|
+
expect(deliveredWebhook!.body).toHaveProperty('event');
|
|
208
|
+
expect(deliveredWebhook!.body).toHaveProperty('email');
|
|
209
|
+
expect(deliveredWebhook!.body).toHaveProperty('timestamp');
|
|
210
|
+
expect(deliveredWebhook!.body).toHaveProperty('sg_message_id');
|
|
211
|
+
|
|
212
|
+
// Verify headers
|
|
213
|
+
expect(deliveredWebhook!.headers).toBeDefined();
|
|
214
|
+
|
|
215
|
+
console.log('\nš¦ Captured webhook payload structure:');
|
|
216
|
+
console.log(JSON.stringify(deliveredWebhook!.body, null, 2));
|
|
217
|
+
}, 120000);
|
|
218
|
+
|
|
219
|
+
it('should handle multiple events in single webhook', async () => {
|
|
220
|
+
const testEmail = process.env.TEST_EMAIL!;
|
|
221
|
+
|
|
222
|
+
// Send email
|
|
223
|
+
await emailManager.sendEmail({
|
|
224
|
+
to: testEmail,
|
|
225
|
+
subject: 'Test Email for Multiple Events',
|
|
226
|
+
html: '<h1>Multiple Events</h1><p>Testing multiple webhook events.</p>',
|
|
227
|
+
text: 'Multiple Events: Testing multiple webhook events.'
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Wait for delivered webhook
|
|
231
|
+
const deliveredWebhook = await waitForWebhook('delivered', 60000);
|
|
232
|
+
|
|
233
|
+
expect(deliveredWebhook).toBeDefined();
|
|
234
|
+
|
|
235
|
+
// SendGrid may send multiple events in one webhook
|
|
236
|
+
// Verify we can handle both single and multiple events
|
|
237
|
+
const allWebhooks = webhookServer.getCapturedWebhooks();
|
|
238
|
+
expect(allWebhooks.length).toBeGreaterThan(0);
|
|
239
|
+
}, 120000);
|
|
240
|
+
|
|
241
|
+
// Note: Open, click, bounce, spamreport, and unsubscribe events
|
|
242
|
+
// require manual interaction or specific test conditions.
|
|
243
|
+
// These can be tested manually or with email client automation.
|
|
244
|
+
|
|
245
|
+
it('should process webhook with signature validation', async () => {
|
|
246
|
+
const testEmail = process.env.TEST_EMAIL!;
|
|
247
|
+
|
|
248
|
+
// Send email
|
|
249
|
+
await emailManager.sendEmail({
|
|
250
|
+
to: testEmail,
|
|
251
|
+
subject: 'Test Email for Signature Validation',
|
|
252
|
+
html: '<h1>Signature Test</h1><p>Testing webhook signature validation.</p>',
|
|
253
|
+
text: 'Signature Test: Testing webhook signature validation.'
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Wait for delivered webhook
|
|
257
|
+
const deliveredWebhook = await waitForWebhook('delivered', 60000);
|
|
258
|
+
|
|
259
|
+
expect(deliveredWebhook).toBeDefined();
|
|
260
|
+
|
|
261
|
+
// Verify signature header is present (if SendGrid provides it)
|
|
262
|
+
const signatureHeader = deliveredWebhook!.headers['x-twilio-email-event-webhook-signature'];
|
|
263
|
+
|
|
264
|
+
if (signatureHeader) {
|
|
265
|
+
expect(signatureHeader).toBeDefined();
|
|
266
|
+
console.log('\nš Webhook signature header present:', signatureHeader.substring(0, 20) + '...');
|
|
267
|
+
} else {
|
|
268
|
+
console.log('\nā ļø Webhook signature header not present (may not be configured in SendGrid)');
|
|
269
|
+
}
|
|
270
|
+
}, 120000);
|
|
271
|
+
});
|
|
272
|
+
|
|
@@ -0,0 +1,188 @@
|
|
|
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 express from 'express';
|
|
10
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
|
|
13
|
+
export interface CapturedWebhook {
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
body: any;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
eventType: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class WebhookTestServer {
|
|
21
|
+
private app: express.Application;
|
|
22
|
+
private server: any;
|
|
23
|
+
private capturedWebhooks: CapturedWebhook[] = [];
|
|
24
|
+
private fixturesDir: string;
|
|
25
|
+
private port: number;
|
|
26
|
+
|
|
27
|
+
constructor(port: number = 3000) {
|
|
28
|
+
this.port = port;
|
|
29
|
+
this.app = express();
|
|
30
|
+
this.fixturesDir = join(__dirname, '../fixtures/sendgrid-webhooks');
|
|
31
|
+
|
|
32
|
+
// Ensure fixtures directory exists
|
|
33
|
+
if (!existsSync(this.fixturesDir)) {
|
|
34
|
+
mkdirSync(this.fixturesDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Middleware to capture raw body
|
|
38
|
+
this.app.use(express.json({ verify: (req: any, res, buf) => {
|
|
39
|
+
req.rawBody = buf.toString('utf8');
|
|
40
|
+
}}));
|
|
41
|
+
|
|
42
|
+
// Log all incoming requests
|
|
43
|
+
this.app.use((req, res, next) => {
|
|
44
|
+
console.log(`š„ ${req.method} ${req.path}`);
|
|
45
|
+
next();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Webhook endpoint - handle both root and /webhooks/sendgrid paths
|
|
49
|
+
const webhookHandler = (req: express.Request, res: express.Response) => {
|
|
50
|
+
console.log(`ā
Webhook received at ${req.path}`);
|
|
51
|
+
const events = Array.isArray(req.body) ? req.body : [req.body];
|
|
52
|
+
|
|
53
|
+
events.forEach((event: any) => {
|
|
54
|
+
console.log(` šØ Event: ${event.event} for ${event.email}`);
|
|
55
|
+
const captured: CapturedWebhook = {
|
|
56
|
+
headers: req.headers as Record<string, string>,
|
|
57
|
+
body: event,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
eventType: event.event || 'unknown'
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
this.capturedWebhooks.push(captured);
|
|
63
|
+
this.saveWebhookPayload(captured);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
res.status(200).json({ received: true, count: events.length });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Handle webhooks at root (current SendGrid config)
|
|
70
|
+
this.app.post('/', webhookHandler);
|
|
71
|
+
|
|
72
|
+
// Also handle webhooks at /webhooks/sendgrid (recommended path)
|
|
73
|
+
this.app.post('/webhooks/sendgrid', webhookHandler);
|
|
74
|
+
|
|
75
|
+
// Legacy endpoint handler (keep for backward compatibility)
|
|
76
|
+
this.app.post('/webhooks/sendgrid-old', (req, res) => {
|
|
77
|
+
const events = Array.isArray(req.body) ? req.body : [req.body];
|
|
78
|
+
|
|
79
|
+
events.forEach((event: any) => {
|
|
80
|
+
const captured: CapturedWebhook = {
|
|
81
|
+
headers: req.headers as Record<string, string>,
|
|
82
|
+
body: event,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
eventType: event.event || 'unknown'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.capturedWebhooks.push(captured);
|
|
88
|
+
this.saveWebhookPayload(captured);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
res.status(200).json({ received: true, count: events.length });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Health check
|
|
95
|
+
this.app.get('/health', (req, res) => {
|
|
96
|
+
res.json({ status: 'ok', captured: this.capturedWebhooks.length });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Catch-all for any other POST (in case SendGrid hits a different path)
|
|
100
|
+
this.app.post('*', (req, res) => {
|
|
101
|
+
console.log(`ā ļø Unhandled POST to: ${req.path}`);
|
|
102
|
+
console.log('Body:', JSON.stringify(req.body, null, 2));
|
|
103
|
+
|
|
104
|
+
// Still capture it as a webhook
|
|
105
|
+
const events = Array.isArray(req.body) ? req.body : [req.body];
|
|
106
|
+
events.forEach((event: any) => {
|
|
107
|
+
const captured: CapturedWebhook = {
|
|
108
|
+
headers: req.headers as Record<string, string>,
|
|
109
|
+
body: event,
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
eventType: event.event || 'unknown'
|
|
112
|
+
};
|
|
113
|
+
this.capturedWebhooks.push(captured);
|
|
114
|
+
this.saveWebhookPayload(captured);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
res.status(200).json({ received: true, path: req.path });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 404 handler
|
|
121
|
+
this.app.use((req, res) => {
|
|
122
|
+
console.log(`ā 404: ${req.method} ${req.path}`);
|
|
123
|
+
res.status(404).json({ error: 'Not found', path: req.path, method: req.method });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private saveWebhookPayload(webhook: CapturedWebhook): void {
|
|
128
|
+
const eventType = webhook.eventType;
|
|
129
|
+
const filePath = join(this.fixturesDir, `${eventType}.json`);
|
|
130
|
+
|
|
131
|
+
let existing: CapturedWebhook[] = [];
|
|
132
|
+
if (existsSync(filePath)) {
|
|
133
|
+
try {
|
|
134
|
+
existing = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// If file is corrupted, start fresh
|
|
137
|
+
console.warn(`Failed to read existing webhook file ${filePath}, starting fresh`);
|
|
138
|
+
existing = [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
existing.push(webhook);
|
|
143
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async start(): Promise<void> {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
try {
|
|
149
|
+
this.server = this.app.listen(this.port, () => {
|
|
150
|
+
console.log(`Webhook test server running on port ${this.port}`);
|
|
151
|
+
resolve();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.server.on('error', (error: Error) => {
|
|
155
|
+
reject(error);
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
reject(error);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async stop(): Promise<void> {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
if (this.server) {
|
|
166
|
+
this.server.close(() => {
|
|
167
|
+
console.log('Webhook test server stopped');
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
resolve();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getCapturedWebhooks(): CapturedWebhook[] {
|
|
177
|
+
return [...this.capturedWebhooks];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
clearCapturedWebhooks(): void {
|
|
181
|
+
this.capturedWebhooks = [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getPort(): number {
|
|
185
|
+
return this.port;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SendGrid Mock Fixtures
|
|
2
|
+
|
|
3
|
+
> **Philosophy: "Only mock services that are not owned by us"**
|
|
4
|
+
|
|
5
|
+
These mock fixtures are based on **REAL SendGrid webhook payload structures** captured during integration testing. They accurately reflect the actual format and fields that SendGrid sends.
|
|
6
|
+
|
|
7
|
+
> ā ļø **SANITIZED DATA**: All email addresses, IPs, message IDs, signatures, API keys, and other identifiable data have been replaced with fake test values (using RFC 5737 test IPs like `192.0.2.x` and `example.com` domains). The **structure** is real, the **values** are safe for version control.
|
|
8
|
+
|
|
9
|
+
## Testing Strategy
|
|
10
|
+
|
|
11
|
+
| Service Type | Mock Strategy | Example |
|
|
12
|
+
|--------------|---------------|---------|
|
|
13
|
+
| External APIs | ā
Mock | SendGrid, Stripe, AWS |
|
|
14
|
+
| Internal packages | ā Use Real | @bernierllc/*, @unicorn-love/* |
|
|
15
|
+
|
|
16
|
+
### Why This Approach?
|
|
17
|
+
|
|
18
|
+
1. **Confidence**: Tests using real internal packages catch real integration issues
|
|
19
|
+
2. **Accuracy**: Mocks based on real payloads prevent "mock drift"
|
|
20
|
+
3. **Speed**: Mocking external APIs is fast and doesn't require network
|
|
21
|
+
4. **Reliability**: No flaky tests from external service downtime
|
|
22
|
+
|
|
23
|
+
## Available Mocks
|
|
24
|
+
|
|
25
|
+
### Webhook Payloads (Receiving Side)
|
|
26
|
+
|
|
27
|
+
For testing webhook processing:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import {
|
|
31
|
+
createDeliveredEvent,
|
|
32
|
+
createOpenEvent,
|
|
33
|
+
createBounceEvent,
|
|
34
|
+
createClickEvent,
|
|
35
|
+
createWebhookPayload,
|
|
36
|
+
REAL_DELIVERED_EVENT,
|
|
37
|
+
REAL_OPEN_EVENT
|
|
38
|
+
} from '../mocks';
|
|
39
|
+
|
|
40
|
+
// Create a mock delivered webhook
|
|
41
|
+
const deliveredEvent = createDeliveredEvent({
|
|
42
|
+
email: 'user@example.com'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Use real captured payload as-is
|
|
46
|
+
const realPayload = REAL_DELIVERED_EVENT;
|
|
47
|
+
|
|
48
|
+
// Create complete webhook with headers
|
|
49
|
+
const webhook = createWebhookPayload(deliveredEvent);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### API Responses (Sending Side)
|
|
53
|
+
|
|
54
|
+
For testing email sending:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import {
|
|
58
|
+
createSuccessResponse,
|
|
59
|
+
createRateLimitResponse,
|
|
60
|
+
createValidationErrorResponse,
|
|
61
|
+
mockSuccessfulSend,
|
|
62
|
+
mockRateLimitThenSuccess
|
|
63
|
+
} from '../mocks';
|
|
64
|
+
|
|
65
|
+
// Mock successful send
|
|
66
|
+
const successResponse = createSuccessResponse();
|
|
67
|
+
|
|
68
|
+
// Mock rate limit then success (for retry testing)
|
|
69
|
+
const fetchMock = mockRateLimitThenSuccess();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Real Captured Payloads
|
|
73
|
+
|
|
74
|
+
These constants contain **actual payloads** from SendGrid:
|
|
75
|
+
|
|
76
|
+
| Constant | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `REAL_SENDGRID_HEADERS` | Actual HTTP headers from webhook requests |
|
|
79
|
+
| `REAL_DELIVERED_EVENT` | Real "delivered" event payload |
|
|
80
|
+
| `REAL_OPEN_EVENT` | Real "open" event payload |
|
|
81
|
+
| `REAL_DEFERRED_EVENT` | Real "deferred" event payload |
|
|
82
|
+
|
|
83
|
+
## Test Scenarios
|
|
84
|
+
|
|
85
|
+
Pre-built scenarios for common test cases:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import {
|
|
89
|
+
createEmailLifecycleEvents,
|
|
90
|
+
createFailedDeliveryEvents,
|
|
91
|
+
createSpamScenarioEvents
|
|
92
|
+
} from '../mocks';
|
|
93
|
+
|
|
94
|
+
// Email lifecycle: delivered -> open -> click
|
|
95
|
+
const lifecycle = createEmailLifecycleEvents('user@example.com');
|
|
96
|
+
|
|
97
|
+
// Failed delivery: deferred -> deferred -> bounce
|
|
98
|
+
const failedDelivery = createFailedDeliveryEvents('bad@example.com');
|
|
99
|
+
|
|
100
|
+
// Spam scenario: delivered -> open -> spam report
|
|
101
|
+
const spamReport = createSpamScenarioEvents('user@example.com');
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## File Structure
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
__tests__/mocks/
|
|
108
|
+
āāā index.ts # Central exports
|
|
109
|
+
āāā sendgrid-webhook-payloads.ts # Webhook receiving mocks
|
|
110
|
+
āāā sendgrid-api-responses.ts # API sending mocks
|
|
111
|
+
āāā README.md # This file
|
|
112
|
+
|
|
113
|
+
__tests__/fixtures/sendgrid-webhooks/
|
|
114
|
+
āāā delivered.json # Raw captured payloads
|
|
115
|
+
āāā open.json
|
|
116
|
+
āāā deferred.json
|
|
117
|
+
āāā README.md
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Updating Mocks
|
|
121
|
+
|
|
122
|
+
If SendGrid changes their payload format:
|
|
123
|
+
|
|
124
|
+
1. Run the integration tests to capture new payloads:
|
|
125
|
+
```bash
|
|
126
|
+
RUN_INTEGRATION_TESTS=true npm test -- sendgrid-webhook-integration --run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
2. New payloads are saved to `__tests__/fixtures/sendgrid-webhooks/`
|
|
130
|
+
|
|
131
|
+
3. Update the mock factories in `sendgrid-webhook-payloads.ts` with any new fields
|
|
132
|
+
|
|
133
|
+
## Example Test
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Jest provides describe, it, expect as globals; use jest.fn() instead of vi.fn()
|
|
137
|
+
import { EmailWebhookEventsService } from '../../src';
|
|
138
|
+
import { createDeliveredEvent, createWebhookPayload } from '../mocks';
|
|
139
|
+
|
|
140
|
+
describe('EmailWebhookEventsService', () => {
|
|
141
|
+
it('should process a delivered webhook', async () => {
|
|
142
|
+
// Arrange - use mock based on real data
|
|
143
|
+
const event = createDeliveredEvent({ email: 'test@example.com' });
|
|
144
|
+
const payload = createWebhookPayload(event);
|
|
145
|
+
|
|
146
|
+
const service = new EmailWebhookEventsService({...});
|
|
147
|
+
await service.initialize();
|
|
148
|
+
|
|
149
|
+
// Act - use REAL service implementation
|
|
150
|
+
const result = await service.processWebhook({
|
|
151
|
+
provider: EmailProvider.SENDGRID,
|
|
152
|
+
payload: payload.body,
|
|
153
|
+
headers: payload.headers
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
157
|
+
expect(result.success).toBe(true);
|
|
158
|
+
expect(result.event?.type).toBe('delivered');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
When adding new event types or updating mocks:
|
|
166
|
+
|
|
167
|
+
1. Run integration tests to capture real payloads
|
|
168
|
+
2. Add the new type interface
|
|
169
|
+
3. Create a factory function with sensible defaults
|
|
170
|
+
4. Add the type to the `SendGridWebhookEvent` union
|
|
171
|
+
5. Export from `index.ts`
|
|
172
|
+
|