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