@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +42 -0
  3. package/__tests__/fixtures/sendgrid-webhooks/.gitkeep +0 -0
  4. package/__tests__/fixtures/sendgrid-webhooks/README.md +63 -0
  5. package/__tests__/fixtures/sendgrid-webhooks/deferred.json +31 -0
  6. package/__tests__/fixtures/sendgrid-webhooks/delivered.json +83 -0
  7. package/__tests__/fixtures/sendgrid-webhooks/open.json +83 -0
  8. package/__tests__/integration/ngrok-tunnel-manager.ts +88 -0
  9. package/__tests__/integration/sendgrid-webhook-integration.test.ts +272 -0
  10. package/__tests__/integration/webhook-server.ts +188 -0
  11. package/__tests__/mocks/README.md +172 -0
  12. package/__tests__/mocks/index.ts +94 -0
  13. package/__tests__/mocks/sendgrid-api-responses.ts +391 -0
  14. package/__tests__/mocks/sendgrid-webhook-payloads.ts +463 -0
  15. package/jest.config.cjs +16 -7
  16. package/package.json +23 -15
  17. package/coverage/clover.xml +0 -328
  18. package/coverage/coverage-final.json +0 -10
  19. package/coverage/lcov-report/base.css +0 -224
  20. package/coverage/lcov-report/block-navigation.js +0 -87
  21. package/coverage/lcov-report/favicon.png +0 -0
  22. package/coverage/lcov-report/index.html +0 -161
  23. package/coverage/lcov-report/prettify.css +0 -1
  24. package/coverage/lcov-report/prettify.js +0 -2
  25. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  26. package/coverage/lcov-report/sorter.js +0 -210
  27. package/coverage/lcov-report/src/EmailWebhookEventsService.ts.html +0 -826
  28. package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +0 -775
  29. package/coverage/lcov-report/src/analytics/index.html +0 -116
  30. package/coverage/lcov-report/src/index.html +0 -131
  31. package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +0 -301
  32. package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +0 -301
  33. package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +0 -436
  34. package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +0 -247
  35. package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +0 -274
  36. package/coverage/lcov-report/src/normalizers/index.html +0 -176
  37. package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +0 -289
  38. package/coverage/lcov-report/src/persistence/index.html +0 -116
  39. package/coverage/lcov-report/src/types.ts.html +0 -823
  40. 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
+