@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.
- package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js +402 -46
- package/dist/cjs/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js.map +1 -1
- package/dist/cjs/services/db/identity-cache-dynamodb-service.d.ts +3 -3
- package/dist/cjs/services/db/identity-cache-dynamodb-service.js +57 -86
- package/dist/cjs/services/db/identity-cache-dynamodb-service.js.map +1 -1
- package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js +402 -46
- package/dist/esm/__tests__/identity-cache/identity-cache-dynamodb-service.spec.js.map +1 -1
- package/dist/esm/services/db/identity-cache-dynamodb-service.d.ts +3 -3
- package/dist/esm/services/db/identity-cache-dynamodb-service.js +58 -87
- package/dist/esm/services/db/identity-cache-dynamodb-service.js.map +1 -1
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
249
|
+
mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
|
|
241
250
|
await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
|
|
242
251
|
traits: { emails: [email] },
|
|
243
252
|
});
|
|
244
|
-
expect(mockedDynamoDbClient.
|
|
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
|
|
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.
|
|
260
|
+
mockedDynamoDbClient.safeGet.mockResolvedValueOnce(null);
|
|
254
261
|
await IdentityCacheDynamoDbService.getIdentityFromCache(pixelId, {
|
|
255
262
|
traits: { userIds: [userId] },
|
|
256
263
|
});
|
|
257
|
-
expect(mockedDynamoDbClient.safeBatchGet).
|
|
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
|
|
326
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
341
|
-
|
|
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
|
|
349
|
-
const
|
|
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: '
|
|
353
|
-
|
|
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.
|
|
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).
|
|
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.
|
|
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
|
|
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
|
|
397
|
-
mockedDynamoDbClient.
|
|
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
|
|
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(
|
|
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
|