@adtrackify/at-service-common 4.0.7 → 4.0.8

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 (69) hide show
  1. package/dist/cjs/__tests__/identity-cache/helpers.spec.d.ts +1 -0
  2. package/dist/cjs/__tests__/identity-cache/helpers.spec.js +594 -0
  3. package/dist/cjs/__tests__/identity-cache/helpers.spec.js.map +1 -0
  4. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.d.ts +1 -0
  5. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.js +556 -0
  6. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.js.map +1 -0
  7. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service-v2-comparison.spec.js +23 -25
  8. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service-v2-comparison.spec.js.map +1 -1
  9. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js +11 -12
  10. package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js.map +1 -1
  11. package/dist/cjs/__tests__/identity-cache/identity-cache-tier-routing.spec.js +64 -21
  12. package/dist/cjs/__tests__/identity-cache/identity-cache-tier-routing.spec.js.map +1 -1
  13. package/dist/cjs/__tests__/identity-cache/identity-gateway-service.spec.js +1617 -14
  14. package/dist/cjs/__tests__/identity-cache/identity-gateway-service.spec.js.map +1 -1
  15. package/dist/cjs/__tests__/identity-cache/trait-merging-and-staleness.spec.js +711 -0
  16. package/dist/cjs/__tests__/identity-cache/trait-merging-and-staleness.spec.js.map +1 -1
  17. package/dist/cjs/helpers/identity-cache-helper.d.ts +9 -0
  18. package/dist/cjs/helpers/identity-cache-helper.js +54 -1
  19. package/dist/cjs/helpers/identity-cache-helper.js.map +1 -1
  20. package/dist/cjs/services/db/identity-cache-dynamodb-service/helpers.d.ts +17 -4
  21. package/dist/cjs/services/db/identity-cache-dynamodb-service/helpers.js +35 -33
  22. package/dist/cjs/services/db/identity-cache-dynamodb-service/helpers.js.map +1 -1
  23. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.d.ts +4 -2
  24. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.js +63 -8
  25. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.js.map +1 -1
  26. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-gateway-service.d.ts +1 -0
  27. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-gateway-service.js +91 -53
  28. package/dist/cjs/services/db/identity-cache-dynamodb-service/identity-gateway-service.js.map +1 -1
  29. package/dist/cjs/services/db/identity-cache-dynamodb-service/index.d.ts +1 -0
  30. package/dist/cjs/services/db/identity-cache-dynamodb-service/index.js +1 -0
  31. package/dist/cjs/services/db/identity-cache-dynamodb-service/index.js.map +1 -1
  32. package/dist/cjs/services/db/identity-cache-dynamodb-service/types.d.ts +16 -0
  33. package/dist/cjs/services/db/identity-cache-dynamodb-service/types.js +9 -1
  34. package/dist/cjs/services/db/identity-cache-dynamodb-service/types.js.map +1 -1
  35. package/dist/esm/__tests__/identity-cache/helpers.spec.d.ts +1 -0
  36. package/dist/esm/__tests__/identity-cache/helpers.spec.js +592 -0
  37. package/dist/esm/__tests__/identity-cache/helpers.spec.js.map +1 -0
  38. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.d.ts +1 -0
  39. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.js +554 -0
  40. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-db-service.spec.js.map +1 -0
  41. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service-v2-comparison.spec.js +23 -25
  42. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service-v2-comparison.spec.js.map +1 -1
  43. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js +11 -12
  44. package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js.map +1 -1
  45. package/dist/esm/__tests__/identity-cache/identity-cache-tier-routing.spec.js +65 -22
  46. package/dist/esm/__tests__/identity-cache/identity-cache-tier-routing.spec.js.map +1 -1
  47. package/dist/esm/__tests__/identity-cache/identity-gateway-service.spec.js +1617 -14
  48. package/dist/esm/__tests__/identity-cache/identity-gateway-service.spec.js.map +1 -1
  49. package/dist/esm/__tests__/identity-cache/trait-merging-and-staleness.spec.js +712 -1
  50. package/dist/esm/__tests__/identity-cache/trait-merging-and-staleness.spec.js.map +1 -1
  51. package/dist/esm/helpers/identity-cache-helper.d.ts +9 -0
  52. package/dist/esm/helpers/identity-cache-helper.js +52 -1
  53. package/dist/esm/helpers/identity-cache-helper.js.map +1 -1
  54. package/dist/esm/services/db/identity-cache-dynamodb-service/helpers.d.ts +17 -4
  55. package/dist/esm/services/db/identity-cache-dynamodb-service/helpers.js +33 -31
  56. package/dist/esm/services/db/identity-cache-dynamodb-service/helpers.js.map +1 -1
  57. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.d.ts +4 -2
  58. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.js +57 -2
  59. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-cache-dynamodb-db-service.js.map +1 -1
  60. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-gateway-service.d.ts +1 -0
  61. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-gateway-service.js +92 -54
  62. package/dist/esm/services/db/identity-cache-dynamodb-service/identity-gateway-service.js.map +1 -1
  63. package/dist/esm/services/db/identity-cache-dynamodb-service/index.d.ts +1 -0
  64. package/dist/esm/services/db/identity-cache-dynamodb-service/index.js +1 -0
  65. package/dist/esm/services/db/identity-cache-dynamodb-service/index.js.map +1 -1
  66. package/dist/esm/services/db/identity-cache-dynamodb-service/types.d.ts +16 -0
  67. package/dist/esm/services/db/identity-cache-dynamodb-service/types.js +8 -0
  68. package/dist/esm/services/db/identity-cache-dynamodb-service/types.js.map +1 -1
  69. package/package.json +1 -1
@@ -288,8 +288,8 @@ describe('IdentityGatewayService new features', () => {
288
288
  }),
289
289
  });
290
290
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
291
- expect(result.summary.didWriteCache).toBe(true);
292
- expect(mockedDynamoDbClient.safeBatchWrite).toHaveBeenCalled();
291
+ expect(result.summary.didWriteCache).toBe(false);
292
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
293
293
  });
294
294
  });
295
295
  describe('skipCacheWrite: false', () => {
@@ -305,8 +305,8 @@ describe('IdentityGatewayService new features', () => {
305
305
  }),
306
306
  });
307
307
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn, { skipCacheWrite: false });
308
- expect(result.summary.didWriteCache).toBe(true);
309
- expect(mockedDynamoDbClient.safeBatchWrite).toHaveBeenCalled();
308
+ expect(result.summary.didWriteCache).toBe(false);
309
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
310
310
  });
311
311
  });
312
312
  describe('skipCacheWrite: true', () => {
@@ -324,10 +324,16 @@ describe('IdentityGatewayService new features', () => {
324
324
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn, { skipCacheWrite: true });
325
325
  expect(result.identity?.identityId).toBe('resolved-identity');
326
326
  expect(result.summary.didWriteCache).toBe(false);
327
- expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_WRITE_BACK);
327
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
328
328
  expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
329
329
  });
330
- it('should NOT write to cache on address update path', async () => {
330
+ it('should enqueue async update on address update path (never writes sync)', async () => {
331
+ IdentityGatewayService.configure({
332
+ awsRegion: 'us-east-1',
333
+ awsAccountId: '123456789',
334
+ asyncUpdateQueueName: 'test-queue',
335
+ });
336
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'test-msg-id' });
331
337
  const cachedResponse = {
332
338
  pk: `identity#${pixelId}#identity456`,
333
339
  pixelId,
@@ -350,9 +356,11 @@ describe('IdentityGatewayService new features', () => {
350
356
  },
351
357
  };
352
358
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, identityWithNewAddress, lambdaArn, { skipCacheWrite: true });
353
- if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_WRITE) {
359
+ if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC) {
354
360
  expect(result.summary.didWriteCache).toBe(false);
361
+ expect(result.summary.didEnqueueAsync).toBe(true);
355
362
  expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
363
+ expect(mockBuildAndSendMessage).toHaveBeenCalled();
356
364
  }
357
365
  });
358
366
  it('should still return resolved identity when skipCacheWrite is true', async () => {
@@ -390,8 +398,14 @@ describe('IdentityGatewayService new features', () => {
390
398
  expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
391
399
  });
392
400
  });
