serverless-plugin-module-registry 1.0.12 → 1.0.13

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,994 @@
1
+ /**
2
+ * Stream Processor Handler Tests
3
+ * Tests for the DynamoDB Stream event processor that publishes to SQS
4
+ */
5
+
6
+ // Mock AWS SDK before imports
7
+ const mockSQSSend = jest.fn()
8
+ const mockEventBridgeSend = jest.fn()
9
+ jest.mock('@aws-sdk/client-sqs', () => ({
10
+ SQSClient: jest.fn(() => ({
11
+ send: mockSQSSend,
12
+ })),
13
+ SendMessageCommand: jest.fn((input) => input),
14
+ }))
15
+ jest.mock('@aws-sdk/client-eventbridge', () => ({
16
+ EventBridgeClient: jest.fn(() => ({
17
+ send: mockEventBridgeSend,
18
+ })),
19
+ PutEventsCommand: jest.fn((input) => input),
20
+ }))
21
+
22
+ import { DynamoDBStreamEvent } from 'aws-lambda'
23
+ import { handler } from '../handlers/stream-processor'
24
+
25
+ describe('Stream Processor Handler', () => {
26
+ beforeEach(() => {
27
+ jest.clearAllMocks()
28
+ mockSQSSend.mockReset()
29
+ mockEventBridgeSend.mockReset()
30
+ // Set environment variables for each test
31
+ process.env.QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/role-update-queue'
32
+ process.env.EVENT_BUS_NAME = 'module-registry-event-bus'
33
+ })
34
+
35
+ describe('Event filtering', () => {
36
+ it('should filter and process only ROLE# records', async () => {
37
+ const event: DynamoDBStreamEvent = {
38
+ Records: [
39
+ {
40
+ eventID: '1',
41
+ eventName: 'INSERT',
42
+ eventVersion: '1.1',
43
+ eventSource: 'aws:dynamodb',
44
+ awsRegion: 'us-east-1',
45
+ dynamodb: {
46
+ Keys: {
47
+ PK: { S: 'ROLE#authenticated' },
48
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
49
+ },
50
+ NewImage: {
51
+ PK: { S: 'ROLE#authenticated' },
52
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
53
+ },
54
+ SequenceNumber: '111',
55
+ SizeBytes: 100,
56
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
57
+ },
58
+ },
59
+ {
60
+ eventID: '2',
61
+ eventName: 'INSERT',
62
+ eventVersion: '1.1',
63
+ eventSource: 'aws:dynamodb',
64
+ awsRegion: 'us-east-1',
65
+ dynamodb: {
66
+ Keys: {
67
+ PK: { S: 'MODULE#sign' },
68
+ SK: { S: 'FEATURE#jSiJIIpP' },
69
+ },
70
+ NewImage: {
71
+ PK: { S: 'MODULE#sign' },
72
+ SK: { S: 'FEATURE#jSiJIIpP' },
73
+ },
74
+ SequenceNumber: '222',
75
+ SizeBytes: 100,
76
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
77
+ },
78
+ },
79
+ ],
80
+ }
81
+
82
+ mockSQSSend.mockResolvedValue({})
83
+
84
+ await handler(event)
85
+
86
+ // Should only publish 1 message (for ROLE# record)
87
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
88
+ })
89
+
90
+ it('should handle records with lowercase pk/sk keys', async () => {
91
+ const event: DynamoDBStreamEvent = {
92
+ Records: [
93
+ {
94
+ eventID: '1',
95
+ eventName: 'INSERT',
96
+ eventVersion: '1.1',
97
+ eventSource: 'aws:dynamodb',
98
+ awsRegion: 'us-east-1',
99
+ dynamodb: {
100
+ Keys: {
101
+ pk: { S: 'ROLE#authenticated' },
102
+ sk: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
103
+ },
104
+ NewImage: {
105
+ pk: { S: 'ROLE#authenticated' },
106
+ sk: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
107
+ },
108
+ SequenceNumber: '111',
109
+ SizeBytes: 100,
110
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
111
+ },
112
+ },
113
+ ],
114
+ }
115
+
116
+ mockSQSSend.mockResolvedValue({})
117
+
118
+ await handler(event)
119
+
120
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
121
+ })
122
+ })
123
+
124
+ describe('Message parsing', () => {
125
+ it('should parse roleName and moduleName correctly', async () => {
126
+ const event: DynamoDBStreamEvent = {
127
+ Records: [
128
+ {
129
+ eventID: '1',
130
+ eventName: 'INSERT',
131
+ eventVersion: '1.1',
132
+ eventSource: 'aws:dynamodb',
133
+ awsRegion: 'us-east-1',
134
+ dynamodb: {
135
+ Keys: {
136
+ PK: { S: 'ROLE#authenticated' },
137
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
138
+ },
139
+ NewImage: {
140
+ PK: { S: 'ROLE#authenticated' },
141
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
142
+ },
143
+ SequenceNumber: '111',
144
+ SizeBytes: 100,
145
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
146
+ },
147
+ },
148
+ ],
149
+ }
150
+
151
+ mockSQSSend.mockResolvedValue({})
152
+
153
+ await handler(event)
154
+
155
+ expect(mockSQSSend).toHaveBeenCalledWith(
156
+ expect.objectContaining({
157
+ MessageBody: expect.stringContaining('"roleName":"authenticated"'),
158
+ })
159
+ )
160
+ expect(mockSQSSend).toHaveBeenCalledWith(
161
+ expect.objectContaining({
162
+ MessageBody: expect.stringContaining('"moduleName":"sign"'),
163
+ })
164
+ )
165
+ })
166
+
167
+ it('should handle REMOVE events using OldImage', async () => {
168
+ const event: DynamoDBStreamEvent = {
169
+ Records: [
170
+ {
171
+ eventID: '1',
172
+ eventName: 'REMOVE',
173
+ eventVersion: '1.1',
174
+ eventSource: 'aws:dynamodb',
175
+ awsRegion: 'us-east-1',
176
+ dynamodb: {
177
+ Keys: {
178
+ PK: { S: 'ROLE#authenticated' },
179
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
180
+ },
181
+ OldImage: {
182
+ PK: { S: 'ROLE#authenticated' },
183
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
184
+ },
185
+ SequenceNumber: '111',
186
+ SizeBytes: 100,
187
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
188
+ },
189
+ },
190
+ ],
191
+ }
192
+
193
+ mockSQSSend.mockResolvedValue({})
194
+
195
+ await handler(event)
196
+
197
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
198
+ expect(mockSQSSend).toHaveBeenCalledWith(
199
+ expect.objectContaining({
200
+ MessageBody: expect.stringContaining('"eventType":"REMOVE"'),
201
+ })
202
+ )
203
+ })
204
+ })
205
+
206
+ describe('Message deduplication', () => {
207
+ it('should deduplicate multiple events for same role+module', async () => {
208
+ const event: DynamoDBStreamEvent = {
209
+ Records: [
210
+ {
211
+ eventID: '1',
212
+ eventName: 'INSERT',
213
+ eventVersion: '1.1',
214
+ eventSource: 'aws:dynamodb',
215
+ awsRegion: 'us-east-1',
216
+ dynamodb: {
217
+ Keys: {
218
+ PK: { S: 'ROLE#authenticated' },
219
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
220
+ },
221
+ NewImage: {
222
+ PK: { S: 'ROLE#authenticated' },
223
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
224
+ },
225
+ SequenceNumber: '111',
226
+ SizeBytes: 100,
227
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
228
+ },
229
+ },
230
+ {
231
+ eventID: '2',
232
+ eventName: 'INSERT',
233
+ eventVersion: '1.1',
234
+ eventSource: 'aws:dynamodb',
235
+ awsRegion: 'us-east-1',
236
+ dynamodb: {
237
+ Keys: {
238
+ PK: { S: 'ROLE#authenticated' },
239
+ SK: { S: 'MODULE#sign#FEATURE#fkpvztwW' },
240
+ },
241
+ NewImage: {
242
+ PK: { S: 'ROLE#authenticated' },
243
+ SK: { S: 'MODULE#sign#FEATURE#fkpvztwW' },
244
+ },
245
+ SequenceNumber: '222',
246
+ SizeBytes: 100,
247
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
248
+ },
249
+ },
250
+ {
251
+ eventID: '3',
252
+ eventName: 'INSERT',
253
+ eventVersion: '1.1',
254
+ eventSource: 'aws:dynamodb',
255
+ awsRegion: 'us-east-1',
256
+ dynamodb: {
257
+ Keys: {
258
+ PK: { S: 'ROLE#authenticated' },
259
+ SK: { S: 'MODULE#sign#FEATURE#7tmARMbS' },
260
+ },
261
+ NewImage: {
262
+ PK: { S: 'ROLE#authenticated' },
263
+ SK: { S: 'MODULE#sign#FEATURE#7tmARMbS' },
264
+ },
265
+ SequenceNumber: '333',
266
+ SizeBytes: 100,
267
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
268
+ },
269
+ },
270
+ ],
271
+ }
272
+
273
+ mockSQSSend.mockResolvedValue({})
274
+
275
+ await handler(event)
276
+
277
+ // Should deduplicate to 1 message (all same role+module)
278
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
279
+ })
280
+
281
+ it('should not deduplicate events for different role+module combos', async () => {
282
+ const event: DynamoDBStreamEvent = {
283
+ Records: [
284
+ {
285
+ eventID: '1',
286
+ eventName: 'INSERT',
287
+ eventVersion: '1.1',
288
+ eventSource: 'aws:dynamodb',
289
+ awsRegion: 'us-east-1',
290
+ dynamodb: {
291
+ Keys: {
292
+ PK: { S: 'ROLE#authenticated' },
293
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
294
+ },
295
+ NewImage: {
296
+ PK: { S: 'ROLE#authenticated' },
297
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
298
+ },
299
+ SequenceNumber: '111',
300
+ SizeBytes: 100,
301
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
302
+ },
303
+ },
304
+ {
305
+ eventID: '2',
306
+ eventName: 'INSERT',
307
+ eventVersion: '1.1',
308
+ eventSource: 'aws:dynamodb',
309
+ awsRegion: 'us-east-1',
310
+ dynamodb: {
311
+ Keys: {
312
+ PK: { S: 'ROLE#authenticated' },
313
+ SK: { S: 'MODULE#workflow#FEATURE#abc123' },
314
+ },
315
+ NewImage: {
316
+ PK: { S: 'ROLE#authenticated' },
317
+ SK: { S: 'MODULE#workflow#FEATURE#abc123' },
318
+ },
319
+ SequenceNumber: '222',
320
+ SizeBytes: 100,
321
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
322
+ },
323
+ },
324
+ {
325
+ eventID: '3',
326
+ eventName: 'INSERT',
327
+ eventVersion: '1.1',
328
+ eventSource: 'aws:dynamodb',
329
+ awsRegion: 'us-east-1',
330
+ dynamodb: {
331
+ Keys: {
332
+ PK: { S: 'ROLE#unauthenticated' },
333
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
334
+ },
335
+ NewImage: {
336
+ PK: { S: 'ROLE#unauthenticated' },
337
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
338
+ },
339
+ SequenceNumber: '333',
340
+ SizeBytes: 100,
341
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
342
+ },
343
+ },
344
+ ],
345
+ }
346
+
347
+ mockSQSSend.mockResolvedValue({})
348
+
349
+ await handler(event)
350
+
351
+ // Should publish 3 messages (different role+module combos)
352
+ expect(mockSQSSend).toHaveBeenCalledTimes(3)
353
+ })
354
+ })
355
+
356
+ describe('SQS message publishing', () => {
357
+ it('should publish message with correct structure', async () => {
358
+ const event: DynamoDBStreamEvent = {
359
+ Records: [
360
+ {
361
+ eventID: '1',
362
+ eventName: 'INSERT',
363
+ eventVersion: '1.1',
364
+ eventSource: 'aws:dynamodb',
365
+ awsRegion: 'us-east-1',
366
+ dynamodb: {
367
+ Keys: {
368
+ PK: { S: 'ROLE#authenticated' },
369
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
370
+ },
371
+ NewImage: {
372
+ PK: { S: 'ROLE#authenticated' },
373
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
374
+ },
375
+ SequenceNumber: '111',
376
+ SizeBytes: 100,
377
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
378
+ },
379
+ },
380
+ ],
381
+ }
382
+
383
+ mockSQSSend.mockResolvedValue({})
384
+
385
+ await handler(event)
386
+
387
+ expect(mockSQSSend).toHaveBeenCalledWith(
388
+ expect.objectContaining({
389
+ QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/role-update-queue',
390
+ MessageGroupId: 'authenticated',
391
+ MessageBody: expect.any(String),
392
+ MessageDeduplicationId: expect.any(String),
393
+ })
394
+ )
395
+
396
+ const messageBody = JSON.parse(mockSQSSend.mock.calls[0][0].MessageBody)
397
+ expect(messageBody).toMatchObject({
398
+ roleName: 'authenticated',
399
+ moduleName: 'sign',
400
+ eventType: 'INSERT',
401
+ timestamp: expect.any(String),
402
+ })
403
+ })
404
+ })
405
+
406
+ describe('Error handling', () => {
407
+ it('should throw error if QUEUE_URL not set', async () => {
408
+ const savedQueueUrl = process.env.QUEUE_URL
409
+ delete process.env.QUEUE_URL
410
+
411
+ const event: DynamoDBStreamEvent = {
412
+ Records: [],
413
+ }
414
+
415
+ await expect(handler(event)).rejects.toThrow('QUEUE_URL environment variable not set')
416
+
417
+ // Restore for next test
418
+ process.env.QUEUE_URL = savedQueueUrl
419
+ })
420
+
421
+ it('should continue processing other records if one fails', async () => {
422
+ const event: DynamoDBStreamEvent = {
423
+ Records: [
424
+ {
425
+ eventID: '1',
426
+ eventName: 'INSERT',
427
+ eventVersion: '1.1',
428
+ eventSource: 'aws:dynamodb',
429
+ awsRegion: 'us-east-1',
430
+ dynamodb: {
431
+ Keys: {
432
+ PK: { S: 'ROLE#authenticated' },
433
+ SK: { S: 'INVALID_SK_FORMAT' },
434
+ },
435
+ NewImage: {
436
+ PK: { S: 'ROLE#authenticated' },
437
+ SK: { S: 'INVALID_SK_FORMAT' },
438
+ },
439
+ SequenceNumber: '111',
440
+ SizeBytes: 100,
441
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
442
+ },
443
+ },
444
+ {
445
+ eventID: '2',
446
+ eventName: 'INSERT',
447
+ eventVersion: '1.1',
448
+ eventSource: 'aws:dynamodb',
449
+ awsRegion: 'us-east-1',
450
+ dynamodb: {
451
+ Keys: {
452
+ PK: { S: 'ROLE#authenticated' },
453
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
454
+ },
455
+ NewImage: {
456
+ PK: { S: 'ROLE#authenticated' },
457
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
458
+ },
459
+ SequenceNumber: '222',
460
+ SizeBytes: 100,
461
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
462
+ },
463
+ },
464
+ ],
465
+ }
466
+
467
+ mockSQSSend.mockResolvedValue({})
468
+
469
+ await handler(event)
470
+
471
+ // Should still publish the valid message
472
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
473
+ })
474
+
475
+ it('should continue publishing even if one message fails', async () => {
476
+ const event: DynamoDBStreamEvent = {
477
+ Records: [
478
+ {
479
+ eventID: '1',
480
+ eventName: 'INSERT',
481
+ eventVersion: '1.1',
482
+ eventSource: 'aws:dynamodb',
483
+ awsRegion: 'us-east-1',
484
+ dynamodb: {
485
+ Keys: {
486
+ PK: { S: 'ROLE#authenticated' },
487
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
488
+ },
489
+ NewImage: {
490
+ PK: { S: 'ROLE#authenticated' },
491
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
492
+ },
493
+ SequenceNumber: '111',
494
+ SizeBytes: 100,
495
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
496
+ },
497
+ },
498
+ {
499
+ eventID: '2',
500
+ eventName: 'INSERT',
501
+ eventVersion: '1.1',
502
+ eventSource: 'aws:dynamodb',
503
+ awsRegion: 'us-east-1',
504
+ dynamodb: {
505
+ Keys: {
506
+ PK: { S: 'ROLE#unauthenticated' },
507
+ SK: { S: 'MODULE#workflow#FEATURE#abc123' },
508
+ },
509
+ NewImage: {
510
+ PK: { S: 'ROLE#unauthenticated' },
511
+ SK: { S: 'MODULE#workflow#FEATURE#abc123' },
512
+ },
513
+ SequenceNumber: '222',
514
+ SizeBytes: 100,
515
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
516
+ },
517
+ },
518
+ ],
519
+ }
520
+
521
+ mockSQSSend
522
+ .mockRejectedValueOnce(new Error('SQS error'))
523
+ .mockResolvedValueOnce({})
524
+
525
+ await handler(event)
526
+
527
+ // Should attempt to publish both messages
528
+ expect(mockSQSSend).toHaveBeenCalledTimes(2)
529
+ })
530
+ })
531
+
532
+ describe('Empty event handling', () => {
533
+ it('should handle empty Records array', async () => {
534
+ const event: DynamoDBStreamEvent = {
535
+ Records: [],
536
+ }
537
+
538
+ await handler(event)
539
+
540
+ expect(mockSQSSend).not.toHaveBeenCalled()
541
+ expect(mockEventBridgeSend).not.toHaveBeenCalled()
542
+ })
543
+
544
+ it('should handle events with no ROLE# records', async () => {
545
+ const event: DynamoDBStreamEvent = {
546
+ Records: [
547
+ {
548
+ eventID: '1',
549
+ eventName: 'INSERT',
550
+ eventVersion: '1.1',
551
+ eventSource: 'aws:dynamodb',
552
+ awsRegion: 'us-east-1',
553
+ dynamodb: {
554
+ Keys: {
555
+ PK: { S: 'MODULE#sign' },
556
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
557
+ },
558
+ NewImage: {
559
+ PK: { S: 'MODULE#sign' },
560
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
561
+ },
562
+ SequenceNumber: '111',
563
+ SizeBytes: 100,
564
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
565
+ },
566
+ },
567
+ ],
568
+ }
569
+
570
+ mockEventBridgeSend.mockResolvedValue({})
571
+
572
+ await handler(event)
573
+
574
+ expect(mockSQSSend).not.toHaveBeenCalled()
575
+ // Should publish FEATURE# record to EventBridge
576
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
577
+ })
578
+ })
579
+
580
+ describe('FEATURE# record processing', () => {
581
+ it('should process FEATURE# records and publish to EventBridge', async () => {
582
+ const event: DynamoDBStreamEvent = {
583
+ Records: [
584
+ {
585
+ eventID: '1',
586
+ eventName: 'INSERT',
587
+ eventVersion: '1.1',
588
+ eventSource: 'aws:dynamodb',
589
+ awsRegion: 'us-east-1',
590
+ dynamodb: {
591
+ Keys: {
592
+ PK: { S: 'MODULE#public' },
593
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
594
+ },
595
+ NewImage: {
596
+ PK: { S: 'MODULE#public' },
597
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
598
+ },
599
+ SequenceNumber: '111',
600
+ SizeBytes: 100,
601
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
602
+ },
603
+ },
604
+ ],
605
+ }
606
+
607
+ mockEventBridgeSend.mockResolvedValue({})
608
+
609
+ await handler(event)
610
+
611
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
612
+ expect(mockEventBridgeSend).toHaveBeenCalledWith(
613
+ expect.objectContaining({
614
+ Entries: expect.arrayContaining([
615
+ expect.objectContaining({
616
+ Source: 'module-registry',
617
+ DetailType: 'FeatureChange',
618
+ Detail: expect.stringContaining('"moduleName":"public"'),
619
+ EventBusName: 'module-registry-event-bus',
620
+ }),
621
+ ]),
622
+ })
623
+ )
624
+ })
625
+
626
+ it('should filter MODULE# records correctly', async () => {
627
+ const event: DynamoDBStreamEvent = {
628
+ Records: [
629
+ {
630
+ eventID: '1',
631
+ eventName: 'INSERT',
632
+ eventVersion: '1.1',
633
+ eventSource: 'aws:dynamodb',
634
+ awsRegion: 'us-east-1',
635
+ dynamodb: {
636
+ Keys: {
637
+ PK: { S: 'MODULE#sign' },
638
+ SK: { S: 'MODULE#sign#FEATURE#xyz789' },
639
+ },
640
+ NewImage: {
641
+ PK: { S: 'MODULE#sign' },
642
+ SK: { S: 'MODULE#sign#FEATURE#xyz789' },
643
+ },
644
+ SequenceNumber: '111',
645
+ SizeBytes: 100,
646
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
647
+ },
648
+ },
649
+ {
650
+ eventID: '2',
651
+ eventName: 'INSERT',
652
+ eventVersion: '1.1',
653
+ eventSource: 'aws:dynamodb',
654
+ awsRegion: 'us-east-1',
655
+ dynamodb: {
656
+ Keys: {
657
+ PK: { S: 'ROLE#authenticated' },
658
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
659
+ },
660
+ NewImage: {
661
+ PK: { S: 'ROLE#authenticated' },
662
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
663
+ },
664
+ SequenceNumber: '222',
665
+ SizeBytes: 100,
666
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
667
+ },
668
+ },
669
+ ],
670
+ }
671
+
672
+ mockSQSSend.mockResolvedValue({})
673
+ mockEventBridgeSend.mockResolvedValue({})
674
+
675
+ await handler(event)
676
+
677
+ // Should publish 1 ROLE# to SQS and 1 FEATURE# to EventBridge
678
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
679
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
680
+ })
681
+
682
+ it('should parse moduleName and featureId from FEATURE# records', async () => {
683
+ const event: DynamoDBStreamEvent = {
684
+ Records: [
685
+ {
686
+ eventID: '1',
687
+ eventName: 'MODIFY',
688
+ eventVersion: '1.1',
689
+ eventSource: 'aws:dynamodb',
690
+ awsRegion: 'us-east-1',
691
+ dynamodb: {
692
+ Keys: {
693
+ PK: { S: 'MODULE#workflow' },
694
+ SK: { S: 'MODULE#workflow#FEATURE#test456' },
695
+ },
696
+ NewImage: {
697
+ PK: { S: 'MODULE#workflow' },
698
+ SK: { S: 'MODULE#workflow#FEATURE#test456' },
699
+ },
700
+ SequenceNumber: '111',
701
+ SizeBytes: 100,
702
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
703
+ },
704
+ },
705
+ ],
706
+ }
707
+
708
+ mockEventBridgeSend.mockResolvedValue({})
709
+
710
+ await handler(event)
711
+
712
+ expect(mockEventBridgeSend).toHaveBeenCalledWith(
713
+ expect.objectContaining({
714
+ Entries: expect.arrayContaining([
715
+ expect.objectContaining({
716
+ Detail: expect.stringContaining('"moduleName":"workflow"'),
717
+ }),
718
+ ]),
719
+ })
720
+ )
721
+ expect(mockEventBridgeSend).toHaveBeenCalledWith(
722
+ expect.objectContaining({
723
+ Entries: expect.arrayContaining([
724
+ expect.objectContaining({
725
+ Detail: expect.stringContaining('"featureId":"test456"'),
726
+ }),
727
+ ]),
728
+ })
729
+ )
730
+ expect(mockEventBridgeSend).toHaveBeenCalledWith(
731
+ expect.objectContaining({
732
+ Entries: expect.arrayContaining([
733
+ expect.objectContaining({
734
+ Detail: expect.stringContaining('"eventType":"MODIFY"'),
735
+ }),
736
+ ]),
737
+ })
738
+ )
739
+ })
740
+
741
+ it('should ensure ROLE# processing unaffected when FEATURE# records present', async () => {
742
+ const event: DynamoDBStreamEvent = {
743
+ Records: [
744
+ {
745
+ eventID: '1',
746
+ eventName: 'INSERT',
747
+ eventVersion: '1.1',
748
+ eventSource: 'aws:dynamodb',
749
+ awsRegion: 'us-east-1',
750
+ dynamodb: {
751
+ Keys: {
752
+ PK: { S: 'ROLE#authenticated' },
753
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
754
+ },
755
+ NewImage: {
756
+ PK: { S: 'ROLE#authenticated' },
757
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
758
+ },
759
+ SequenceNumber: '111',
760
+ SizeBytes: 100,
761
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
762
+ },
763
+ },
764
+ {
765
+ eventID: '2',
766
+ eventName: 'INSERT',
767
+ eventVersion: '1.1',
768
+ eventSource: 'aws:dynamodb',
769
+ awsRegion: 'us-east-1',
770
+ dynamodb: {
771
+ Keys: {
772
+ PK: { S: 'MODULE#public' },
773
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
774
+ },
775
+ NewImage: {
776
+ PK: { S: 'MODULE#public' },
777
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
778
+ },
779
+ SequenceNumber: '222',
780
+ SizeBytes: 100,
781
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
782
+ },
783
+ },
784
+ ],
785
+ }
786
+
787
+ mockSQSSend.mockResolvedValue({})
788
+ mockEventBridgeSend.mockResolvedValue({})
789
+
790
+ await handler(event)
791
+
792
+ // Verify ROLE# still publishes to SQS
793
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
794
+ expect(mockSQSSend).toHaveBeenCalledWith(
795
+ expect.objectContaining({
796
+ MessageBody: expect.stringContaining('"roleName":"authenticated"'),
797
+ })
798
+ )
799
+
800
+ // Verify FEATURE# publishes to EventBridge
801
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
802
+ })
803
+
804
+ it('should ensure EventBridge failure does not break ROLE# processing', async () => {
805
+ const event: DynamoDBStreamEvent = {
806
+ Records: [
807
+ {
808
+ eventID: '1',
809
+ eventName: 'INSERT',
810
+ eventVersion: '1.1',
811
+ eventSource: 'aws:dynamodb',
812
+ awsRegion: 'us-east-1',
813
+ dynamodb: {
814
+ Keys: {
815
+ PK: { S: 'ROLE#authenticated' },
816
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
817
+ },
818
+ NewImage: {
819
+ PK: { S: 'ROLE#authenticated' },
820
+ SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
821
+ },
822
+ SequenceNumber: '111',
823
+ SizeBytes: 100,
824
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
825
+ },
826
+ },
827
+ {
828
+ eventID: '2',
829
+ eventName: 'INSERT',
830
+ eventVersion: '1.1',
831
+ eventSource: 'aws:dynamodb',
832
+ awsRegion: 'us-east-1',
833
+ dynamodb: {
834
+ Keys: {
835
+ PK: { S: 'MODULE#public' },
836
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
837
+ },
838
+ NewImage: {
839
+ PK: { S: 'MODULE#public' },
840
+ SK: { S: 'MODULE#public#FEATURE#abc123' },
841
+ },
842
+ SequenceNumber: '222',
843
+ SizeBytes: 100,
844
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
845
+ },
846
+ },
847
+ ],
848
+ }
849
+
850
+ mockSQSSend.mockResolvedValue({})
851
+ mockEventBridgeSend.mockRejectedValue(new Error('EventBridge error'))
852
+
853
+ await handler(event)
854
+
855
+ // Verify ROLE# still published successfully despite EventBridge failure
856
+ expect(mockSQSSend).toHaveBeenCalledTimes(1)
857
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
858
+ })
859
+
860
+ it('should handle mixed ROLE# and FEATURE# records in same batch', async () => {
861
+ const event: DynamoDBStreamEvent = {
862
+ Records: [
863
+ {
864
+ eventID: '1',
865
+ eventName: 'INSERT',
866
+ eventVersion: '1.1',
867
+ eventSource: 'aws:dynamodb',
868
+ awsRegion: 'us-east-1',
869
+ dynamodb: {
870
+ Keys: {
871
+ PK: { S: 'ROLE#authenticated' },
872
+ SK: { S: 'MODULE#sign#FEATURE#feat1' },
873
+ },
874
+ NewImage: {
875
+ PK: { S: 'ROLE#authenticated' },
876
+ SK: { S: 'MODULE#sign#FEATURE#feat1' },
877
+ },
878
+ SequenceNumber: '111',
879
+ SizeBytes: 100,
880
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
881
+ },
882
+ },
883
+ {
884
+ eventID: '2',
885
+ eventName: 'INSERT',
886
+ eventVersion: '1.1',
887
+ eventSource: 'aws:dynamodb',
888
+ awsRegion: 'us-east-1',
889
+ dynamodb: {
890
+ Keys: {
891
+ PK: { S: 'MODULE#public' },
892
+ SK: { S: 'MODULE#public#FEATURE#feat2' },
893
+ },
894
+ NewImage: {
895
+ PK: { S: 'MODULE#public' },
896
+ SK: { S: 'MODULE#public#FEATURE#feat2' },
897
+ },
898
+ SequenceNumber: '222',
899
+ SizeBytes: 100,
900
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
901
+ },
902
+ },
903
+ {
904
+ eventID: '3',
905
+ eventName: 'INSERT',
906
+ eventVersion: '1.1',
907
+ eventSource: 'aws:dynamodb',
908
+ awsRegion: 'us-east-1',
909
+ dynamodb: {
910
+ Keys: {
911
+ PK: { S: 'ROLE#unauthenticated' },
912
+ SK: { S: 'MODULE#workflow#FEATURE#feat3' },
913
+ },
914
+ NewImage: {
915
+ PK: { S: 'ROLE#unauthenticated' },
916
+ SK: { S: 'MODULE#workflow#FEATURE#feat3' },
917
+ },
918
+ SequenceNumber: '333',
919
+ SizeBytes: 100,
920
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
921
+ },
922
+ },
923
+ {
924
+ eventID: '4',
925
+ eventName: 'INSERT',
926
+ eventVersion: '1.1',
927
+ eventSource: 'aws:dynamodb',
928
+ awsRegion: 'us-east-1',
929
+ dynamodb: {
930
+ Keys: {
931
+ PK: { S: 'MODULE#sign' },
932
+ SK: { S: 'MODULE#sign#FEATURE#feat4' },
933
+ },
934
+ NewImage: {
935
+ PK: { S: 'MODULE#sign' },
936
+ SK: { S: 'MODULE#sign#FEATURE#feat4' },
937
+ },
938
+ SequenceNumber: '444',
939
+ SizeBytes: 100,
940
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
941
+ },
942
+ },
943
+ ],
944
+ }
945
+
946
+ mockSQSSend.mockResolvedValue({})
947
+ mockEventBridgeSend.mockResolvedValue({})
948
+
949
+ await handler(event)
950
+
951
+ // Should publish 2 ROLE# messages to SQS
952
+ expect(mockSQSSend).toHaveBeenCalledTimes(2)
953
+ // Should publish 2 FEATURE# events to EventBridge (in 1 batch)
954
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
955
+
956
+ // Verify EventBridge batch contains 2 entries
957
+ const eventBridgeCall = mockEventBridgeSend.mock.calls[0][0]
958
+ expect(eventBridgeCall.Entries).toHaveLength(2)
959
+ })
960
+
961
+ it('should handle lowercase pk/sk keys for FEATURE# records', async () => {
962
+ const event: DynamoDBStreamEvent = {
963
+ Records: [
964
+ {
965
+ eventID: '1',
966
+ eventName: 'INSERT',
967
+ eventVersion: '1.1',
968
+ eventSource: 'aws:dynamodb',
969
+ awsRegion: 'us-east-1',
970
+ dynamodb: {
971
+ Keys: {
972
+ pk: { S: 'MODULE#public' },
973
+ sk: { S: 'MODULE#public#FEATURE#abc123' },
974
+ },
975
+ NewImage: {
976
+ pk: { S: 'MODULE#public' },
977
+ sk: { S: 'MODULE#public#FEATURE#abc123' },
978
+ },
979
+ SequenceNumber: '111',
980
+ SizeBytes: 100,
981
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
982
+ },
983
+ },
984
+ ],
985
+ }
986
+
987
+ mockEventBridgeSend.mockResolvedValue({})
988
+
989
+ await handler(event)
990
+
991
+ expect(mockEventBridgeSend).toHaveBeenCalledTimes(1)
992
+ })
993
+ })
994
+ })