@wraps.dev/cli 0.1.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.
@@ -0,0 +1,932 @@
1
+ import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
2
+ import type { SQSEvent, SQSRecord } from "aws-lambda";
3
+ import { mockClient } from "aws-sdk-client-mock";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { handler } from "../index.js";
6
+
7
+ const dynamoMock = mockClient(DynamoDBClient);
8
+
9
+ /**
10
+ * AWS SES Mailbox Simulator addresses for testing different scenarios
11
+ * https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html
12
+ */
13
+ export const SES_SIMULATOR_ADDRESSES = {
14
+ /** Successful delivery scenario */
15
+ SUCCESS: "success@simulator.amazonses.com",
16
+ /** Bounce scenario - generates SMTP 550 5.1.1 "Unknown User" response */
17
+ BOUNCE: "bounce@simulator.amazonses.com",
18
+ /** Out-of-office auto-response scenario */
19
+ OOTO: "ooto@simulator.amazonses.com",
20
+ /** Complaint scenario - recipient marks email as spam */
21
+ COMPLAINT: "complaint@simulator.amazonses.com",
22
+ /** Suppression list scenario - generates hard bounce */
23
+ SUPPRESSION_LIST: "suppressionlist@simulator.amazonses.com",
24
+ } as const;
25
+
26
+ describe("Lambda Event Processor", () => {
27
+ const originalEnv = process.env;
28
+
29
+ beforeEach(() => {
30
+ dynamoMock.reset();
31
+ process.env = {
32
+ ...originalEnv,
33
+ TABLE_NAME: "test-email-events",
34
+ AWS_ACCOUNT_ID: "123456789012",
35
+ };
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env = originalEnv;
41
+ });
42
+
43
+ it("should throw error if TABLE_NAME is not set", async () => {
44
+ delete process.env.TABLE_NAME;
45
+
46
+ const event: SQSEvent = {
47
+ Records: [],
48
+ };
49
+
50
+ await expect(handler(event)).rejects.toThrow(
51
+ "TABLE_NAME environment variable not set"
52
+ );
53
+ });
54
+
55
+ describe("Send Event", () => {
56
+ it("should process Send event successfully", async () => {
57
+ dynamoMock.on(PutItemCommand).resolves({});
58
+
59
+ const event: SQSEvent = {
60
+ Records: [
61
+ createSQSRecord({
62
+ eventType: "Send",
63
+ messageId: "test-message-id-123",
64
+ timestamp: "2024-01-15T10:30:00.000Z",
65
+ source: "sender@example.com",
66
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
67
+ subject: "Test Email",
68
+ tags: { campaign: "test" },
69
+ }),
70
+ ],
71
+ };
72
+
73
+ const result = await handler(event);
74
+
75
+ expect(result.statusCode).toBe(200);
76
+ expect(dynamoMock.calls()).toHaveLength(1);
77
+
78
+ const putItemCall = dynamoMock.call(0).args[0].input;
79
+ expect(putItemCall.TableName).toBe("test-email-events");
80
+ expect(putItemCall.Item?.messageId.S).toBe("test-message-id-123");
81
+ expect(putItemCall.Item?.eventType.S).toBe("Send");
82
+ expect(putItemCall.Item?.from.S).toBe("sender@example.com");
83
+ expect(putItemCall.Item?.to.SS).toEqual([
84
+ SES_SIMULATOR_ADDRESSES.SUCCESS,
85
+ ]);
86
+ });
87
+ });
88
+
89
+ describe("Delivery Event", () => {
90
+ it("should process Delivery event successfully", async () => {
91
+ dynamoMock.on(PutItemCommand).resolves({});
92
+
93
+ const event: SQSEvent = {
94
+ Records: [
95
+ createSQSRecord({
96
+ eventType: "Delivery",
97
+ messageId: "delivery-test-123",
98
+ timestamp: "2024-01-15T10:30:00.000Z",
99
+ source: "sender@example.com",
100
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
101
+ subject: "Delivery Test",
102
+ delivery: {
103
+ timestamp: "2024-01-15T10:30:05.000Z",
104
+ processingTimeMillis: 5000,
105
+ recipients: [SES_SIMULATOR_ADDRESSES.SUCCESS],
106
+ smtpResponse: "250 2.0.0 OK",
107
+ remoteMtaIp: "192.0.2.1",
108
+ },
109
+ }),
110
+ ],
111
+ };
112
+
113
+ const result = await handler(event);
114
+
115
+ expect(result.statusCode).toBe(200);
116
+ expect(dynamoMock.calls()).toHaveLength(1);
117
+
118
+ const putItemCall = dynamoMock.call(0).args[0].input;
119
+ expect(putItemCall.Item?.eventType.S).toBe("Delivery");
120
+
121
+ const additionalData = JSON.parse(
122
+ putItemCall.Item?.additionalData.S || "{}"
123
+ );
124
+ expect(additionalData.processingTimeMillis).toBe(5000);
125
+ expect(additionalData.smtpResponse).toBe("250 2.0.0 OK");
126
+ });
127
+ });
128
+
129
+ describe("Bounce Event - AWS Simulator", () => {
130
+ it("should process Bounce event from simulator successfully", async () => {
131
+ dynamoMock.on(PutItemCommand).resolves({});
132
+
133
+ const event: SQSEvent = {
134
+ Records: [
135
+ createSQSRecord({
136
+ eventType: "Bounce",
137
+ messageId: "bounce-test-123",
138
+ timestamp: "2024-01-15T10:30:00.000Z",
139
+ source: "sender@example.com",
140
+ destination: [SES_SIMULATOR_ADDRESSES.BOUNCE],
141
+ subject: "Bounce Test",
142
+ bounce: {
143
+ bounceType: "Permanent",
144
+ bounceSubType: "General",
145
+ bouncedRecipients: [
146
+ {
147
+ emailAddress: SES_SIMULATOR_ADDRESSES.BOUNCE,
148
+ action: "failed",
149
+ status: "5.1.1",
150
+ diagnosticCode: "smtp; 550 5.1.1 user unknown",
151
+ },
152
+ ],
153
+ timestamp: "2024-01-15T10:30:05.000Z",
154
+ feedbackId: "00000000-0000-0000-0000-000000000000",
155
+ },
156
+ }),
157
+ ],
158
+ };
159
+
160
+ const result = await handler(event);
161
+
162
+ expect(result.statusCode).toBe(200);
163
+ expect(dynamoMock.calls()).toHaveLength(1);
164
+
165
+ const putItemCall = dynamoMock.call(0).args[0].input;
166
+ expect(putItemCall.Item?.eventType.S).toBe("Bounce");
167
+ expect(putItemCall.Item?.to.SS).toEqual([SES_SIMULATOR_ADDRESSES.BOUNCE]);
168
+
169
+ const additionalData = JSON.parse(
170
+ putItemCall.Item?.additionalData.S || "{}"
171
+ );
172
+ expect(additionalData.bounceType).toBe("Permanent");
173
+ expect(additionalData.bounceSubType).toBe("General");
174
+ expect(additionalData.bouncedRecipients).toHaveLength(1);
175
+ expect(additionalData.bouncedRecipients[0].emailAddress).toBe(
176
+ SES_SIMULATOR_ADDRESSES.BOUNCE
177
+ );
178
+ });
179
+
180
+ it("should handle transient bounce correctly", async () => {
181
+ dynamoMock.on(PutItemCommand).resolves({});
182
+
183
+ const event: SQSEvent = {
184
+ Records: [
185
+ createSQSRecord({
186
+ eventType: "Bounce",
187
+ messageId: "transient-bounce-123",
188
+ timestamp: "2024-01-15T10:30:00.000Z",
189
+ source: "sender@example.com",
190
+ destination: [SES_SIMULATOR_ADDRESSES.BOUNCE],
191
+ subject: "Transient Bounce Test",
192
+ bounce: {
193
+ bounceType: "Transient",
194
+ bounceSubType: "MailboxFull",
195
+ bouncedRecipients: [
196
+ {
197
+ emailAddress: SES_SIMULATOR_ADDRESSES.BOUNCE,
198
+ action: "failed",
199
+ status: "4.2.2",
200
+ diagnosticCode: "smtp; 452 4.2.2 mailbox full",
201
+ },
202
+ ],
203
+ timestamp: "2024-01-15T10:30:05.000Z",
204
+ feedbackId: "transient-bounce-feedback",
205
+ },
206
+ }),
207
+ ],
208
+ };
209
+
210
+ const result = await handler(event);
211
+
212
+ expect(result.statusCode).toBe(200);
213
+
214
+ const putItemCall = dynamoMock.call(0).args[0].input;
215
+ const additionalData = JSON.parse(
216
+ putItemCall.Item?.additionalData.S || "{}"
217
+ );
218
+ expect(additionalData.bounceType).toBe("Transient");
219
+ expect(additionalData.bounceSubType).toBe("MailboxFull");
220
+ });
221
+ });
222
+
223
+ describe("Complaint Event - AWS Simulator", () => {
224
+ it("should process Complaint event from simulator successfully", async () => {
225
+ dynamoMock.on(PutItemCommand).resolves({});
226
+
227
+ const event: SQSEvent = {
228
+ Records: [
229
+ createSQSRecord({
230
+ eventType: "Complaint",
231
+ messageId: "complaint-test-123",
232
+ timestamp: "2024-01-15T10:30:00.000Z",
233
+ source: "sender@example.com",
234
+ destination: [SES_SIMULATOR_ADDRESSES.COMPLAINT],
235
+ subject: "Complaint Test",
236
+ complaint: {
237
+ complainedRecipients: [
238
+ {
239
+ emailAddress: SES_SIMULATOR_ADDRESSES.COMPLAINT,
240
+ },
241
+ ],
242
+ timestamp: "2024-01-15T10:35:00.000Z",
243
+ feedbackId: "complaint-feedback-123",
244
+ complaintFeedbackType: "abuse",
245
+ userAgent: "Mozilla/5.0",
246
+ },
247
+ }),
248
+ ],
249
+ };
250
+
251
+ const result = await handler(event);
252
+
253
+ expect(result.statusCode).toBe(200);
254
+ expect(dynamoMock.calls()).toHaveLength(1);
255
+
256
+ const putItemCall = dynamoMock.call(0).args[0].input;
257
+ expect(putItemCall.Item?.eventType.S).toBe("Complaint");
258
+ expect(putItemCall.Item?.to.SS).toEqual([
259
+ SES_SIMULATOR_ADDRESSES.COMPLAINT,
260
+ ]);
261
+
262
+ const additionalData = JSON.parse(
263
+ putItemCall.Item?.additionalData.S || "{}"
264
+ );
265
+ expect(additionalData.complaintFeedbackType).toBe("abuse");
266
+ expect(additionalData.complainedRecipients).toHaveLength(1);
267
+ expect(additionalData.complainedRecipients[0].emailAddress).toBe(
268
+ SES_SIMULATOR_ADDRESSES.COMPLAINT
269
+ );
270
+ });
271
+ });
272
+
273
+ describe("Suppression List Event - AWS Simulator", () => {
274
+ it("should process suppression list bounce event", async () => {
275
+ dynamoMock.on(PutItemCommand).resolves({});
276
+
277
+ const event: SQSEvent = {
278
+ Records: [
279
+ createSQSRecord({
280
+ eventType: "Bounce",
281
+ messageId: "suppression-test-123",
282
+ timestamp: "2024-01-15T10:30:00.000Z",
283
+ source: "sender@example.com",
284
+ destination: [SES_SIMULATOR_ADDRESSES.SUPPRESSION_LIST],
285
+ subject: "Suppression List Test",
286
+ bounce: {
287
+ bounceType: "Permanent",
288
+ bounceSubType: "Suppressed",
289
+ bouncedRecipients: [
290
+ {
291
+ emailAddress: SES_SIMULATOR_ADDRESSES.SUPPRESSION_LIST,
292
+ action: "failed",
293
+ status: "5.1.1",
294
+ diagnosticCode:
295
+ "Amazon SES has suppressed sending to this address",
296
+ },
297
+ ],
298
+ timestamp: "2024-01-15T10:30:01.000Z",
299
+ feedbackId: "suppression-feedback-123",
300
+ },
301
+ }),
302
+ ],
303
+ };
304
+
305
+ const result = await handler(event);
306
+
307
+ expect(result.statusCode).toBe(200);
308
+
309
+ const putItemCall = dynamoMock.call(0).args[0].input;
310
+ const additionalData = JSON.parse(
311
+ putItemCall.Item?.additionalData.S || "{}"
312
+ );
313
+ expect(additionalData.bounceType).toBe("Permanent");
314
+ expect(additionalData.bounceSubType).toBe("Suppressed");
315
+ });
316
+ });
317
+
318
+ describe("Open Event", () => {
319
+ it("should process Open event successfully", async () => {
320
+ dynamoMock.on(PutItemCommand).resolves({});
321
+
322
+ const event: SQSEvent = {
323
+ Records: [
324
+ createSQSRecord({
325
+ eventType: "Open",
326
+ messageId: "open-test-123",
327
+ timestamp: "2024-01-15T10:30:00.000Z",
328
+ source: "sender@example.com",
329
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
330
+ subject: "Open Test",
331
+ open: {
332
+ timestamp: "2024-01-15T10:35:00.000Z",
333
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)",
334
+ ipAddress: "198.51.100.1",
335
+ },
336
+ }),
337
+ ],
338
+ };
339
+
340
+ const result = await handler(event);
341
+
342
+ expect(result.statusCode).toBe(200);
343
+
344
+ const putItemCall = dynamoMock.call(0).args[0].input;
345
+ expect(putItemCall.Item?.eventType.S).toBe("Open");
346
+
347
+ const additionalData = JSON.parse(
348
+ putItemCall.Item?.additionalData.S || "{}"
349
+ );
350
+ expect(additionalData.userAgent).toContain("iPhone");
351
+ expect(additionalData.ipAddress).toBe("198.51.100.1");
352
+ });
353
+ });
354
+
355
+ describe("Click Event", () => {
356
+ it("should process Click event successfully", async () => {
357
+ dynamoMock.on(PutItemCommand).resolves({});
358
+
359
+ const event: SQSEvent = {
360
+ Records: [
361
+ createSQSRecord({
362
+ eventType: "Click",
363
+ messageId: "click-test-123",
364
+ timestamp: "2024-01-15T10:30:00.000Z",
365
+ source: "sender@example.com",
366
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
367
+ subject: "Click Test",
368
+ click: {
369
+ timestamp: "2024-01-15T10:36:00.000Z",
370
+ link: "https://example.com/signup",
371
+ linkTags: { campaign: "test", source: "email" },
372
+ userAgent: "Mozilla/5.0 (Windows NT 10.0)",
373
+ ipAddress: "203.0.113.1",
374
+ },
375
+ }),
376
+ ],
377
+ };
378
+
379
+ const result = await handler(event);
380
+
381
+ expect(result.statusCode).toBe(200);
382
+
383
+ const putItemCall = dynamoMock.call(0).args[0].input;
384
+ expect(putItemCall.Item?.eventType.S).toBe("Click");
385
+
386
+ const additionalData = JSON.parse(
387
+ putItemCall.Item?.additionalData.S || "{}"
388
+ );
389
+ expect(additionalData.link).toBe("https://example.com/signup");
390
+ expect(additionalData.linkTags).toEqual({
391
+ campaign: "test",
392
+ source: "email",
393
+ });
394
+ });
395
+ });
396
+
397
+ describe("Reject Event", () => {
398
+ it("should process Reject event successfully", async () => {
399
+ dynamoMock.on(PutItemCommand).resolves({});
400
+
401
+ const event: SQSEvent = {
402
+ Records: [
403
+ createSQSRecord({
404
+ eventType: "Reject",
405
+ messageId: "reject-test-123",
406
+ timestamp: "2024-01-15T10:30:00.000Z",
407
+ source: "sender@example.com",
408
+ destination: ["invalid@example.com"],
409
+ subject: "Reject Test",
410
+ reject: {
411
+ reason: "Bad content",
412
+ },
413
+ }),
414
+ ],
415
+ };
416
+
417
+ const result = await handler(event);
418
+
419
+ expect(result.statusCode).toBe(200);
420
+
421
+ const putItemCall = dynamoMock.call(0).args[0].input;
422
+ expect(putItemCall.Item?.eventType.S).toBe("Reject");
423
+
424
+ const additionalData = JSON.parse(
425
+ putItemCall.Item?.additionalData.S || "{}"
426
+ );
427
+ expect(additionalData.reason).toBe("Bad content");
428
+ });
429
+ });
430
+
431
+ describe("Rendering Failure Event", () => {
432
+ it("should process Rendering Failure event successfully", async () => {
433
+ dynamoMock.on(PutItemCommand).resolves({});
434
+
435
+ const event: SQSEvent = {
436
+ Records: [
437
+ createSQSRecord({
438
+ eventType: "Rendering Failure",
439
+ messageId: "rendering-failure-123",
440
+ timestamp: "2024-01-15T10:30:00.000Z",
441
+ source: "sender@example.com",
442
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
443
+ subject: "Rendering Failure Test",
444
+ failure: {
445
+ errorMessage: "Template variable not found",
446
+ templateName: "welcome-email",
447
+ },
448
+ }),
449
+ ],
450
+ };
451
+
452
+ const result = await handler(event);
453
+
454
+ expect(result.statusCode).toBe(200);
455
+
456
+ const putItemCall = dynamoMock.call(0).args[0].input;
457
+ expect(putItemCall.Item?.eventType.S).toBe("Rendering Failure");
458
+
459
+ const additionalData = JSON.parse(
460
+ putItemCall.Item?.additionalData.S || "{}"
461
+ );
462
+ expect(additionalData.errorMessage).toBe("Template variable not found");
463
+ expect(additionalData.templateName).toBe("welcome-email");
464
+ });
465
+ });
466
+
467
+ describe("DeliveryDelay Event", () => {
468
+ it("should process DeliveryDelay event successfully", async () => {
469
+ dynamoMock.on(PutItemCommand).resolves({});
470
+
471
+ const event: SQSEvent = {
472
+ Records: [
473
+ createSQSRecord({
474
+ eventType: "DeliveryDelay",
475
+ messageId: "delay-test-123",
476
+ timestamp: "2024-01-15T10:30:00.000Z",
477
+ source: "sender@example.com",
478
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
479
+ subject: "Delay Test",
480
+ deliveryDelay: {
481
+ timestamp: "2024-01-15T10:35:00.000Z",
482
+ delayType: "TransientCommunicationFailure",
483
+ expirationTime: "2024-01-15T22:30:00.000Z",
484
+ delayedRecipients: [
485
+ {
486
+ emailAddress: SES_SIMULATOR_ADDRESSES.SUCCESS,
487
+ status: "4.4.1",
488
+ diagnosticCode: "smtp; 441 4.4.1 Unable to connect",
489
+ },
490
+ ],
491
+ },
492
+ }),
493
+ ],
494
+ };
495
+
496
+ const result = await handler(event);
497
+
498
+ expect(result.statusCode).toBe(200);
499
+
500
+ const putItemCall = dynamoMock.call(0).args[0].input;
501
+ expect(putItemCall.Item?.eventType.S).toBe("DeliveryDelay");
502
+
503
+ const additionalData = JSON.parse(
504
+ putItemCall.Item?.additionalData.S || "{}"
505
+ );
506
+ expect(additionalData.delayType).toBe("TransientCommunicationFailure");
507
+ expect(additionalData.delayedRecipients).toHaveLength(1);
508
+ });
509
+ });
510
+
511
+ describe("Subscription Event", () => {
512
+ it("should process Subscription event successfully", async () => {
513
+ dynamoMock.on(PutItemCommand).resolves({});
514
+
515
+ const event: SQSEvent = {
516
+ Records: [
517
+ createSQSRecord({
518
+ eventType: "Subscription",
519
+ messageId: "subscription-test-123",
520
+ timestamp: "2024-01-15T10:30:00.000Z",
521
+ source: "sender@example.com",
522
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
523
+ subject: "Subscription Test",
524
+ subscription: {
525
+ contactList: "newsletter-subscribers",
526
+ timestamp: "2024-01-15T10:35:00.000Z",
527
+ source: "UnsubscribeHeader",
528
+ newTopicPreferences: {
529
+ unsubscribeAll: true,
530
+ topicSubscriptionStatus: [],
531
+ },
532
+ oldTopicPreferences: {
533
+ unsubscribeAll: false,
534
+ topicSubscriptionStatus: [
535
+ {
536
+ topicName: "weekly-digest",
537
+ subscriptionStatus: "OptIn",
538
+ },
539
+ ],
540
+ },
541
+ },
542
+ }),
543
+ ],
544
+ };
545
+
546
+ const result = await handler(event);
547
+
548
+ expect(result.statusCode).toBe(200);
549
+
550
+ const putItemCall = dynamoMock.call(0).args[0].input;
551
+ expect(putItemCall.Item?.eventType.S).toBe("Subscription");
552
+
553
+ const additionalData = JSON.parse(
554
+ putItemCall.Item?.additionalData.S || "{}"
555
+ );
556
+ expect(additionalData.contactList).toBe("newsletter-subscribers");
557
+ expect(additionalData.newTopicPreferences.unsubscribeAll).toBe(true);
558
+ });
559
+ });
560
+
561
+ describe("Multiple Events", () => {
562
+ it("should process multiple events in a batch", async () => {
563
+ dynamoMock.on(PutItemCommand).resolves({});
564
+
565
+ const event: SQSEvent = {
566
+ Records: [
567
+ createSQSRecord({
568
+ eventType: "Send",
569
+ messageId: "batch-msg-1",
570
+ timestamp: "2024-01-15T10:30:00.000Z",
571
+ source: "sender@example.com",
572
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
573
+ subject: "Batch Test 1",
574
+ }),
575
+ createSQSRecord({
576
+ eventType: "Delivery",
577
+ messageId: "batch-msg-2",
578
+ timestamp: "2024-01-15T10:30:00.000Z",
579
+ source: "sender@example.com",
580
+ destination: [SES_SIMULATOR_ADDRESSES.BOUNCE],
581
+ subject: "Batch Test 2",
582
+ delivery: {
583
+ timestamp: "2024-01-15T10:30:05.000Z",
584
+ processingTimeMillis: 5000,
585
+ recipients: [SES_SIMULATOR_ADDRESSES.BOUNCE],
586
+ smtpResponse: "250 2.0.0 OK",
587
+ remoteMtaIp: "192.0.2.1",
588
+ },
589
+ }),
590
+ createSQSRecord({
591
+ eventType: "Bounce",
592
+ messageId: "batch-msg-3",
593
+ timestamp: "2024-01-15T10:30:00.000Z",
594
+ source: "sender@example.com",
595
+ destination: [SES_SIMULATOR_ADDRESSES.BOUNCE],
596
+ subject: "Batch Test 3",
597
+ bounce: {
598
+ bounceType: "Permanent",
599
+ bounceSubType: "General",
600
+ bouncedRecipients: [
601
+ {
602
+ emailAddress: SES_SIMULATOR_ADDRESSES.BOUNCE,
603
+ action: "failed",
604
+ status: "5.1.1",
605
+ diagnosticCode: "smtp; 550 5.1.1 user unknown",
606
+ },
607
+ ],
608
+ timestamp: "2024-01-15T10:30:10.000Z",
609
+ feedbackId: "batch-bounce-feedback",
610
+ },
611
+ }),
612
+ ],
613
+ };
614
+
615
+ const result = await handler(event);
616
+
617
+ expect(result.statusCode).toBe(200);
618
+ expect(dynamoMock.calls()).toHaveLength(3);
619
+ });
620
+ });
621
+
622
+ describe("Error Handling", () => {
623
+ it("should continue processing other records when one fails", async () => {
624
+ const consoleErrorSpy = vi
625
+ .spyOn(console, "error")
626
+ .mockImplementation(() => {});
627
+
628
+ dynamoMock.on(PutItemCommand).resolves({});
629
+
630
+ const event: SQSEvent = {
631
+ Records: [
632
+ createSQSRecord({
633
+ eventType: "Send",
634
+ messageId: "success-msg",
635
+ timestamp: "2024-01-15T10:30:00.000Z",
636
+ source: "sender@example.com",
637
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
638
+ subject: "Success",
639
+ }),
640
+ {
641
+ messageId: "invalid-record",
642
+ receiptHandle: "invalid",
643
+ body: "invalid json",
644
+ attributes: {
645
+ ApproximateReceiveCount: "1",
646
+ SentTimestamp: "1234567890",
647
+ SenderId: "test",
648
+ ApproximateFirstReceiveTimestamp: "1234567890",
649
+ },
650
+ messageAttributes: {},
651
+ md5OfBody: "test",
652
+ eventSource: "aws:sqs",
653
+ eventSourceARN: "arn:aws:sqs:us-east-1:123456789012:test",
654
+ awsRegion: "us-east-1",
655
+ } as SQSRecord,
656
+ ],
657
+ };
658
+
659
+ const result = await handler(event);
660
+
661
+ expect(result.statusCode).toBe(200);
662
+ expect(dynamoMock.calls()).toHaveLength(1); // Only one successful call
663
+ expect(consoleErrorSpy).toHaveBeenCalled();
664
+
665
+ consoleErrorSpy.mockRestore();
666
+ });
667
+
668
+ it("should handle DynamoDB errors gracefully", async () => {
669
+ const consoleErrorSpy = vi
670
+ .spyOn(console, "error")
671
+ .mockImplementation(() => {});
672
+
673
+ dynamoMock.on(PutItemCommand).rejects(new Error("DynamoDB error"));
674
+
675
+ const event: SQSEvent = {
676
+ Records: [
677
+ createSQSRecord({
678
+ eventType: "Send",
679
+ messageId: "error-msg",
680
+ timestamp: "2024-01-15T10:30:00.000Z",
681
+ source: "sender@example.com",
682
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
683
+ subject: "Error Test",
684
+ }),
685
+ ],
686
+ };
687
+
688
+ const result = await handler(event);
689
+
690
+ expect(result.statusCode).toBe(200);
691
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
692
+ "Error processing record:",
693
+ expect.any(Error)
694
+ );
695
+
696
+ consoleErrorSpy.mockRestore();
697
+ });
698
+ });
699
+
700
+ describe("TTL and Timestamps", () => {
701
+ it("should set correct TTL (90 days) on records", async () => {
702
+ dynamoMock.on(PutItemCommand).resolves({});
703
+
704
+ const event: SQSEvent = {
705
+ Records: [
706
+ createSQSRecord({
707
+ eventType: "Send",
708
+ messageId: "ttl-test",
709
+ timestamp: "2024-01-15T10:30:00.000Z",
710
+ source: "sender@example.com",
711
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
712
+ subject: "TTL Test",
713
+ }),
714
+ ],
715
+ };
716
+
717
+ await handler(event);
718
+
719
+ const putItemCall = dynamoMock.call(0).args[0].input;
720
+ const expiresAt = Number.parseInt(putItemCall.Item?.expiresAt.N || "0");
721
+ const createdAt = Number.parseInt(putItemCall.Item?.createdAt.N || "0");
722
+
723
+ // TTL should be approximately 90 days (7776000000 ms) after createdAt
724
+ const expectedTTL = 90 * 24 * 60 * 60 * 1000;
725
+ const actualTTL = expiresAt - createdAt;
726
+
727
+ expect(actualTTL).toBeGreaterThanOrEqual(expectedTTL - 1000);
728
+ expect(actualTTL).toBeLessThanOrEqual(expectedTTL + 1000);
729
+ });
730
+
731
+ it("should use event-specific timestamps for sortKey", async () => {
732
+ dynamoMock.on(PutItemCommand).resolves({});
733
+
734
+ const mailTimestamp = "2024-01-15T10:30:00.000Z";
735
+ const deliveryTimestamp = "2024-01-15T10:30:05.000Z";
736
+
737
+ const event: SQSEvent = {
738
+ Records: [
739
+ createSQSRecord({
740
+ eventType: "Delivery",
741
+ messageId: "timestamp-test",
742
+ timestamp: mailTimestamp,
743
+ source: "sender@example.com",
744
+ destination: [SES_SIMULATOR_ADDRESSES.SUCCESS],
745
+ subject: "Timestamp Test",
746
+ delivery: {
747
+ timestamp: deliveryTimestamp,
748
+ processingTimeMillis: 5000,
749
+ recipients: [SES_SIMULATOR_ADDRESSES.SUCCESS],
750
+ smtpResponse: "250 OK",
751
+ remoteMtaIp: "192.0.2.1",
752
+ },
753
+ }),
754
+ ],
755
+ };
756
+
757
+ await handler(event);
758
+
759
+ const putItemCall = dynamoMock.call(0).args[0].input;
760
+ const sentAt = Number.parseInt(putItemCall.Item?.sentAt.N || "0");
761
+ const expectedTimestamp = new Date(deliveryTimestamp).getTime();
762
+
763
+ expect(sentAt).toBe(expectedTimestamp);
764
+ });
765
+ });
766
+ });
767
+
768
+ /**
769
+ * Helper function to create an SQS record with SES event data
770
+ */
771
+ function createSQSRecord(params: {
772
+ eventType: string;
773
+ messageId: string;
774
+ timestamp: string;
775
+ source: string;
776
+ destination: string[];
777
+ subject: string;
778
+ tags?: Record<string, string>;
779
+ send?: Record<string, unknown>;
780
+ delivery?: {
781
+ timestamp: string;
782
+ processingTimeMillis: number;
783
+ recipients: string[];
784
+ smtpResponse: string;
785
+ remoteMtaIp: string;
786
+ };
787
+ bounce?: {
788
+ bounceType: string;
789
+ bounceSubType: string;
790
+ bouncedRecipients: Array<{
791
+ emailAddress: string;
792
+ action: string;
793
+ status: string;
794
+ diagnosticCode: string;
795
+ }>;
796
+ timestamp: string;
797
+ feedbackId: string;
798
+ };
799
+ complaint?: {
800
+ complainedRecipients: Array<{ emailAddress: string }>;
801
+ timestamp: string;
802
+ feedbackId: string;
803
+ complaintFeedbackType: string;
804
+ userAgent: string;
805
+ };
806
+ open?: {
807
+ timestamp: string;
808
+ userAgent: string;
809
+ ipAddress: string;
810
+ };
811
+ click?: {
812
+ timestamp: string;
813
+ link: string;
814
+ linkTags?: Record<string, string>;
815
+ userAgent: string;
816
+ ipAddress: string;
817
+ };
818
+ reject?: {
819
+ reason: string;
820
+ };
821
+ failure?: {
822
+ errorMessage: string;
823
+ templateName: string;
824
+ };
825
+ deliveryDelay?: {
826
+ timestamp: string;
827
+ delayType: string;
828
+ expirationTime: string;
829
+ delayedRecipients: Array<{
830
+ emailAddress: string;
831
+ status: string;
832
+ diagnosticCode: string;
833
+ }>;
834
+ };
835
+ subscription?: {
836
+ contactList: string;
837
+ timestamp: string;
838
+ source: string;
839
+ newTopicPreferences: {
840
+ unsubscribeAll: boolean;
841
+ topicSubscriptionStatus: Array<{
842
+ topicName?: string;
843
+ subscriptionStatus?: string;
844
+ }>;
845
+ };
846
+ oldTopicPreferences: {
847
+ unsubscribeAll: boolean;
848
+ topicSubscriptionStatus: Array<{
849
+ topicName?: string;
850
+ subscriptionStatus?: string;
851
+ }>;
852
+ };
853
+ };
854
+ }): SQSRecord {
855
+ const mail = {
856
+ timestamp: params.timestamp,
857
+ source: params.source,
858
+ messageId: params.messageId,
859
+ destination: params.destination,
860
+ commonHeaders: {
861
+ subject: params.subject,
862
+ },
863
+ tags: params.tags || {},
864
+ };
865
+
866
+ const detail: Record<string, unknown> = {
867
+ eventType: params.eventType,
868
+ mail,
869
+ };
870
+
871
+ if (params.send) {
872
+ detail.send = params.send;
873
+ }
874
+ if (params.delivery) {
875
+ detail.delivery = params.delivery;
876
+ }
877
+ if (params.bounce) {
878
+ detail.bounce = params.bounce;
879
+ }
880
+ if (params.complaint) {
881
+ detail.complaint = params.complaint;
882
+ }
883
+ if (params.open) {
884
+ detail.open = params.open;
885
+ }
886
+ if (params.click) {
887
+ detail.click = params.click;
888
+ }
889
+ if (params.reject) {
890
+ detail.reject = params.reject;
891
+ }
892
+ if (params.failure) {
893
+ detail.failure = params.failure;
894
+ }
895
+ if (params.deliveryDelay) {
896
+ detail.deliveryDelay = params.deliveryDelay;
897
+ }
898
+ if (params.subscription) {
899
+ detail.subscription = params.subscription;
900
+ }
901
+
902
+ const eventBridgeEvent = {
903
+ version: "0",
904
+ id: `event-${params.messageId}`,
905
+ "detail-type": "SES Email Event",
906
+ source: "aws.ses",
907
+ account: "123456789012",
908
+ time: params.timestamp,
909
+ region: "us-east-1",
910
+ resources: [],
911
+ detail,
912
+ };
913
+
914
+ return {
915
+ messageId: params.messageId,
916
+ receiptHandle: `receipt-${params.messageId}`,
917
+ body: JSON.stringify(eventBridgeEvent),
918
+ attributes: {
919
+ ApproximateReceiveCount: "1",
920
+ SentTimestamp: new Date(params.timestamp).getTime().toString(),
921
+ SenderId: "AIDAI123456789",
922
+ ApproximateFirstReceiveTimestamp: new Date(params.timestamp)
923
+ .getTime()
924
+ .toString(),
925
+ },
926
+ messageAttributes: {},
927
+ md5OfBody: "test-md5",
928
+ eventSource: "aws:sqs",
929
+ eventSourceARN: "arn:aws:sqs:us-east-1:123456789012:wraps-email-events",
930
+ awsRegion: "us-east-1",
931
+ };
932
+ }