393
- describe('address update path with skipCacheWrite', () => {
394
- it('should write cache when skipCacheWrite is false and address update triggers write', async () => {
401
+ describe('address update path - always enqueues async (never writes sync)', () => {
402
+ it('should enqueue async (not write cache) when address update is detected', async () => {
403
+ IdentityGatewayService.configure({
404
+ awsRegion: 'us-east-1',
405
+ awsAccountId: '123456789',
406
+ asyncUpdateQueueName: 'test-queue',
407
+ });
408
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'test-msg-id' });
395
409
  const cachedResponse = {
396
410
  pk: `identity#${pixelId}#identity456`,
397
411
  pixelId,
@@ -414,11 +428,84 @@ describe('IdentityGatewayService new features', () => {
414
428
  },
415
429
  };
416
430
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, identityWithNewAddress, lambdaArn, { skipCacheWrite: false });
417
- if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_WRITE) {
418
- expect(result.summary.didWriteCache).toBe(true);
419
- expect(mockedDynamoDbClient.safeBatchWrite).toHaveBeenCalled();
431
+ if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC) {
432
+ expect(result.summary.didWriteCache).toBe(false);
433
+ expect(result.summary.didEnqueueAsync).toBe(true);
434
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
435
+ expect(mockBuildAndSendMessage).toHaveBeenCalledWith('identify.updateAsync', expect.objectContaining({
436
+ pixelId,
437
+ incomingIdentityId: 'identity456',
438
+ }));
420
439
  }
421
440
  });
441
+ it('should return merged identity without waiting for cache write', async () => {
442
+ IdentityGatewayService.configure({
443
+ awsRegion: 'us-east-1',
444
+ awsAccountId: '123456789',
445
+ asyncUpdateQueueName: 'test-queue',
446
+ });
447
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'test-msg-id' });
448
+ const cachedResponse = {
449
+ pk: `identity#${pixelId}#identity456`,
450
+ pixelId,
451
+ identityId: 'identity456',
452
+ response: {
453
+ identityId: 'identity456',
454
+ traits: {
455
+ emails: ['test@email.com'],
456
+ addresses: [],
457
+ },
458
+ },
459
+ updatedAt: new Date().toISOString(),
460
+ };
461
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
462
+ const identityWithNewAddress = {
463
+ identityId: 'identity456',
464
+ traits: {
465
+ emails: ['test@email.com'],
466
+ addresses: [{ city: 'New City', firstName: 'John' }],
467
+ },
468
+ };
469
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, identityWithNewAddress, lambdaArn);
470
+ expect(result.identity).toBeDefined();
471
+ expect(result.identity?.identityId).toBe('identity456');
472
+ expect(result.summary.didSkipNeptune).toBe(true);
473
+ expect(result.summary.didCallNeptune).toBe(false);
474
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
475
+ });
476
+ it('should set didEnqueueAsync to false when SQS enqueue fails', async () => {
477
+ IdentityGatewayService.configure({
478
+ awsRegion: 'us-east-1',
479
+ awsAccountId: '123456789',
480
+ asyncUpdateQueueName: 'test-queue',
481
+ });
482
+ mockBuildAndSendMessage.mockRejectedValueOnce(new Error('SQS error'));
483
+ const cachedResponse = {
484
+ pk: `identity#${pixelId}#identity456`,
485
+ pixelId,
486
+ identityId: 'identity456',
487
+ response: {
488
+ identityId: 'identity456',
489
+ traits: {
490
+ emails: ['test@email.com'],
491
+ addresses: [],
492
+ },
493
+ },
494
+ updatedAt: new Date().toISOString(),
495
+ };
496
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
497
+ const identityWithNewAddress = {
498
+ identityId: 'identity456',
499
+ traits: {
500
+ emails: ['test@email.com'],
501
+ addresses: [{ city: 'New City', firstName: 'John' }],
502
+ },
503
+ };
504
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, identityWithNewAddress, lambdaArn);
505
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
506
+ expect(result.identity).toBeDefined();
507
+ expect(result.summary.didEnqueueAsync).toBe(false);
508
+ });
422
509
  });
423
510
  });
424
511
  describe('edge cases', () => {
@@ -506,7 +593,7 @@ describe('IdentityGatewayService new features', () => {
506
593
  }),
507
594
  });
508
595
  await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'original-id', traits: { emails: ['test@email.com'] } }, lambdaArn, { skipCacheWrite: false });
509
- expect(mockedDynamoDbClient.safeBatchWrite).toHaveBeenCalled();
596
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
510
597
  });
511
598
  });
512
599
  describe('summary fields consistency', () => {
@@ -531,6 +618,7 @@ describe('IdentityGatewayService new features', () => {
531
618
  didWriteCache: false,
532
619
  didSkipNeptune: false,
533
620
  didFallbackToIncoming: false,
621
+ didEnqueueAsync: false,
534
622
  staleTraits: expect.any(Array),
535
623
  }));
536
624
  });
@@ -556,10 +644,1525 @@ describe('IdentityGatewayService new features', () => {
556
644
  }),
557
645
  });
558
646
  const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'identity456', traits: { emails: ['old@email.com', 'new@email.com'] } }, lambdaArn);
559
- if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_WRITE_BACK) {
647
+ if (result.summary.outcome === IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED) {
560
648
  expect(result.summary.traitChanges).toBeDefined();
561
649
  }
562
650
  });
563
651
  });
