@adtrackify/at-service-common 3.19.18 → 3.19.20

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.
@@ -27,6 +27,15 @@ const mockedDynamoDbClient = DynamoDbClient;
27
27
  describe('IdentityCacheDynamoDbService', () => {
28
28
  beforeEach(() => {
29
29
  jest.clearAllMocks();
30
+ mockedDynamoDbClient.safeGet.mockReset();
31
+ mockedDynamoDbClient.safePut.mockReset();
32
+ mockedDynamoDbClient.safeDelete.mockReset();
33
+ mockedDynamoDbClient.safeBatchGet.mockReset();
34
+ mockedDynamoDbClient.safeBatchWrite.mockReset();
35
+ mockedDynamoDbClient.safeQueryByGSI.mockReset();
36
+ mockedDynamoDbClient.batchWrite.mockReset();
37
+ mockInvokeFunction.mockReset();
38
+ IdentityCacheDynamoDbService.lambdaInvokeClient = undefined;
30
39
  });
31
40
  describe('getIdentityWithCaching', () => {
32
41
  const pixelId = 'pixel123';
@@ -153,7 +162,7 @@ describe('IdentityCacheDynamoDbService', () => {
153
162
  const incomingIdentity = {
154
163
  traits: { emails: ['test@email.com'] },
155
164
  };
156
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
165
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
157
166
  mockInvokeFunction.mockResolvedValueOnce({
158
167
  statusCode: 200,
159
168
  body: JSON.stringify({
@@ -173,7 +182,7 @@ describe('IdentityCacheDynamoDbService', () => {
173
182
  const incomingIdentity = {
174
183
  traits: { emails: ['test@email.com'] },
175
184
  };
176
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
185
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
177
186
  mockInvokeFunction.mockResolvedValueOnce({
178
187
  statusCode: 200,
179
188
  body: JSON.stringify({ identity: undefined }),
@@ -207,7 +216,7 @@ describe('IdentityCacheDynamoDbService', () => {
207
216
  const incomingIdentity = {
208
217
  traits: { emails: ['test@email.com'] },
209
218
  };
210
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
219
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
211
220
  mockInvokeFunction.mockResolvedValueOnce({
212
221
  statusCode: 200,
213
222
  body: JSON.stringify({
@@ -237,26 +246,22 @@ describe('IdentityCacheDynamoDbService', () => {
237
246
  it('should build correct pk for email lookup (lowercase)', async () => {
238
247
  const pixelId = 'pixel123';
239
248
  const email = 'Test@Email.COM';
240
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
249
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
241
250
  await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
242
251
  traits: { emails: [email] },
243
252
  });
244
- expect(mockedDynamoDbClient.safeBatchGet).toHaveBeenCalledWith(expect.any(String), [
245
- { pk: `email#${pixelId}#test@email.com` },
246
- ]);
253
+ expect(mockedDynamoDbClient.safeGet).toHaveBeenCalledWith(expect.any(String), 'pk', `email#${pixelId}#test@email.com`);
247
254
  });
248
255
  });
249
- describe('buildUserIdPk', () => {
250
- it('should build correct pk for userId lookup (trimmed)', async () => {
256
+ describe('buildUserIdPk (no longer used for lookup)', () => {
257
+ it('should NOT lookup by userId (optimization: email-only secondary lookup)', async () => {
251
258
  const pixelId = 'pixel123';
252
259
  const userId = ' user789 ';
253
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
260
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
254
261
  await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
255
262
  traits: { userIds: [userId] },
256
263
  });
257
- expect(mockedDynamoDbClient.safeBatchGet).toHaveBeenCalledWith(expect.any(String), [
258
- { pk: `user_id#${pixelId}#user789` },
259
- ]);
264
+ expect(mockedDynamoDbClient.safeBatchGet).not.toHaveBeenCalled();
260
265
  });
261
266
  });
262
267
  });
@@ -322,66 +327,62 @@ describe('IdentityCacheDynamoDbService', () => {
322
327
  });
323
328
  });
324
329
  describe('without identityId (secondary lookup)', () => {
325
- it('should use batch get for email and userId lookups', async () => {
326
- const cachedResponse = {
330
+ it('should use single get for email lookup (pointer-based)', async () => {
331
+ const pointerRecord = {
327
332
  pk: `email#${pixelId}#test@email.com`,
328
333
  pixelId,
329
334
  identityId: 'resolved-id',
330
- response: {
331
- identityId: 'resolved-id',
332
- traits: { emails: ['test@email.com'] },
333
- },
335
+ gsi1pk: 'resolved-id',
334
336
  updatedAt: new Date().toISOString(),
335
337
  };
336
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([cachedResponse]);
338
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(pointerRecord);
337
339
  const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
338
340
  traits: { emails: ['test@email.com'], userIds: ['user123'] },
339
341
  });
340
- expect(mockedDynamoDbClient.safeBatchGet).toHaveBeenCalledWith(expect.any(String), [
341
- { pk: `email#${pixelId}#test@email.com` },
342
- { pk: `user_id#${pixelId}#user123` },
343
- ]);
342
+ expect(mockedDynamoDbClient.safeGet).toHaveBeenCalledWith(expect.any(String), 'pk', `email#${pixelId}#test@email.com`);
343
+ expect(mockedDynamoDbClient.safeBatchGet).not.toHaveBeenCalled();
344
344
  expect(result.resolvedIdentity).toBeDefined();
345
345
  expect(result.resolvedIdentity?.traits?.emails).toContain('test@email.com');
346
346
  expect(result.isCacheStale).toBe(true);
347
347
  });
348
- it('should detect conflict when multiple identityIds found', async () => {
349
- const response1 = {
348
+ it('should return discovered identityId with stale flag for Neptune resolution', async () => {
349
+ const pointerRecord = {
350
350
  pk: `email#${pixelId}#test@email.com`,
351
351
  pixelId,
352
- identityId: 'identity-1',
353
- response: { identityId: 'identity-1' },
354
- updatedAt: new Date().toISOString(),
355
- };
356
- const response2 = {
357
- pk: `user_id#${pixelId}#user123`,
358
- pixelId,
359
- identityId: 'identity-2',
360
- response: { identityId: 'identity-2' },
352
+ identityId: 'discovered-id',
353
+ gsi1pk: 'discovered-id',
361
354
  updatedAt: new Date().toISOString(),
362
355
  };
363
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([response1, response2]);
356
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(pointerRecord);
364
357
  const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
365
358
  traits: { emails: ['test@email.com'], userIds: ['user123'] },
366
359
  });
367
- expect(result.resolvedIdentity).toBeUndefined();
360
+ expect(result.resolvedIdentity).toBeDefined();
361
+ expect(result.resolvedIdentity?.identityId).toBe('discovered-id');
368
362
  expect(result.isCacheStale).toBe(true);
369
363
  });
370
364
  it('should return cache miss when no secondary keys match', async () => {
371
- mockedDynamoDbClient.safeBatchGet.mockResolvedValueOnce([]);
365
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
372
366
  const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
373
367
  traits: { emails: ['unknown@email.com'] },
374
368
  });
375
369
  expect(result.resolvedIdentity).toBeUndefined();
376
370
  expect(result.isCacheStale).toBe(true);
377
371
  });
378
- it('should return cache miss when no secondary keys to lookup', async () => {
372
+ it('should return cache miss when no emails to lookup', async () => {
373
+ const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
374
+ traits: { userIds: ['user123'] },
375
+ });
376
+ expect(result.resolvedIdentity).toBeUndefined();
377
+ expect(result.isCacheStale).toBe(true);
378
+ expect(mockedDynamoDbClient.safeGet).not.toHaveBeenCalled();
379
+ });
380
+ it('should return cache miss when empty traits', async () => {
379
381
  const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
380
382
  traits: {},
381
383
  });
382
384
  expect(result.resolvedIdentity).toBeUndefined();
383
385
  expect(result.isCacheStale).toBe(true);
384
- expect(mockedDynamoDbClient.safeBatchGet).not.toHaveBeenCalled();
385
386
  });
386
387
  });
387
388
  describe('error handling (fail-open)', () => {
@@ -393,8 +394,8 @@ describe('IdentityCacheDynamoDbService', () => {
393
394
  expect(result.resolvedIdentity).toBeUndefined();
394
395
  expect(result.isCacheStale).toBe(true);
395
396
  });
396
- it('should return cache miss on safeBatchGet error', async () => {
397
- mockedDynamoDbClient.safeBatchGet.mockRejectedValueOnce(new Error('DynamoDB error'));
397
+ it('should return cache miss on safeGet error for email lookup', async () => {
398
+ mockedDynamoDbClient.safeGet.mockRejectedValueOnce(new Error('DynamoDB error'));
398
399
  const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
399
400
  traits: { emails: ['test@email.com'] },
400
401
  });
@@ -417,7 +418,7 @@ describe('IdentityCacheDynamoDbService', () => {
417
418
  });
418
419
  describe('updateIdentityCache', () => {
419
420
  const pixelId = 'pixel123';
420
- it('should write identity with all secondary keys', async () => {
421
+ it('should write identity with email pointer (no full blob on secondary keys)', async () => {
421
422
  const identity = {
422
423
  identityId: 'identity456',
423
424
  traits: {
@@ -428,13 +429,21 @@ describe('IdentityCacheDynamoDbService', () => {
428
429
  await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
429
430
  expect(mockedDynamoDbClient.safeBatchWrite).toHaveBeenCalledTimes(1);
430
431
  const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
431
- expect(writtenItems).toHaveLength(5);
432
+ expect(writtenItems).toHaveLength(3);
432
433
  const pks = writtenItems.map((item) => item.pk);
433
434
  expect(pks).toContain(`identity#${pixelId}#identity456`);
434
435
  expect(pks).toContain(`email#${pixelId}#test@email.com`);
435
436
  expect(pks).toContain(`email#${pixelId}#other@email.com`);
436
- expect(pks).toContain(`user_id#${pixelId}#user1`);
437
- expect(pks).toContain(`user_id#${pixelId}#user2`);
437
+ expect(pks).not.toContain(`user_id#${pixelId}#user1`);
438
+ expect(pks).not.toContain(`user_id#${pixelId}#user2`);
439
+ const identityItem = writtenItems.find((item) => item.pk.startsWith('identity#'));
440
+ expect(identityItem).toBeDefined();
441
+ expect(identityItem.response).toBeDefined();
442
+ expect(identityItem.response.identityId).toBe('identity456');
443
+ const emailItem = writtenItems.find((item) => item.pk.startsWith('email#'));
444
+ expect(emailItem).toBeDefined();
445
+ expect(emailItem.response).toBeUndefined();
446
+ expect(emailItem.identityId).toBe('identity456');
438
447
  });
439
448
  it('should not throw on safeBatchWrite error (fail-open)', async () => {
440
449
  mockedDynamoDbClient.safeBatchWrite.mockRejectedValueOnce(new Error('DynamoDB error'));
@@ -779,5 +788,352 @@ describe('IdentityCacheDynamoDbService', () => {
779
788
  expect(result.isCacheStale).toBe(false);
780
789
  });
781
790
  });
791
+ describe('multiple emails pointer writes', () => {
792
+ const pixelId = 'pixel123';
793
+ it('should write ALL emails as pointer records (not just first)', async () => {
794
+ const identity = {
795
+ identityId: 'identity456',
796
+ traits: {
797
+ emails: ['first@email.com', 'second@email.com', 'third@email.com', 'fourth@email.com'],
798
+ },
799
+ };
800
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
801
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
802
+ expect(writtenItems).toHaveLength(5);
803
+ const emailPks = writtenItems
804
+ .filter((item) => item.pk.startsWith('email#'))
805
+ .map((item) => item.pk);
806
+ expect(emailPks).toContain(`email#${pixelId}#first@email.com`);
807
+ expect(emailPks).toContain(`email#${pixelId}#second@email.com`);
808
+ expect(emailPks).toContain(`email#${pixelId}#third@email.com`);
809
+ expect(emailPks).toContain(`email#${pixelId}#fourth@email.com`);
810
+ });
811
+ it('should skip null/undefined emails in array', async () => {
812
+ const identity = {
813
+ identityId: 'identity456',
814
+ traits: {
815
+ emails: ['valid@email.com', null, undefined, '', 'another@email.com'],
816
+ },
817
+ };
818
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
819
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
820
+ const emailItems = writtenItems.filter((item) => item.pk.startsWith('email#'));
821
+ expect(emailItems).toHaveLength(2);
822
+ expect(emailItems.map((e) => e.pk)).toContain(`email#${pixelId}#valid@email.com`);
823
+ expect(emailItems.map((e) => e.pk)).toContain(`email#${pixelId}#another@email.com`);
824
+ });
825
+ it('should handle empty emails array', async () => {
826
+ const identity = {
827
+ identityId: 'identity456',
828
+ traits: { emails: [] },
829
+ };
830
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
831
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
832
+ expect(writtenItems).toHaveLength(1);
833
+ expect(writtenItems[0].pk).toBe(`identity#${pixelId}#identity456`);
834
+ });
835
+ it('should handle email normalization (lowercase)', async () => {
836
+ const identity = {
837
+ identityId: 'identity456',
838
+ traits: {
839
+ emails: ['UPPERCASE@EMAIL.COM', 'MixedCase@Email.Com'],
840
+ },
841
+ };
842
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
843
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
844
+ const emailPks = writtenItems
845
+ .filter((item) => item.pk.startsWith('email#'))
846
+ .map((item) => item.pk);
847
+ expect(emailPks).toContain(`email#${pixelId}#uppercase@email.com`);
848
+ expect(emailPks).toContain(`email#${pixelId}#mixedcase@email.com`);
849
+ });
850
+ });
851
+ describe('null/undefined traits edge cases', () => {
852
+ const pixelId = 'pixel123';
853
+ it('should handle null traits object', async () => {
854
+ const identity = {
855
+ identityId: 'identity456',
856
+ traits: null,
857
+ };
858
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
859
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
860
+ expect(writtenItems).toHaveLength(1);
861
+ });
862
+ it('should handle undefined traits object', async () => {
863
+ const identity = {
864
+ identityId: 'identity456',
865
+ traits: undefined,
866
+ };
867
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
868
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
869
+ expect(writtenItems).toHaveLength(1);
870
+ });
871
+ it('should return cache miss for identity with null traits in lookup', async () => {
872
+ const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
873
+ traits: null,
874
+ });
875
+ expect(result.resolvedIdentity).toBeUndefined();
876
+ expect(result.isCacheStale).toBe(true);
877
+ expect(mockedDynamoDbClient.safeGet).not.toHaveBeenCalled();
878
+ });
879
+ it('should handle identity with undefined emails in traits', async () => {
880
+ const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
881
+ traits: { userIds: ['user123'] },
882
+ });
883
+ expect(result.resolvedIdentity).toBeUndefined();
884
+ expect(result.isCacheStale).toBe(true);
885
+ });
886
+ });
887
+ describe('backward compatibility (old full-blob format)', () => {
888
+ const pixelId = 'pixel123';
889
+ it('should read old format email record with full response blob', async () => {
890
+ const oldFormatEmailRecord = {
891
+ pk: `email#${pixelId}#test@email.com`,
892
+ pixelId,
893
+ identityId: 'discovered-id',
894
+ gsi1pk: 'discovered-id',
895
+ response: {
896
+ identityId: 'discovered-id',
897
+ traits: { emails: ['test@email.com', 'other@email.com'] },
898
+ },
899
+ updatedAt: new Date().toISOString(),
900
+ };
901
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(oldFormatEmailRecord);
902
+ const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
903
+ traits: { emails: ['test@email.com'] },
904
+ });
905
+ expect(result.resolvedIdentity).toBeDefined();
906
+ expect(result.resolvedIdentity?.identityId).toBe('discovered-id');
907
+ expect(result.isCacheStale).toBe(true);
908
+ });
909
+ it('should read new format pointer record (no response blob)', async () => {
910
+ const newFormatPointerRecord = {
911
+ pk: `email#${pixelId}#test@email.com`,
912
+ pixelId,
913
+ identityId: 'discovered-id',
914
+ gsi1pk: 'discovered-id',
915
+ updatedAt: new Date().toISOString(),
916
+ };
917
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(newFormatPointerRecord);
918
+ const result = await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
919
+ traits: { emails: ['test@email.com'] },
920
+ });
921
+ expect(result.resolvedIdentity).toBeDefined();
922
+ expect(result.resolvedIdentity?.identityId).toBe('discovered-id');
923
+ expect(result.isCacheStale).toBe(true);
924
+ });
925
+ it('should trigger Neptune resolution after finding pointer record', async () => {
926
+ const lambdaArn = 'arn:aws:lambda:us-east-1:123456789:function:identity-private';
927
+ const pointerRecord = {
928
+ pk: `email#${pixelId}#test@email.com`,
929
+ pixelId,
930
+ identityId: 'discovered-id',
931
+ gsi1pk: 'discovered-id',
932
+ updatedAt: new Date().toISOString(),
933
+ };
934
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(pointerRecord);
935
+ mockInvokeFunction.mockResolvedValueOnce({
936
+ statusCode: 200,
937
+ body: JSON.stringify({
938
+ identity: {
939
+ identityId: 'discovered-id',
940
+ traits: { emails: ['test@email.com', 'resolved@email.com'] },
941
+ },
942
+ }),
943
+ });
944
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(pixelId, { traits: { emails: ['test@email.com'] } }, lambdaArn);
945
+ expect(mockInvokeFunction).toHaveBeenCalledWith(lambdaArn, expect.objectContaining({
946
+ pixelId,
947
+ context: {
948
+ identity: expect.objectContaining({
949
+ identityId: 'discovered-id',
950
+ }),
951
+ },
952
+ }));
953
+ expect(result?.identityId).toBe('discovered-id');
954
+ });
955
+ });
956
+ describe('deleteIdentityCache with multiple email pointers', () => {
957
+ const pixelId = 'pixel123';
958
+ it('should delete all email pointers from both incoming and resolved identities', async () => {
959
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue([
960
+ { pk: `identity#${pixelId}#identity456`, pixelId },
961
+ ]);
962
+ mockedDynamoDbClient.batchWrite.mockResolvedValue({ $metadata: {} });
963
+ const incomingIdentity = {
964
+ identityId: 'identity456',
965
+ traits: { emails: ['incoming1@email.com', 'incoming2@email.com'] },
966
+ };
967
+ const resolvedIdentity = {
968
+ identityId: 'identity456',
969
+ traits: { emails: ['resolved1@email.com', 'resolved2@email.com', 'resolved3@email.com'] },
970
+ };
971
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, incomingIdentity, resolvedIdentity);
972
+ expect(mockedDynamoDbClient.batchWrite).toHaveBeenCalled();
973
+ const batchWriteCall = mockedDynamoDbClient.batchWrite.mock.calls[0][0];
974
+ const deleteRequests = batchWriteCall.RequestItems[Object.keys(batchWriteCall.RequestItems)[0]];
975
+ const deletedPks = deleteRequests.map((req) => req.DeleteRequest.Key.pk);
976
+ expect(deletedPks).toContain(`identity#${pixelId}#identity456`);
977
+ expect(deletedPks).toContain(`email#${pixelId}#incoming1@email.com`);
978
+ expect(deletedPks).toContain(`email#${pixelId}#incoming2@email.com`);
979
+ expect(deletedPks).toContain(`email#${pixelId}#resolved1@email.com`);
980
+ expect(deletedPks).toContain(`email#${pixelId}#resolved2@email.com`);
981
+ expect(deletedPks).toContain(`email#${pixelId}#resolved3@email.com`);
982
+ });
983
+ it('should handle empty emails in delete', async () => {
984
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue([
985
+ { pk: `identity#${pixelId}#identity456`, pixelId },
986
+ ]);
987
+ mockedDynamoDbClient.batchWrite.mockResolvedValue({ $metadata: {} });
988
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId: 'identity456', traits: { emails: [] } }, { identityId: 'identity456' });
989
+ expect(mockedDynamoDbClient.batchWrite).toHaveBeenCalled();
990
+ });
991
+ it('should handle missing traits in delete', async () => {
992
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue([
993
+ { pk: `identity#${pixelId}#identity456`, pixelId },
994
+ ]);
995
+ mockedDynamoDbClient.batchWrite.mockResolvedValue({ $metadata: {} });
996
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId: 'identity456' }, { identityId: 'identity456' });
997
+ expect(mockedDynamoDbClient.batchWrite).toHaveBeenCalled();
998
+ });
999
+ it('should not delete items from other pixelIds', async () => {
1000
+ const otherPixelId = 'other-pixel';
1001
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue([
1002
+ { pk: `identity#${pixelId}#identity456`, pixelId },
1003
+ { pk: `identity#${otherPixelId}#identity456`, pixelId: otherPixelId },
1004
+ ]);
1005
+ mockedDynamoDbClient.batchWrite.mockResolvedValue({ $metadata: {} });
1006
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId: 'identity456' }, { identityId: 'identity456' });
1007
+ const batchWriteCall = mockedDynamoDbClient.batchWrite.mock.calls[0][0];
1008
+ const deleteRequests = batchWriteCall.RequestItems[Object.keys(batchWriteCall.RequestItems)[0]];
1009
+ const deletedPks = deleteRequests.map((req) => req.DeleteRequest.Key.pk);
1010
+ expect(deletedPks).toContain(`identity#${pixelId}#identity456`);
1011
+ expect(deletedPks).not.toContain(`identity#${otherPixelId}#identity456`);
1012
+ });
1013
+ });
1014
+ describe('GSI1 query for reverse lookups', () => {
1015
+ const pixelId = 'pixel123';
1016
+ it('should find all records by identityId via GSI1', async () => {
1017
+ const identityId = 'identity456';
1018
+ const mockResults = [
1019
+ { pk: `identity#${pixelId}#${identityId}`, pixelId, identityId },
1020
+ { pk: `email#${pixelId}#email1@test.com`, pixelId, identityId },
1021
+ { pk: `email#${pixelId}#email2@test.com`, pixelId, identityId },
1022
+ ];
1023
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue(mockResults);
1024
+ mockedDynamoDbClient.batchWrite.mockResolvedValue({ $metadata: {} });
1025
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId }, { identityId });
1026
+ expect(mockedDynamoDbClient.safeQueryByGSI).toHaveBeenCalledWith(expect.any(String), expect.any(String), 'gsi1pk', identityId);
1027
+ });
1028
+ it('should handle GSI1 query returning empty results', async () => {
1029
+ mockedDynamoDbClient.safeQueryByGSI.mockResolvedValue([]);
1030
+ await IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId: 'unknown-id' }, { identityId: 'unknown-id' });
1031
+ expect(mockedDynamoDbClient.batchWrite).not.toHaveBeenCalled();
1032
+ });
1033
+ it('should handle GSI1 query error gracefully (fail-open)', async () => {
1034
+ mockedDynamoDbClient.safeQueryByGSI.mockRejectedValueOnce(new Error('GSI query failed'));
1035
+ await expect(IdentityCacheDynamoDbService.deleteIdentityCache(pixelId, { identityId: 'identity456' }, { identityId: 'identity456' })).resolves.toBeUndefined();
1036
+ });
1037
+ });
1038
+ describe('email pointer lookups', () => {
1039
+ const pixelId = 'pixel123';
1040
+ it('should only lookup first email (optimization)', async () => {
1041
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1042
+ await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
1043
+ traits: { emails: ['first@email.com', 'second@email.com', 'third@email.com'] },
1044
+ });
1045
+ expect(mockedDynamoDbClient.safeGet).toHaveBeenCalledTimes(1);
1046
+ expect(mockedDynamoDbClient.safeGet).toHaveBeenCalledWith(expect.any(String), 'pk', `email#${pixelId}#first@email.com`);
1047
+ });
1048
+ it('should handle whitespace in email during lookup', async () => {
1049
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1050
+ await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
1051
+ traits: { emails: [' test@email.com '] },
1052
+ });
1053
+ expect(mockedDynamoDbClient.safeGet).toHaveBeenCalled();
1054
+ });
1055
+ });
1056
+ describe('fail-open behavior comprehensive', () => {
1057
+ const pixelId = 'pixel123';
1058
+ const lambdaArn = 'arn:aws:lambda:us-east-1:123456789:function:identity-private';
1059
+ it('should return incoming identity when Neptune throws (fail-open fallback)', async () => {
1060
+ const incomingIdentity = {
1061
+ identityId: 'identity456',
1062
+ traits: { emails: ['test@email.com'] },
1063
+ };
1064
+ mockedDynamoDbClient.safeGet.mockRejectedValueOnce(new Error('DynamoDB down'));
1065
+ mockInvokeFunction.mockRejectedValueOnce(new Error('Lambda down'));
1066
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(pixelId, incomingIdentity, lambdaArn);
1067
+ expect(result).toEqual(incomingIdentity);
1068
+ });
1069
+ it('should continue when cache write fails after Neptune success', async () => {
1070
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1071
+ mockInvokeFunction.mockResolvedValueOnce({
1072
+ statusCode: 200,
1073
+ body: JSON.stringify({
1074
+ identity: { identityId: 'resolved-id', traits: { emails: ['test@email.com'] } },
1075
+ }),
1076
+ });
1077
+ mockedDynamoDbClient.safeBatchWrite.mockRejectedValueOnce(new Error('Write failed'));
1078
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(pixelId, { traits: { emails: ['test@email.com'] } }, lambdaArn);
1079
+ expect(result?.identityId).toBe('resolved-id');
1080
+ });
1081
+ it('should return incoming identity when Neptune returns non-200', async () => {
1082
+ const incomingIdentity = {
1083
+ identityId: 'incoming-id',
1084
+ traits: { emails: ['test@email.com'] },
1085
+ };
1086
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1087
+ mockInvokeFunction.mockResolvedValueOnce({
1088
+ statusCode: 500,
1089
+ body: 'Internal Server Error',
1090
+ });
1091
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(pixelId, incomingIdentity, lambdaArn);
1092
+ expect(result).toEqual(incomingIdentity);
1093
+ });
1094
+ it('should return incoming identity when Neptune returns malformed JSON (fail-open)', async () => {
1095
+ const incomingIdentity = {
1096
+ identityId: 'test-id',
1097
+ traits: { emails: ['test@email.com'] },
1098
+ };
1099
+ mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
1100
+ mockInvokeFunction.mockResolvedValueOnce({
1101
+ statusCode: 200,
1102
+ body: 'not valid json',
1103
+ });
1104
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(pixelId, incomingIdentity, lambdaArn);
1105
+ expect(result).toEqual(incomingIdentity);
1106
+ });
1107
+ it('should return undefined only when both inputs are invalid', async () => {
1108
+ const result = await IdentityCacheDynamoDbService.getIdentityWithCaching(null, null, lambdaArn);
1109
+ expect(result).toBeUndefined();
1110
+ });
1111
+ });
1112
+ describe('gsi1pk attribute on all records', () => {
1113
+ const pixelId = 'pixel123';
1114
+ it('should include gsi1pk on identity record', async () => {
1115
+ const identity = {
1116
+ identityId: 'identity456',
1117
+ traits: { emails: ['test@email.com'] },
1118
+ };
1119
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
1120
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
1121
+ const identityItem = writtenItems.find((item) => item.pk.startsWith('identity#'));
1122
+ expect(identityItem).toBeDefined();
1123
+ expect(identityItem.gsi1pk).toBe('identity456');
1124
+ });
1125
+ it('should include gsi1pk on all email pointer records', async () => {
1126
+ const identity = {
1127
+ identityId: 'identity456',
1128
+ traits: { emails: ['email1@test.com', 'email2@test.com'] },
1129
+ };
1130
+ await IdentityCacheDynamoDbService.updateIdentityCache(pixelId, identity);
1131
+ const writtenItems = mockedDynamoDbClient.safeBatchWrite.mock.calls[0][1];
1132
+ const emailItems = writtenItems.filter((item) => item.pk.startsWith('email#'));
1133
+ for (const emailItem of emailItems) {
1134
+ expect(emailItem.gsi1pk).toBe('identity456');
1135
+ }
1136
+ });
1137
+ });
782
1138
  });
783
1139
  //# sourceMappingURL=identity-cache-dynamodb-service.spec.js.map