652
+ describe('ADDRESS_UPDATE async behavior (comprehensive)', () => {
653
+ describe('ADDRESS_UPDATE enqueues async (not sync write)', () => {
654
+ it('should enqueue async when staleness tier is ADDRESS_UPDATE with non-IP address', async () => {
655
+ IdentityGatewayService.configure({
656
+ awsRegion: 'us-east-1',
657
+ awsAccountId: '123456789',
658
+ asyncUpdateQueueName: 'identity-async-queue',
659
+ });
660
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'async-msg-123' });
661
+ const cachedResponse = {
662
+ pk: `identity#${pixelId}#identity456`,
663
+ pixelId,
664
+ identityId: 'identity456',
665
+ response: {
666
+ identityId: 'identity456',
667
+ traits: {
668
+ emails: ['test@email.com'],
669
+ addresses: [],
670
+ },
671
+ },
672
+ updatedAt: new Date().toISOString(),
673
+ };
674
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
675
+ const incomingWithAddress = {
676
+ identityId: 'identity456',
677
+ traits: {
678
+ emails: ['test@email.com'],
679
+ addresses: [{ firstName: 'John', lastName: 'Doe', city: 'New York' }],
680
+ },
681
+ };
682
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
683
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
684
+ expect(mockBuildAndSendMessage).toHaveBeenCalledWith('identify.updateAsync', expect.objectContaining({
685
+ pixelId,
686
+ incomingIdentityId: 'identity456',
687
+ }));
688
+ expect(result.summary.didEnqueueAsync).toBe(true);
689
+ expect(result.summary.didWriteCache).toBe(false);
690
+ expect(result.summary.didCallNeptune).toBe(false);
691
+ expect(result.summary.didSkipNeptune).toBe(true);
692
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
693
+ expect(mockInvokeFunction).not.toHaveBeenCalled();
694
+ });
695
+ it('should return merged identity with updated addresses', async () => {
696
+ IdentityGatewayService.configure({
697
+ awsRegion: 'us-east-1',
698
+ awsAccountId: '123456789',
699
+ asyncUpdateQueueName: 'identity-async-queue',
700
+ });
701
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'msg-456' });
702
+ const cachedResponse = {
703
+ pk: `identity#${pixelId}#identity456`,
704
+ pixelId,
705
+ identityId: 'identity456',
706
+ response: {
707
+ identityId: 'identity456',
708
+ traits: {
709
+ emails: ['test@email.com'],
710
+ phones: ['555-1234'],
711
+ addresses: [],
712
+ },
713
+ },
714
+ updatedAt: new Date().toISOString(),
715
+ };
716
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
717
+ const incomingWithAddress = {
718
+ identityId: 'identity456',
719
+ traits: {
720
+ emails: ['test@email.com'],
721
+ addresses: [{ firstName: 'Jane', city: 'Boston', zip: '02101' }],
722
+ },
723
+ };
724
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
725
+ expect(result.identity).toBeDefined();
726
+ expect(result.identity?.identityId).toBe('identity456');
727
+ expect(result.identity?.traits?.emails).toContain('test@email.com');
728
+ expect(result.identity?.traits?.addresses).toHaveLength(1);
729
+ expect(result.identity?.traits?.addresses?.[0]?.firstName).toBe('Jane');
730
+ });
731
+ it('should include writeReason ADDRESS_UPDATE in summary', async () => {
732
+ IdentityGatewayService.configure({
733
+ awsRegion: 'us-east-1',
734
+ awsAccountId: '123456789',
735
+ asyncUpdateQueueName: 'identity-async-queue',
736
+ });
737
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'msg-789' });
738
+ const cachedResponse = {
739
+ pk: `identity#${pixelId}#identity456`,
740
+ pixelId,
741
+ identityId: 'identity456',
742
+ response: {
743
+ identityId: 'identity456',
744
+ traits: {
745
+ emails: ['test@email.com'],
746
+ addresses: [],
747
+ },
748
+ },
749
+ updatedAt: new Date().toISOString(),
750
+ };
751
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
752
+ const incomingWithAddress = {
753
+ identityId: 'identity456',
754
+ traits: {
755
+ emails: ['test@email.com'],
756
+ addresses: [{ firstName: 'Bob', city: 'Seattle' }],
757
+ },
758
+ };
759
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
760
+ expect(result.summary.writeReason).toBe('address_update');
761
+ });
762
+ it('should include traitChanges for address update', async () => {
763
+ IdentityGatewayService.configure({
764
+ awsRegion: 'us-east-1',
765
+ awsAccountId: '123456789',
766
+ asyncUpdateQueueName: 'identity-async-queue',
767
+ });
768
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'msg-trait' });
769
+ const cachedResponse = {
770
+ pk: `identity#${pixelId}#identity456`,
771
+ pixelId,
772
+ identityId: 'identity456',
773
+ response: {
774
+ identityId: 'identity456',
775
+ traits: {
776
+ emails: ['test@email.com'],
777
+ addresses: [],
778
+ },
779
+ },
780
+ updatedAt: new Date().toISOString(),
781
+ };
782
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
783
+ const incomingWithAddress = {
784
+ identityId: 'identity456',
785
+ traits: {
786
+ emails: ['test@email.com'],
787
+ addresses: [{ firstName: 'Alice', city: 'Chicago' }],
788
+ },
789
+ };
790
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
791
+ expect(result.summary.traitChanges).toBeDefined();
792
+ });
793
+ });
794
+ describe('ADDRESS_UPDATE async failure handling', () => {
795
+ it('should set didEnqueueAsync to false when enqueue fails', async () => {
796
+ IdentityGatewayService.configure({
797
+ awsRegion: 'us-east-1',
798
+ awsAccountId: '123456789',
799
+ asyncUpdateQueueName: 'identity-async-queue',
800
+ });
801
+ mockBuildAndSendMessage.mockRejectedValueOnce(new Error('SQS unavailable'));
802
+ const cachedResponse = {
803
+ pk: `identity#${pixelId}#identity456`,
804
+ pixelId,
805
+ identityId: 'identity456',
806
+ response: {
807
+ identityId: 'identity456',
808
+ traits: {
809
+ emails: ['test@email.com'],
810
+ addresses: [],
811
+ },
812
+ },
813
+ updatedAt: new Date().toISOString(),
814
+ };
815
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
816
+ const incomingWithAddress = {
817
+ identityId: 'identity456',
818
+ traits: {
819
+ emails: ['test@email.com'],
820
+ addresses: [{ firstName: 'Test', city: 'TestCity' }],
821
+ },
822
+ };
823
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
824
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
825
+ expect(result.summary.didEnqueueAsync).toBe(false);
826
+ expect(result.identity).toBeDefined();
827
+ expect(result.identity?.identityId).toBe('identity456');
828
+ });
829
+ it('should log warning when enqueue fails', async () => {
830
+ IdentityGatewayService.configure({
831
+ awsRegion: 'us-east-1',
832
+ awsAccountId: '123456789',
833
+ asyncUpdateQueueName: 'identity-async-queue',
834
+ });
835
+ mockBuildAndSendMessage.mockRejectedValueOnce(new Error('Network timeout'));
836
+ const cachedResponse = {
837
+ pk: `identity#${pixelId}#identity456`,
838
+ pixelId,
839
+ identityId: 'identity456',
840
+ response: {
841
+ identityId: 'identity456',
842
+ traits: {
843
+ emails: ['test@email.com'],
844
+ addresses: [],
845
+ },
846
+ },
847
+ updatedAt: new Date().toISOString(),
848
+ };
849
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
850
+ const incomingWithAddress = {
851
+ identityId: 'identity456',
852
+ traits: {
853
+ emails: ['test@email.com'],
854
+ addresses: [{ firstName: 'Test', city: 'TestCity' }],
855
+ },
856
+ };
857
+ await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
858
+ expect(mockedLogger.warn).toHaveBeenCalledWith('IdentityGateway - enqueueAsyncIdentityUpdate: failed to enqueue (failing open)', expect.objectContaining({
859
+ error: expect.any(Error),
860
+ errorMessage: 'Network timeout',
861
+ pixelId,
862
+ }));
863
+ });
864
+ it('should set didEnqueueAsync to false when SQS client is not configured', async () => {
865
+ const cachedResponse = {
866
+ pk: `identity#${pixelId}#identity456`,
867
+ pixelId,
868
+ identityId: 'identity456',
869
+ response: {
870
+ identityId: 'identity456',
871
+ traits: {
872
+ emails: ['test@email.com'],
873
+ addresses: [],
874
+ },
875
+ },
876
+ updatedAt: new Date().toISOString(),
877
+ };
878
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
879
+ const incomingWithAddress = {
880
+ identityId: 'identity456',
881
+ traits: {
882
+ emails: ['test@email.com'],
883
+ addresses: [{ firstName: 'Test', city: 'TestCity' }],
884
+ },
885
+ };
886
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
887
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
888
+ expect(result.summary.didEnqueueAsync).toBe(false);
889
+ expect(result.identity).toBeDefined();
890
+ });
891
+ });
892
+ describe('IDENTITY_STALE path (comparison with ADDRESS_UPDATE)', () => {
893
+ it('should call Neptune when staleness tier is IDENTITY_STALE (not ADDRESS_UPDATE)', async () => {
894
+ IdentityGatewayService.configure({
895
+ awsRegion: 'us-east-1',
896
+ awsAccountId: '123456789',
897
+ asyncUpdateQueueName: 'identity-async-queue',
898
+ });
899
+ const cachedResponse = {
900
+ pk: `identity#${pixelId}#identity456`,
901
+ pixelId,
902
+ identityId: 'identity456',
903
+ response: {
904
+ identityId: 'identity456',
905
+ traits: {
906
+ emails: ['old@email.com'],
907
+ },
908
+ },
909
+ updatedAt: new Date().toISOString(),
910
+ };
911
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
912
+ mockInvokeFunction.mockResolvedValueOnce({
913
+ statusCode: 200,
914
+ body: JSON.stringify({
915
+ identity: {
916
+ identityId: 'identity456',
917
+ traits: {
918
+ emails: ['old@email.com', 'new@email.com'],
919
+ },
920
+ },
921
+ }),
922
+ });
923
+ const incomingWithNewEmail = {
924
+ identityId: 'identity456',
925
+ traits: {
926
+ emails: ['old@email.com', 'new@email.com'],
927
+ },
928
+ };
929
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithNewEmail, lambdaArn);
930
+ expect(mockInvokeFunction).toHaveBeenCalled();
931
+ expect(result.summary.didCallNeptune).toBe(true);
932
+ expect(result.summary.didSkipNeptune).toBe(false);
933
+ expect(mockBuildAndSendMessage).not.toHaveBeenCalled();
934
+ expect(result.summary.didEnqueueAsync).toBe(false);
935
+ expect(result.summary.outcome).not.toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
936
+ });
937
+ it('should NOT call Neptune when staleness tier is ADDRESS_UPDATE', async () => {
938
+ IdentityGatewayService.configure({
939
+ awsRegion: 'us-east-1',
940
+ awsAccountId: '123456789',
941
+ asyncUpdateQueueName: 'identity-async-queue',
942
+ });
943
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'msg-no-neptune' });
944
+ const cachedResponse = {
945
+ pk: `identity#${pixelId}#identity456`,
946
+ pixelId,
947
+ identityId: 'identity456',
948
+ response: {
949
+ identityId: 'identity456',
950
+ traits: {
951
+ emails: ['test@email.com'],
952
+ addresses: [],
953
+ },
954
+ },
955
+ updatedAt: new Date().toISOString(),
956
+ };
957
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
958
+ const incomingWithAddress = {
959
+ identityId: 'identity456',
960
+ traits: {
961
+ emails: ['test@email.com'],
962
+ addresses: [{ firstName: 'Test', city: 'TestCity' }],
963
+ },
964
+ };
965
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
966
+ expect(mockInvokeFunction).not.toHaveBeenCalled();
967
+ expect(result.summary.didCallNeptune).toBe(false);
968
+ expect(result.summary.didSkipNeptune).toBe(true);
969
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
970
+ });
971
+ it('should write cache from Neptune in IDENTITY_STALE path (identity-service handles it)', async () => {
972
+ const cachedResponse = {
973
+ pk: `identity#${pixelId}#identity456`,
974
+ pixelId,
975
+ identityId: 'identity456',
976
+ response: {
977
+ identityId: 'identity456',
978
+ traits: {
979
+ emails: ['old@email.com'],
980
+ },
981
+ },
982
+ updatedAt: new Date().toISOString(),
983
+ };
984
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
985
+ mockInvokeFunction.mockResolvedValueOnce({
986
+ statusCode: 200,
987
+ body: JSON.stringify({
988
+ identity: {
989
+ identityId: 'identity456',
990
+ traits: {
991
+ emails: ['old@email.com', 'new@email.com'],
992
+ },
993
+ },
994
+ }),
995
+ });
996
+ const incomingWithNewEmail = {
997
+ identityId: 'identity456',
998
+ traits: {
999
+ emails: ['old@email.com', 'new@email.com'],
1000
+ },
1001
+ };
1002
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithNewEmail, lambdaArn);
1003
+ expect(result.summary.didCallNeptune).toBe(true);
1004
+ expect(result.summary.didWriteCache).toBe(false);
1005
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1006
+ });
1007
+ });
1008
+ describe('summary correctness for ADDRESS_UPDATE path', () => {
1009
+ it('should have correct summary fields for successful ADDRESS_UPDATE async enqueue', async () => {
1010
+ IdentityGatewayService.configure({
1011
+ awsRegion: 'us-east-1',
1012
+ awsAccountId: '123456789',
1013
+ asyncUpdateQueueName: 'identity-async-queue',
1014
+ });
1015
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'summary-test-msg' });
1016
+ const cachedResponse = {
1017
+ pk: `identity#${pixelId}#identity456`,
1018
+ pixelId,
1019
+ identityId: 'identity456',
1020
+ response: {
1021
+ identityId: 'identity456',
1022
+ traits: {
1023
+ emails: ['test@email.com'],
1024
+ addresses: [],
1025
+ },
1026
+ },
1027
+ updatedAt: new Date().toISOString(),
1028
+ };
1029
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1030
+ const incomingWithAddress = {
1031
+ identityId: 'identity456',
1032
+ traits: {
1033
+ emails: ['test@email.com'],
1034
+ addresses: [{ firstName: 'Summary', city: 'SummaryCity' }],
1035
+ },
1036
+ };
1037
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
1038
+ expect(result.summary).toEqual(expect.objectContaining({
1039
+ outcome: IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC,
1040
+ pixelId,
1041
+ identityId: 'identity456',
1042
+ didReadCache: true,
1043
+ didCallNeptune: false,
1044
+ didWriteCache: false,
1045
+ didSkipNeptune: true,
1046
+ didFallbackToIncoming: false,
1047
+ didEnqueueAsync: true,
1048
+ writeReason: 'address_update',
1049
+ }));
1050
+ });
1051
+ it('should have correct summary fields for failed ADDRESS_UPDATE async enqueue', async () => {
1052
+ IdentityGatewayService.configure({
1053
+ awsRegion: 'us-east-1',
1054
+ awsAccountId: '123456789',
1055
+ asyncUpdateQueueName: 'identity-async-queue',
1056
+ });
1057
+ mockBuildAndSendMessage.mockRejectedValueOnce(new Error('Enqueue failed'));
1058
+ const cachedResponse = {
1059
+ pk: `identity#${pixelId}#identity456`,
1060
+ pixelId,
1061
+ identityId: 'identity456',
1062
+ response: {
1063
+ identityId: 'identity456',
1064
+ traits: {
1065
+ emails: ['test@email.com'],
1066
+ addresses: [],
1067
+ },
1068
+ },
1069
+ updatedAt: new Date().toISOString(),
1070
+ };
1071
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1072
+ const incomingWithAddress = {
1073
+ identityId: 'identity456',
1074
+ traits: {
1075
+ emails: ['test@email.com'],
1076
+ addresses: [{ firstName: 'Fail', city: 'FailCity' }],
1077
+ },
1078
+ };
1079
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithAddress, lambdaArn);
1080
+ expect(result.summary).toEqual(expect.objectContaining({
1081
+ outcome: IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC,
1082
+ pixelId,
1083
+ identityId: 'identity456',
1084
+ didReadCache: true,
1085
+ didCallNeptune: false,
1086
+ didWriteCache: false,
1087
+ didSkipNeptune: true,
1088
+ didFallbackToIncoming: false,
1089
+ didEnqueueAsync: false,
1090
+ writeReason: 'address_update',
1091
+ }));
1092
+ });
1093
+ });
1094
+ describe('IP-sourced vs non-IP address behavior', () => {
1095
+ it('should NOT trigger ADDRESS_UPDATE for IP-sourced addresses (VOLATILE_ONLY tier)', async () => {
1096
+ IdentityGatewayService.configure({
1097
+ awsRegion: 'us-east-1',
1098
+ awsAccountId: '123456789',
1099
+ asyncUpdateQueueName: 'identity-async-queue',
1100
+ });
1101
+ const cachedResponse = {
1102
+ pk: `identity#${pixelId}#identity456`,
1103
+ pixelId,
1104
+ identityId: 'identity456',
1105
+ response: {
1106
+ identityId: 'identity456',
1107
+ traits: {
1108
+ emails: ['test@email.com'],
1109
+ addresses: [],
1110
+ },
1111
+ },
1112
+ updatedAt: new Date().toISOString(),
1113
+ };
1114
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1115
+ const incomingWithIpAddress = {
1116
+ identityId: 'identity456',
1117
+ traits: {
1118
+ emails: ['test@email.com'],
1119
+ addresses: [{ city: 'IPCity', country: 'US' }],
1120
+ },
1121
+ };
1122
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithIpAddress, lambdaArn);
1123
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.VOLATILE_ONLY_CACHE_HIT);
1124
+ expect(result.summary.didEnqueueAsync).toBe(false);
1125
+ expect(mockBuildAndSendMessage).not.toHaveBeenCalled();
1126
+ });
1127
+ it('should trigger ADDRESS_UPDATE for non-IP address (has PII fields)', async () => {
1128
+ IdentityGatewayService.configure({
1129
+ awsRegion: 'us-east-1',
1130
+ awsAccountId: '123456789',
1131
+ asyncUpdateQueueName: 'identity-async-queue',
1132
+ });
1133
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'non-ip-msg' });
1134
+ const cachedResponse = {
1135
+ pk: `identity#${pixelId}#identity456`,
1136
+ pixelId,
1137
+ identityId: 'identity456',
1138
+ response: {
1139
+ identityId: 'identity456',
1140
+ traits: {
1141
+ emails: ['test@email.com'],
1142
+ addresses: [],
1143
+ },
1144
+ },
1145
+ updatedAt: new Date().toISOString(),
1146
+ };
1147
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1148
+ const incomingWithNonIpAddress = {
1149
+ identityId: 'identity456',
1150
+ traits: {
1151
+ emails: ['test@email.com'],
1152
+ addresses: [{ firstName: 'John', city: 'NonIPCity' }],
1153
+ },
1154
+ };
1155
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingWithNonIpAddress, lambdaArn);
1156
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
1157
+ expect(result.summary.didEnqueueAsync).toBe(true);
1158
+ expect(mockBuildAndSendMessage).toHaveBeenCalled();
1159
+ });
1160
+ });
1161
+ });
1162
+ describe('refactored flow: merge and staleness computation', () => {
1163
+ describe('mergeAndComputeIdentityStaleness behavior', () => {
1164
+ it('should merge incoming traits with cached traits on cache hit', async () => {
1165
+ const cachedResponse = {
1166
+ pk: `identity#${pixelId}#merge-test`,
1167
+ pixelId,
1168
+ identityId: 'merge-test',
1169
+ response: {
1170
+ identityId: 'merge-test',
1171
+ traits: {
1172
+ emails: ['cached@email.com'],
1173
+ phones: ['555-cached'],
1174
+ },
1175
+ },
1176
+ updatedAt: new Date().toISOString(),
1177
+ };
1178
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1179
+ const incomingIdentity = {
1180
+ identityId: 'merge-test',
1181
+ traits: {
1182
+ emails: ['cached@email.com'],
1183
+ },
1184
+ };
1185
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1186
+ expect(result.identity?.traits?.emails).toContain('cached@email.com');
1187
+ expect(result.identity?.traits?.phones).toContain('555-cached');
1188
+ });
1189
+ it('should compute staleness after cache hit', async () => {
1190
+ const cachedResponse = {
1191
+ pk: `identity#${pixelId}#staleness-compute`,
1192
+ pixelId,
1193
+ identityId: 'staleness-compute',
1194
+ response: {
1195
+ identityId: 'staleness-compute',
1196
+ traits: {
1197
+ emails: ['old@email.com'],
1198
+ },
1199
+ },
1200
+ updatedAt: new Date().toISOString(),
1201
+ };
1202
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1203
+ mockInvokeFunction.mockResolvedValueOnce({
1204
+ statusCode: 200,
1205
+ body: JSON.stringify({
1206
+ identity: {
1207
+ identityId: 'staleness-compute',
1208
+ traits: { emails: ['old@email.com', 'new@email.com'] },
1209
+ },
1210
+ }),
1211
+ });
1212
+ const incomingIdentity = {
1213
+ identityId: 'staleness-compute',
1214
+ traits: {
1215
+ emails: ['old@email.com', 'new@email.com'],
1216
+ },
1217
+ };
1218
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1219
+ expect(result.summary.didCallNeptune).toBe(true);
1220
+ expect(result.summary.staleTraits).toContain('emails');
1221
+ });
1222
+ });
1223
+ describe('deep equality check', () => {
1224
+ it('should treat as cache hit when merged identity is deep equal to cached', async () => {
1225
+ const cachedResponse = {
1226
+ pk: `identity#${pixelId}#deep-equal`,
1227
+ pixelId,
1228
+ identityId: 'deep-equal',
1229
+ response: {
1230
+ identityId: 'deep-equal',
1231
+ traits: {
1232
+ emails: ['same@email.com'],
1233
+ phones: ['555-1234'],
1234
+ },
1235
+ },
1236
+ updatedAt: new Date().toISOString(),
1237
+ };
1238
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1239
+ const incomingIdentity = {
1240
+ identityId: 'deep-equal',
1241
+ traits: {
1242
+ emails: ['same@email.com'],
1243
+ phones: ['555-1234'],
1244
+ },
1245
+ };
1246
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1247
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.FRESH_CACHE_HIT);
1248
+ expect(result.summary.didCallNeptune).toBe(false);
1249
+ expect(mockInvokeFunction).not.toHaveBeenCalled();
1250
+ });
1251
+ it('should strip address timestamps before deep equality comparison', async () => {
1252
+ const now = Date.now();
1253
+ const cachedResponse = {
1254
+ pk: `identity#${pixelId}#timestamp-strip`,
1255
+ pixelId,
1256
+ identityId: 'timestamp-strip',
1257
+ response: {
1258
+ identityId: 'timestamp-strip',
1259
+ traits: {
1260
+ emails: ['test@email.com'],
1261
+ addresses: [{ city: 'NYC', cra: now - 10000, upa: now - 5000 }],
1262
+ },
1263
+ },
1264
+ updatedAt: new Date().toISOString(),
1265
+ };
1266
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1267
+ const incomingIdentity = {
1268
+ identityId: 'timestamp-strip',
1269
+ traits: {
1270
+ emails: ['test@email.com'],
1271
+ addresses: [{ city: 'NYC', cra: now, upa: now }],
1272
+ },
1273
+ };
1274
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1275
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.FRESH_CACHE_HIT);
1276
+ expect(result.summary.didCallNeptune).toBe(false);
1277
+ });
1278
+ });
1279
+ describe('volatile-only override', () => {
1280
+ it('should treat VOLATILE_ONLY + not deep equal as cache hit', async () => {
1281
+ const cachedResponse = {
1282
+ pk: `identity#${pixelId}#volatile-override`,
1283
+ pixelId,
1284
+ identityId: 'volatile-override',
1285
+ response: {
1286
+ identityId: 'volatile-override',
1287
+ traits: {
1288
+ emails: ['same@email.com'],
1289
+ },
1290
+ },
1291
+ updatedAt: new Date().toISOString(),
1292
+ };
1293
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1294
+ const incomingIdentity = {
1295
+ identityId: 'volatile-override',
1296
+ traits: {
1297
+ emails: ['same@email.com'],
1298
+ ipAddress: '10.0.0.1',
1299
+ userAgent: 'Mozilla/5.0 (New)',
1300
+ },
1301
+ };
1302
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1303
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.VOLATILE_ONLY_CACHE_HIT);
1304
+ expect(result.summary.didCallNeptune).toBe(false);
1305
+ expect(result.summary.didWriteCache).toBe(false);
1306
+ });
1307
+ it('should return merged identity for VOLATILE_ONLY tier', async () => {
1308
+ const cachedResponse = {
1309
+ pk: `identity#${pixelId}#volatile-merged`,
1310
+ pixelId,
1311
+ identityId: 'volatile-merged',
1312
+ response: {
1313
+ identityId: 'volatile-merged',
1314
+ traits: {
1315
+ emails: ['test@email.com'],
1316
+ phones: ['555-cached'],
1317
+ },
1318
+ },
1319
+ updatedAt: new Date().toISOString(),
1320
+ };
1321
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1322
+ const incomingIdentity = {
1323
+ identityId: 'volatile-merged',
1324
+ traits: {
1325
+ emails: ['test@email.com'],
1326
+ ipAddress: '10.0.0.1',
1327
+ },
1328
+ };
1329
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1330
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.VOLATILE_ONLY_CACHE_HIT);
1331
+ expect(result.identity?.traits?.emails).toContain('test@email.com');
1332
+ expect(result.identity?.traits?.phones).toContain('555-cached');
1333
+ expect(result.identity?.traits?.ipAddress).toBe('10.0.0.1');
1334
+ });
1335
+ });
1336
+ describe('skipNeptune derivation from staleTier', () => {
1337
+ it('should set skipNeptune=true for ADDRESS_UPDATE tier', async () => {
1338
+ IdentityGatewayService.configure({
1339
+ awsRegion: 'us-east-1',
1340
+ awsAccountId: '123456789',
1341
+ asyncUpdateQueueName: 'identity-async-queue',
1342
+ });
1343
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'skip-neptune-test' });
1344
+ const cachedResponse = {
1345
+ pk: `identity#${pixelId}#skip-neptune-addr`,
1346
+ pixelId,
1347
+ identityId: 'skip-neptune-addr',
1348
+ response: {
1349
+ identityId: 'skip-neptune-addr',
1350
+ traits: {
1351
+ emails: ['test@email.com'],
1352
+ addresses: [],
1353
+ },
1354
+ },
1355
+ updatedAt: new Date().toISOString(),
1356
+ };
1357
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1358
+ const incomingIdentity = {
1359
+ identityId: 'skip-neptune-addr',
1360
+ traits: {
1361
+ emails: ['test@email.com'],
1362
+ addresses: [{ firstName: 'John', city: 'NYC' }],
1363
+ },
1364
+ };
1365
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1366
+ expect(result.summary.didSkipNeptune).toBe(true);
1367
+ expect(result.summary.didCallNeptune).toBe(false);
1368
+ expect(mockInvokeFunction).not.toHaveBeenCalled();
1369
+ });
1370
+ it('should set skipNeptune=false for IDENTITY_STALE tier', async () => {
1371
+ const cachedResponse = {
1372
+ pk: `identity#${pixelId}#call-neptune`,
1373
+ pixelId,
1374
+ identityId: 'call-neptune',
1375
+ response: {
1376
+ identityId: 'call-neptune',
1377
+ traits: {
1378
+ emails: ['old@email.com'],
1379
+ },
1380
+ },
1381
+ updatedAt: new Date().toISOString(),
1382
+ };
1383
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1384
+ mockInvokeFunction.mockResolvedValueOnce({
1385
+ statusCode: 200,
1386
+ body: JSON.stringify({
1387
+ identity: {
1388
+ identityId: 'call-neptune',
1389
+ traits: { emails: ['old@email.com', 'new@email.com'] },
1390
+ },
1391
+ }),
1392
+ });
1393
+ const incomingIdentity = {
1394
+ identityId: 'call-neptune',
1395
+ traits: {
1396
+ emails: ['old@email.com', 'new@email.com'],
1397
+ },
1398
+ };
1399
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1400
+ expect(result.summary.didSkipNeptune).toBe(false);
1401
+ expect(result.summary.didCallNeptune).toBe(true);
1402
+ expect(mockInvokeFunction).toHaveBeenCalled();
1403
+ });
1404
+ });
1405
+ describe('writeReason derivation from staleTier', () => {
1406
+ it('should set writeReason=address_update for ADDRESS_UPDATE tier', async () => {
1407
+ IdentityGatewayService.configure({
1408
+ awsRegion: 'us-east-1',
1409
+ awsAccountId: '123456789',
1410
+ asyncUpdateQueueName: 'identity-async-queue',
1411
+ });
1412
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'addr-reason-test' });
1413
+ const cachedResponse = {
1414
+ pk: `identity#${pixelId}#addr-reason`,
1415
+ pixelId,
1416
+ identityId: 'addr-reason',
1417
+ response: {
1418
+ identityId: 'addr-reason',
1419
+ traits: {
1420
+ emails: ['test@email.com'],
1421
+ addresses: [],
1422
+ },
1423
+ },
1424
+ updatedAt: new Date().toISOString(),
1425
+ };
1426
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1427
+ const incomingIdentity = {
1428
+ identityId: 'addr-reason',
1429
+ traits: {
1430
+ emails: ['test@email.com'],
1431
+ addresses: [{ firstName: 'Test', city: 'TestCity' }],
1432
+ },
1433
+ };
1434
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1435
+ expect(result.summary.writeReason).toBe('address_update');
1436
+ });
1437
+ it('should set writeReason=stale_traits for IDENTITY_STALE tier', async () => {
1438
+ const cachedResponse = {
1439
+ pk: `identity#${pixelId}#stale-reason`,
1440
+ pixelId,
1441
+ identityId: 'stale-reason',
1442
+ response: {
1443
+ identityId: 'stale-reason',
1444
+ traits: {
1445
+ emails: ['old@email.com'],
1446
+ },
1447
+ },
1448
+ updatedAt: new Date().toISOString(),
1449
+ };
1450
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1451
+ mockInvokeFunction.mockResolvedValueOnce({
1452
+ statusCode: 200,
1453
+ body: JSON.stringify({
1454
+ identity: {
1455
+ identityId: 'stale-reason',
1456
+ traits: { emails: ['old@email.com', 'new@email.com'] },
1457
+ },
1458
+ }),
1459
+ });
1460
+ const incomingIdentity = {
1461
+ identityId: 'stale-reason',
1462
+ traits: {
1463
+ emails: ['old@email.com', 'new@email.com'],
1464
+ },
1465
+ };
1466
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1467
+ expect(result.summary.writeReason).toBeUndefined();
1468
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1469
+ });
1470
+ });
1471
+ describe('ADDRESS_UPDATE → enqueueAsyncIdentityUpdate path', () => {
1472
+ it('should enqueue async update instead of calling Neptune', async () => {
1473
+ IdentityGatewayService.configure({
1474
+ awsRegion: 'us-east-1',
1475
+ awsAccountId: '123456789',
1476
+ asyncUpdateQueueName: 'identity-async-queue',
1477
+ });
1478
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'async-path-test' });
1479
+ const cachedResponse = {
1480
+ pk: `identity#${pixelId}#async-enqueue`,
1481
+ pixelId,
1482
+ identityId: 'async-enqueue',
1483
+ response: {
1484
+ identityId: 'async-enqueue',
1485
+ traits: {
1486
+ emails: ['test@email.com'],
1487
+ addresses: [],
1488
+ },
1489
+ },
1490
+ updatedAt: new Date().toISOString(),
1491
+ };
1492
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1493
+ const incomingIdentity = {
1494
+ identityId: 'async-enqueue',
1495
+ traits: {
1496
+ emails: ['test@email.com'],
1497
+ addresses: [{ firstName: 'Async', city: 'AsyncCity' }],
1498
+ },
1499
+ };
1500
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1501
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.ADDRESS_UPDATE_ASYNC);
1502
+ expect(result.summary.didEnqueueAsync).toBe(true);
1503
+ expect(result.summary.didCallNeptune).toBe(false);
1504
+ expect(mockBuildAndSendMessage).toHaveBeenCalledWith('identify.updateAsync', expect.objectContaining({
1505
+ pixelId,
1506
+ incomingIdentityId: 'async-enqueue',
1507
+ }));
1508
+ expect(mockInvokeFunction).not.toHaveBeenCalled();
1509
+ });
1510
+ it('should return merged identity immediately (no Neptune wait)', async () => {
1511
+ IdentityGatewayService.configure({
1512
+ awsRegion: 'us-east-1',
1513
+ awsAccountId: '123456789',
1514
+ asyncUpdateQueueName: 'identity-async-queue',
1515
+ });
1516
+ mockBuildAndSendMessage.mockResolvedValueOnce({ MessageId: 'immediate-return' });
1517
+ const cachedResponse = {
1518
+ pk: `identity#${pixelId}#immediate`,
1519
+ pixelId,
1520
+ identityId: 'immediate',
1521
+ response: {
1522
+ identityId: 'immediate',
1523
+ traits: {
1524
+ emails: ['test@email.com'],
1525
+ phones: ['555-cached'],
1526
+ addresses: [],
1527
+ },
1528
+ },
1529
+ updatedAt: new Date().toISOString(),
1530
+ };
1531
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1532
+ const incomingIdentity = {
1533
+ identityId: 'immediate',
1534
+ traits: {
1535
+ emails: ['test@email.com'],
1536
+ addresses: [{ firstName: 'Immediate', city: 'ImmediateCity' }],
1537
+ },
1538
+ };
1539
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1540
+ expect(result.identity).toBeDefined();
1541
+ expect(result.identity?.identityId).toBe('immediate');
1542
+ expect(result.identity?.traits?.phones).toContain('555-cached');
1543
+ expect(result.identity?.traits?.addresses).toHaveLength(1);
1544
+ });
1545
+ });
1546
+ describe('IDENTITY_STALE → Neptune path', () => {
1547
+ it('should call Neptune for identity-defining trait changes', async () => {
1548
+ const cachedResponse = {
1549
+ pk: `identity#${pixelId}#neptune-path`,
1550
+ pixelId,
1551
+ identityId: 'neptune-path',
1552
+ response: {
1553
+ identityId: 'neptune-path',
1554
+ traits: {
1555
+ emails: ['old@email.com'],
1556
+ },
1557
+ },
1558
+ updatedAt: new Date().toISOString(),
1559
+ };
1560
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1561
+ mockInvokeFunction.mockResolvedValueOnce({
1562
+ statusCode: 200,
1563
+ body: JSON.stringify({
1564
+ identity: {
1565
+ identityId: 'neptune-path',
1566
+ traits: { emails: ['old@email.com', 'new@email.com'] },
1567
+ },
1568
+ }),
1569
+ });
1570
+ const incomingIdentity = {
1571
+ identityId: 'neptune-path',
1572
+ traits: {
1573
+ emails: ['old@email.com', 'new@email.com'],
1574
+ },
1575
+ };
1576
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1577
+ expect(result.summary.didCallNeptune).toBe(true);
1578
+ expect(mockInvokeFunction).toHaveBeenCalledWith(lambdaArn, expect.objectContaining({
1579
+ pixelId,
1580
+ }));
1581
+ });
1582
+ it('should write Neptune result to cache when different from cached', async () => {
1583
+ const cachedResponse = {
1584
+ pk: `identity#${pixelId}#neptune-write`,
1585
+ pixelId,
1586
+ identityId: 'neptune-write',
1587
+ response: {
1588
+ identityId: 'neptune-write',
1589
+ traits: {
1590
+ emails: ['old@email.com'],
1591
+ },
1592
+ },
1593
+ updatedAt: new Date().toISOString(),
1594
+ };
1595
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1596
+ mockInvokeFunction.mockResolvedValueOnce({
1597
+ statusCode: 200,
1598
+ body: JSON.stringify({
1599
+ identity: {
1600
+ identityId: 'neptune-write',
1601
+ traits: {
1602
+ emails: ['old@email.com', 'new@email.com'],
1603
+ phones: ['555-neptune'],
1604
+ },
1605
+ },
1606
+ }),
1607
+ });
1608
+ const incomingIdentity = {
1609
+ identityId: 'neptune-write',
1610
+ traits: {
1611
+ emails: ['old@email.com', 'new@email.com'],
1612
+ },
1613
+ };
1614
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1615
+ expect(result.summary.didWriteCache).toBe(false);
1616
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1617
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1618
+ });
1619
+ it('should skip cache write when Neptune result matches cached', async () => {
1620
+ const cachedResponse = {
1621
+ pk: `identity#${pixelId}#neptune-skip-write`,
1622
+ pixelId,
1623
+ identityId: 'neptune-skip-write',
1624
+ response: {
1625
+ identityId: 'neptune-skip-write',
1626
+ traits: {
1627
+ emails: ['existing@email.com'],
1628
+ },
1629
+ },
1630
+ updatedAt: new Date().toISOString(),
1631
+ };
1632
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1633
+ mockInvokeFunction.mockResolvedValueOnce({
1634
+ statusCode: 200,
1635
+ body: JSON.stringify({
1636
+ identity: {
1637
+ identityId: 'neptune-skip-write',
1638
+ traits: {
1639
+ emails: ['existing@email.com'],
1640
+ },
1641
+ },
1642
+ }),
1643
+ });
1644
+ const incomingIdentity = {
1645
+ identityId: 'neptune-skip-write',
1646
+ traits: {
1647
+ emails: ['existing@email.com', 'thought-new@email.com'],
1648
+ },
1649
+ };
1650
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1651
+ expect(result.summary.didCallNeptune).toBe(true);
1652
+ expect(result.summary.didWriteCache).toBe(false);
1653
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1654
+ });
1655
+ it('should return NEPTUNE_RESOLUTION_FAILED when Neptune throws', async () => {
1656
+ const cachedResponse = {
1657
+ pk: `identity#${pixelId}#neptune-throws`,
1658
+ pixelId,
1659
+ identityId: 'neptune-throws',
1660
+ response: {
1661
+ identityId: 'neptune-throws',
1662
+ traits: {
1663
+ emails: ['old@email.com'],
1664
+ },
1665
+ },
1666
+ updatedAt: new Date().toISOString(),
1667
+ };
1668
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1669
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Neptune invocation failed'));
1670
+ const incomingIdentity = {
1671
+ identityId: 'neptune-throws',
1672
+ traits: {
1673
+ emails: ['old@email.com', 'new@email.com'],
1674
+ },
1675
+ };
1676
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1677
+ expect(result.summary.didCallNeptune).toBe(true);
1678
+ expect(result.summary.didWriteCache).toBe(false);
1679
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLUTION_FAILED);
1680
+ expect(result.identity).toBeDefined();
1681
+ expect(result.identity?.identityId).toBe('neptune-throws');
1682
+ expect(result.identity?.traits?.emails).toEqual(['old@email.com', 'new@email.com']);
1683
+ });
1684
+ });
1685
+ });
1686
+ describe('resolveCacheMissViaNeptune helper', () => {
1687
+ describe('cache miss without discovered identity ID', () => {
1688
+ it('should call Neptune with incoming identity when no discovered ID', async () => {
1689
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1690
+ mockInvokeFunction.mockResolvedValueOnce({
1691
+ statusCode: 200,
1692
+ body: JSON.stringify({
1693
+ identity: {
1694
+ identityId: 'neptune-resolved-id',
1695
+ traits: { emails: ['test@email.com'] },
1696
+ },
1697
+ }),
1698
+ });
1699
+ const incomingIdentity = {
1700
+ identityId: 'incoming-id',
1701
+ traits: { emails: ['test@email.com'] },
1702
+ };
1703
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1704
+ expect(mockInvokeFunction).toHaveBeenCalledWith(lambdaArn, expect.objectContaining({
1705
+ pixelId,
1706
+ context: expect.objectContaining({
1707
+ identity: expect.objectContaining({
1708
+ identityId: 'incoming-id',
1709
+ }),
1710
+ }),
1711
+ }));
1712
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1713
+ expect(result.summary.hasDiscoveredIdentityId).toBeUndefined();
1714
+ expect(result.summary.writeReason).toBeUndefined();
1715
+ });
1716
+ it('should set state.didReadCache=true and state.didCallNeptune=true', async () => {
1717
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1718
+ mockInvokeFunction.mockResolvedValueOnce({
1719
+ statusCode: 200,
1720
+ body: JSON.stringify({
1721
+ identity: { identityId: 'resolved', traits: {} },
1722
+ }),
1723
+ });
1724
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'test', traits: {} }, lambdaArn);
1725
+ expect(result.summary.didReadCache).toBe(true);
1726
+ expect(result.summary.didCallNeptune).toBe(true);
1727
+ });
1728
+ });
1729
+ describe('email pointer discovery with discovered identity ID', () => {
1730
+ it('should call Neptune with discovered identity ID when email lookup succeeds', async () => {
1731
+ const incomingNoId = {
1732
+ traits: { emails: ['discovered@email.com'] },
1733
+ };
1734
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce({
1735
+ pk: `email#${pixelId}#discovered@email.com`,
1736
+ identityId: 'discovered-identity-id',
1737
+ });
1738
+ mockInvokeFunction.mockResolvedValueOnce({
1739
+ statusCode: 200,
1740
+ body: JSON.stringify({
1741
+ identity: {
1742
+ identityId: 'discovered-identity-id',
1743
+ traits: { emails: ['discovered@email.com', 'other@email.com'] },
1744
+ },
1745
+ }),
1746
+ });
1747
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingNoId, lambdaArn);
1748
+ expect(result.identity?.identityId).toBe('discovered-identity-id');
1749
+ });
1750
+ it('should use incoming identity in Neptune call (not discovered ID)', async () => {
1751
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce({
1752
+ pk: `email#${pixelId}#test@email.com`,
1753
+ identityId: 'discovered-from-email',
1754
+ });
1755
+ mockInvokeFunction.mockResolvedValueOnce({
1756
+ statusCode: 200,
1757
+ body: JSON.stringify({
1758
+ identity: {
1759
+ identityId: 'neptune-resolved',
1760
+ traits: { emails: ['test@email.com'] },
1761
+ },
1762
+ }),
1763
+ });
1764
+ await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { traits: { emails: ['test@email.com'] } }, lambdaArn);
1765
+ expect(mockInvokeFunction).toHaveBeenCalledWith(lambdaArn, expect.objectContaining({
1766
+ pixelId,
1767
+ context: expect.objectContaining({
1768
+ identity: expect.objectContaining({
1769
+ traits: { emails: ['test@email.com'] },
1770
+ }),
1771
+ }),
1772
+ }));
1773
+ });
1774
+ });
1775
+ describe('Neptune resolution success → write-back', () => {
1776
+ it('should write to cache when Neptune returns identity', async () => {
1777
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1778
+ mockInvokeFunction.mockResolvedValueOnce({
1779
+ statusCode: 200,
1780
+ body: JSON.stringify({
1781
+ identity: {
1782
+ identityId: 'neptune-success',
1783
+ traits: { emails: ['test@email.com'], phones: ['555-1234'] },
1784
+ },
1785
+ }),
1786
+ });
1787
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'incoming', traits: { emails: ['test@email.com'] } }, lambdaArn);
1788
+ expect(result.summary.didWriteCache).toBe(false);
1789
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1790
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1791
+ });
1792
+ it('should set resolvedIdentity to Neptune response', async () => {
1793
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1794
+ mockInvokeFunction.mockResolvedValueOnce({
1795
+ statusCode: 200,
1796
+ body: JSON.stringify({
1797
+ identity: {
1798
+ identityId: 'from-neptune',
1799
+ traits: { emails: ['neptune@email.com'] },
1800
+ },
1801
+ }),
1802
+ });
1803
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'original', traits: { emails: ['original@email.com'] } }, lambdaArn);
1804
+ expect(result.identity?.identityId).toBe('from-neptune');
1805
+ expect(result.identity?.traits?.emails).toContain('neptune@email.com');
1806
+ });
1807
+ it('should include traitChanges with "no cached identity" reason', async () => {
1808
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1809
+ mockInvokeFunction.mockResolvedValueOnce({
1810
+ statusCode: 200,
1811
+ body: JSON.stringify({
1812
+ identity: {
1813
+ identityId: 'new-identity',
1814
+ traits: { emails: ['new@email.com'] },
1815
+ },
1816
+ }),
1817
+ });
1818
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'incoming', traits: { emails: ['incoming@email.com'] } }, lambdaArn);
1819
+ expect(result.summary.traitChanges).toEqual({
1820
+ reason: { resolved: 'no cached identity' },
1821
+ });
1822
+ });
1823
+ });
1824
+ describe('Neptune resolution failure → fallback to incoming', () => {
1825
+ it('should fallback to incoming identity when Neptune fails', async () => {
1826
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1827
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Neptune unavailable'));
1828
+ const incomingIdentity = {
1829
+ identityId: 'fallback-incoming',
1830
+ traits: { emails: ['fallback@email.com'] },
1831
+ };
1832
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, incomingIdentity, lambdaArn);
1833
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLUTION_FAILED);
1834
+ expect(result.summary.didFallbackToIncoming).toBe(true);
1835
+ expect(result.identity?.identityId).toBe('fallback-incoming');
1836
+ });
1837
+ it('should set didWriteCache=false when Neptune fails', async () => {
1838
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1839
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Lambda timeout'));
1840
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'test', traits: {} }, lambdaArn);
1841
+ expect(result.summary.didWriteCache).toBe(false);
1842
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1843
+ });
1844
+ it('should use identityForNeptune (with discovered ID) as fallback when available', async () => {
1845
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce({
1846
+ pk: `email#${pixelId}#test@email.com`,
1847
+ identityId: 'discovered-id',
1848
+ });
1849
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Neptune error'));
1850
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { traits: { emails: ['test@email.com'] } }, lambdaArn);
1851
+ expect(result.summary.didFallbackToIncoming).toBe(true);
1852
+ expect(result.identity?.identityId).toBeUndefined();
1853
+ expect(result.identity?.traits?.emails).toContain('test@email.com');
1854
+ });
1855
+ });
1856
+ describe('skipCacheWrite=true behavior', () => {
1857
+ it('should NOT write to cache when skipCacheWrite=true', async () => {
1858
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1859
+ mockInvokeFunction.mockResolvedValueOnce({
1860
+ statusCode: 200,
1861
+ body: JSON.stringify({
1862
+ identity: {
1863
+ identityId: 'skip-write-identity',
1864
+ traits: { emails: ['test@email.com'] },
1865
+ },
1866
+ }),
1867
+ });
1868
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'incoming', traits: { emails: ['test@email.com'] } }, lambdaArn, { skipCacheWrite: true });
1869
+ expect(result.summary.didWriteCache).toBe(false);
1870
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1871
+ expect(result.identity?.identityId).toBe('skip-write-identity');
1872
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1873
+ });
1874
+ it('should still call Neptune when skipCacheWrite=true', async () => {
1875
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1876
+ mockInvokeFunction.mockResolvedValueOnce({
1877
+ statusCode: 200,
1878
+ body: JSON.stringify({
1879
+ identity: { identityId: 'resolved', traits: {} },
1880
+ }),
1881
+ });
1882
+ await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'test', traits: {} }, lambdaArn, { skipCacheWrite: true });
1883
+ expect(mockInvokeFunction).toHaveBeenCalled();
1884
+ });
1885
+ });
1886
+ });
1887
+ describe('resolveStaleViaNeptune helper', () => {
1888
+ describe('Neptune resolution success with changes → write-back', () => {
1889
+ it('should write to cache when Neptune returns different identity', async () => {
1890
+ const cachedResponse = {
1891
+ pk: `identity#${pixelId}#stale-changes`,
1892
+ pixelId,
1893
+ identityId: 'stale-changes',
1894
+ response: {
1895
+ identityId: 'stale-changes',
1896
+ traits: { emails: ['old@email.com'] },
1897
+ },
1898
+ updatedAt: new Date().toISOString(),
1899
+ };
1900
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1901
+ mockInvokeFunction.mockResolvedValueOnce({
1902
+ statusCode: 200,
1903
+ body: JSON.stringify({
1904
+ identity: {
1905
+ identityId: 'stale-changes',
1906
+ traits: { emails: ['old@email.com', 'new@email.com'], phones: ['555-new'] },
1907
+ },
1908
+ }),
1909
+ });
1910
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'stale-changes', traits: { emails: ['old@email.com', 'new@email.com'] } }, lambdaArn);
1911
+ expect(result.summary.didWriteCache).toBe(false);
1912
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1913
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1914
+ });
1915
+ it('should compute traitChanges between cached and Neptune identity', async () => {
1916
+ const cachedResponse = {
1917
+ pk: `identity#${pixelId}#trait-changes`,
1918
+ pixelId,
1919
+ identityId: 'trait-changes',
1920
+ response: {
1921
+ identityId: 'trait-changes',
1922
+ traits: {
1923
+ emails: ['old@email.com'],
1924
+ phones: ['555-old'],
1925
+ },
1926
+ },
1927
+ updatedAt: new Date().toISOString(),
1928
+ };
1929
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1930
+ mockInvokeFunction.mockResolvedValueOnce({
1931
+ statusCode: 200,
1932
+ body: JSON.stringify({
1933
+ identity: {
1934
+ identityId: 'trait-changes',
1935
+ traits: {
1936
+ emails: ['old@email.com', 'new@email.com'],
1937
+ phones: ['555-old', '555-new'],
1938
+ },
1939
+ },
1940
+ }),
1941
+ });
1942
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'trait-changes', traits: { emails: ['old@email.com', 'new@email.com'] } }, lambdaArn);
1943
+ expect(result.summary.traitChanges).toBeDefined();
1944
+ expect(result.summary.traitChanges?.emails).toBeDefined();
1945
+ });
1946
+ });
1947
+ describe('Neptune resolution success without changes → skip write', () => {
1948
+ it('should skip cache write when Neptune matches cached', async () => {
1949
+ const cachedResponse = {
1950
+ pk: `identity#${pixelId}#no-changes`,
1951
+ pixelId,
1952
+ identityId: 'no-changes',
1953
+ response: {
1954
+ identityId: 'no-changes',
1955
+ traits: { emails: ['same@email.com'] },
1956
+ },
1957
+ updatedAt: new Date().toISOString(),
1958
+ };
1959
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1960
+ mockInvokeFunction.mockResolvedValueOnce({
1961
+ statusCode: 200,
1962
+ body: JSON.stringify({
1963
+ identity: {
1964
+ identityId: 'no-changes',
1965
+ traits: { emails: ['same@email.com'] },
1966
+ },
1967
+ }),
1968
+ });
1969
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'no-changes', traits: { emails: ['same@email.com', 'thought-new@email.com'] } }, lambdaArn);
1970
+ expect(result.summary.didCallNeptune).toBe(true);
1971
+ expect(result.summary.didWriteCache).toBe(false);
1972
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
1973
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
1974
+ });
1975
+ it('should still return Neptune identity even when skipping write', async () => {
1976
+ const cachedResponse = {
1977
+ pk: `identity#${pixelId}#return-identity`,
1978
+ pixelId,
1979
+ identityId: 'return-identity',
1980
+ response: {
1981
+ identityId: 'return-identity',
1982
+ traits: { emails: ['cached@email.com'] },
1983
+ },
1984
+ updatedAt: new Date().toISOString(),
1985
+ };
1986
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
1987
+ mockInvokeFunction.mockResolvedValueOnce({
1988
+ statusCode: 200,
1989
+ body: JSON.stringify({
1990
+ identity: {
1991
+ identityId: 'return-identity',
1992
+ traits: { emails: ['cached@email.com'] },
1993
+ },
1994
+ }),
1995
+ });
1996
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'return-identity', traits: { emails: ['cached@email.com', 'extra@email.com'] } }, lambdaArn);
1997
+ expect(result.identity?.identityId).toBe('return-identity');
1998
+ });
1999
+ it('should strip address timestamps for comparison', async () => {
2000
+ const now = Date.now();
2001
+ const cachedResponse = {
2002
+ pk: `identity#${pixelId}#addr-timestamp`,
2003
+ pixelId,
2004
+ identityId: 'addr-timestamp',
2005
+ response: {
2006
+ identityId: 'addr-timestamp',
2007
+ traits: {
2008
+ emails: ['test@email.com'],
2009
+ addresses: [{ city: 'NYC', cra: now - 1000, upa: now - 500 }],
2010
+ },
2011
+ },
2012
+ updatedAt: new Date().toISOString(),
2013
+ };
2014
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2015
+ mockInvokeFunction.mockResolvedValueOnce({
2016
+ statusCode: 200,
2017
+ body: JSON.stringify({
2018
+ identity: {
2019
+ identityId: 'addr-timestamp',
2020
+ traits: {
2021
+ emails: ['test@email.com'],
2022
+ addresses: [{ city: 'NYC', cra: now, upa: now }],
2023
+ },
2024
+ },
2025
+ }),
2026
+ });
2027
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'addr-timestamp', traits: { emails: ['test@email.com', 'new@email.com'] } }, lambdaArn);
2028
+ expect(result.summary.didWriteCache).toBe(false);
2029
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
2030
+ });
2031
+ });
2032
+ describe('Neptune resolution failure', () => {
2033
+ it('should return NEPTUNE_RESOLUTION_FAILED when Neptune fails', async () => {
2034
+ const cachedResponse = {
2035
+ pk: `identity#${pixelId}#neptune-fail`,
2036
+ pixelId,
2037
+ identityId: 'neptune-fail',
2038
+ response: {
2039
+ identityId: 'neptune-fail',
2040
+ traits: { emails: ['cached@email.com'] },
2041
+ },
2042
+ updatedAt: new Date().toISOString(),
2043
+ };
2044
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2045
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Lambda invocation failed'));
2046
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'neptune-fail', traits: { emails: ['cached@email.com', 'new@email.com'] } }, lambdaArn);
2047
+ expect(result.summary.didCallNeptune).toBe(true);
2048
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLUTION_FAILED);
2049
+ });
2050
+ it('should not write to cache when Neptune fails', async () => {
2051
+ const cachedResponse = {
2052
+ pk: `identity#${pixelId}#no-write-fail`,
2053
+ pixelId,
2054
+ identityId: 'no-write-fail',
2055
+ response: {
2056
+ identityId: 'no-write-fail',
2057
+ traits: { emails: ['test@email.com'] },
2058
+ },
2059
+ updatedAt: new Date().toISOString(),
2060
+ };
2061
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2062
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Network error'));
2063
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'no-write-fail', traits: { emails: ['test@email.com', 'extra@email.com'] } }, lambdaArn);
2064
+ expect(result.summary.didWriteCache).toBe(false);
2065
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
2066
+ });
2067
+ it('should still return merged identity when Neptune fails', async () => {
2068
+ const cachedResponse = {
2069
+ pk: `identity#${pixelId}#merged-on-fail`,
2070
+ pixelId,
2071
+ identityId: 'merged-on-fail',
2072
+ response: {
2073
+ identityId: 'merged-on-fail',
2074
+ traits: {
2075
+ emails: ['cached@email.com'],
2076
+ phones: ['555-cached'],
2077
+ },
2078
+ },
2079
+ updatedAt: new Date().toISOString(),
2080
+ };
2081
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2082
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Timeout'));
2083
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'merged-on-fail', traits: { emails: ['cached@email.com', 'new@email.com'] } }, lambdaArn);
2084
+ expect(result.identity).toBeDefined();
2085
+ });
2086
+ });
2087
+ describe('skipCacheWrite=true behavior', () => {
2088
+ it('should NOT write to cache when skipCacheWrite=true even with changes', async () => {
2089
+ const cachedResponse = {
2090
+ pk: `identity#${pixelId}#skip-stale`,
2091
+ pixelId,
2092
+ identityId: 'skip-stale',
2093
+ response: {
2094
+ identityId: 'skip-stale',
2095
+ traits: { emails: ['old@email.com'] },
2096
+ },
2097
+ updatedAt: new Date().toISOString(),
2098
+ };
2099
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2100
+ mockInvokeFunction.mockResolvedValueOnce({
2101
+ statusCode: 200,
2102
+ body: JSON.stringify({
2103
+ identity: {
2104
+ identityId: 'skip-stale',
2105
+ traits: { emails: ['old@email.com', 'new@email.com'], phones: ['555-new'] },
2106
+ },
2107
+ }),
2108
+ });
2109
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'skip-stale', traits: { emails: ['old@email.com', 'new@email.com'] } }, lambdaArn, { skipCacheWrite: true });
2110
+ expect(result.summary.didWriteCache).toBe(false);
2111
+ expect(mockedDynamoDbClient.safeBatchWrite).not.toHaveBeenCalled();
2112
+ expect(result.summary.outcome).toBe(IDENTITY_CACHE_WITH_CACHING_OUTCOME.NEPTUNE_RESOLVED);
2113
+ });
2114
+ it('should still call Neptune when skipCacheWrite=true', async () => {
2115
+ const cachedResponse = {
2116
+ pk: `identity#${pixelId}#call-neptune-skip`,
2117
+ pixelId,
2118
+ identityId: 'call-neptune-skip',
2119
+ response: {
2120
+ identityId: 'call-neptune-skip',
2121
+ traits: { emails: ['test@email.com'] },
2122
+ },
2123
+ updatedAt: new Date().toISOString(),
2124
+ };
2125
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2126
+ mockInvokeFunction.mockResolvedValueOnce({
2127
+ statusCode: 200,
2128
+ body: JSON.stringify({
2129
+ identity: {
2130
+ identityId: 'call-neptune-skip',
2131
+ traits: { emails: ['test@email.com', 'added@email.com'] },
2132
+ },
2133
+ }),
2134
+ });
2135
+ await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'call-neptune-skip', traits: { emails: ['test@email.com', 'added@email.com'] } }, lambdaArn, { skipCacheWrite: true });
2136
+ expect(mockInvokeFunction).toHaveBeenCalled();
2137
+ });
2138
+ it('should return Neptune identity when skipCacheWrite=true', async () => {
2139
+ const cachedResponse = {
2140
+ pk: `identity#${pixelId}#return-skip`,
2141
+ pixelId,
2142
+ identityId: 'return-skip',
2143
+ response: {
2144
+ identityId: 'return-skip',
2145
+ traits: { emails: ['cached@email.com'] },
2146
+ },
2147
+ updatedAt: new Date().toISOString(),
2148
+ };
2149
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(cachedResponse);
2150
+ mockInvokeFunction.mockResolvedValueOnce({
2151
+ statusCode: 200,
2152
+ body: JSON.stringify({
2153
+ identity: {
2154
+ identityId: 'return-skip',
2155
+ traits: {
2156
+ emails: ['cached@email.com', 'neptune@email.com'],
2157
+ phones: ['555-neptune'],
2158
+ },
2159
+ },
2160
+ }),
2161
+ });
2162
+ const result = await IdentityGatewayService.getIdentityWithCachingV2(pixelId, { identityId: 'return-skip', traits: { emails: ['cached@email.com', 'neptune@email.com'] } }, lambdaArn, { skipCacheWrite: true });
2163
+ expect(result.identity?.traits?.phones).toContain('555-neptune');
2164
+ });
2165
+ });
2166
+ });
564
2167
  });
565
2168
  //# sourceMappingURL=identity-gateway-service.spec.js.map