From 33b87be9b0efdf995af366aae200ad3b8ed4ed6d Mon Sep 17 00:00:00 2001 From: shrouti1507 Date: Mon, 29 Apr 2024 17:39:20 +0530 Subject: [PATCH] feat: unit test cases with minor edits --- src/cdk/v2/destinations/emersys/config.js | 26 +- .../v2/destinations/emersys/procWorkflow.yaml | 2 +- src/cdk/v2/destinations/emersys/utils.js | 74 +++-- src/cdk/v2/destinations/emersys/utils.test.js | 290 ++++++++++++++++++ src/constants/destinationCanonicalNames.js | 1 + 5 files changed, 346 insertions(+), 47 deletions(-) create mode 100644 src/cdk/v2/destinations/emersys/utils.test.js diff --git a/src/cdk/v2/destinations/emersys/config.js b/src/cdk/v2/destinations/emersys/config.js index a15044b19d..604cac8cec 100644 --- a/src/cdk/v2/destinations/emersys/config.js +++ b/src/cdk/v2/destinations/emersys/config.js @@ -1,26 +1,8 @@ -const { getMappingConfig } = require('../../../../v0/util'); - -// ref : https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-02&tabs=http#adding-multiple-conversion-events-in-a-batch -const BATCH_ENDPOINT = 'https://api.linkedin.com/rest/conversionEvents'; -const API_HEADER_METHOD = 'BATCH_CREATE'; -const API_VERSION = '202402'; // yyyymm format -const API_PROTOCOL_VERSION = '2.0.0'; - -const CONFIG_CATEGORIES = { - USER_INFO: { - name: 'linkedinUserInfoConfig', - type: 'user', - }, -}; - -const MAPPING_CONFIG = getMappingConfig(CONFIG_CATEGORIES, __dirname); +const ALLOWED_OPT_IN_VALUES = ['1', '2', '']; module.exports = { MAX_BATCH_SIZE: 1000, - BATCH_ENDPOINT, - API_HEADER_METHOD, - API_VERSION, - API_PROTOCOL_VERSION, - CONFIG_CATEGORIES, - MAPPING_CONFIG, + EMAIL_FIELD_ID: 3, + OPT_IN_FILED_ID: 31, + ALLOWED_OPT_IN_VALUES, }; diff --git a/src/cdk/v2/destinations/emersys/procWorkflow.yaml b/src/cdk/v2/destinations/emersys/procWorkflow.yaml index 322631a515..7b4ba51529 100644 --- a/src/cdk/v2/destinations/emersys/procWorkflow.yaml +++ b/src/cdk/v2/destinations/emersys/procWorkflow.yaml @@ -38,7 +38,7 @@ steps: - name: preparePayloadForIdentify description: | Builds identify payload. - ref: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-02&tabs=curl#adding-multiple-conversion-events-in-a-batch + ref: template: | $.context.payload = $.buildIdentifyPayload(.message, .destination.Config,); - name: preparePayloadForGroup diff --git a/src/cdk/v2/destinations/emersys/utils.js b/src/cdk/v2/destinations/emersys/utils.js index c943b90ad2..f1aa5cdf2b 100644 --- a/src/cdk/v2/destinations/emersys/utils.js +++ b/src/cdk/v2/destinations/emersys/utils.js @@ -2,7 +2,6 @@ import { EVENT_TYPE } from 'rudder-transformer-cdk/build/constants'; const lodash = require('lodash'); const crypto = require('crypto'); -const get = require('get-value'); const { InstrumentationError, @@ -13,7 +12,12 @@ const { } = require('@rudderstack/integrations-lib'); const { getValueFromMessage } = require('rudder-transformer-cdk/build/utils'); const { getIntegrationsObj } = require('../../../../v0/util'); -const { EMAIL_FIELD_ID, MAX_BATCH_SIZE } = require('./config'); +const { + EMAIL_FIELD_ID, + MAX_BATCH_SIZE, + OPT_IN_FILED_ID, + ALLOWED_OPT_IN_VALUES, +} = require('./config'); function base64Sha(str) { const hexDigest = crypto.createHash('sha1').update(str).digest('hex'); @@ -42,30 +46,46 @@ const buildIdentifyPayload = (message, destination) => { const { fieldMapping, emersysCustomIdentifier, discardEmptyProperties, defaultContactList } = destination.Config; const payload = {}; + + const integrationObject = getIntegrationsObj(message, 'emersys'); + const finalContactList = integrationObject?.contactListId || defaultContactList; + + if (!isDefinedAndNotNullAndNotEmpty(finalContactList)) { + throw new InstrumentationError( + 'Cannot a find a specific contact list either through configuration or via integrations object', + ); + } if (fieldMapping) { fieldMapping.forEach((trait) => { const { rudderProperty, emersysProperty } = trait; - const value = get(message, rudderProperty); + const value = + getValueFromMessage(message.traits, rudderProperty) || + getValueFromMessage(message.context.traits, rudderProperty); if (value) { payload[emersysProperty] = value; } }); } - - const emersysIdentifier = emersysCustomIdentifier || EMAIL_FIELD_ID; + const emersysIdentifier = + integrationObject?.customIdentifierId || emersysCustomIdentifier || EMAIL_FIELD_ID; const finalPayload = discardEmptyProperties === true ? removeUndefinedAndNullAndEmptyValues(payload) // empty property value has a significance in emersys : removeUndefinedAndNullValues(payload); - const integrationObject = getIntegrationsObj(message, 'emersys'); - - // TODO: add validation for opt in field + if ( + isDefinedAndNotNull(finalPayload[OPT_IN_FILED_ID]) && + !ALLOWED_OPT_IN_VALUES.includes(String(finalPayload[OPT_IN_FILED_ID])) + ) { + throw new InstrumentationError( + `Only ${ALLOWED_OPT_IN_VALUES} values are allowed for optin field`, + ); + } if (isDefinedAndNotNullAndNotEmpty(payload[emersysIdentifier])) { destinationPayload = { - key_id: integrationObject.customIdentifierId || emersysIdentifier, - contacts: [...finalPayload], - contact_list_id: integrationObject.contactListId || defaultContactList, + key_id: emersysIdentifier, + contacts: [finalPayload], + contact_list_id: finalContactList, }; } else { throw new InstrumentationError( @@ -87,14 +107,16 @@ const buildGroupPayload = (message, destination) => { const { emersysCustomIdentifier, defaultContactList, fieldMapping } = destination.Config; const integrationObject = getIntegrationsObj(message, 'emersys'); const emersysIdentifier = - integrationObject.customIdentifierId || emersysCustomIdentifier || EMAIL_FIELD_ID; + integrationObject?.customIdentifierId || emersysCustomIdentifier || EMAIL_FIELD_ID; const configuredPayloadProperty = findRudderPropertyByEmersysProperty( emersysIdentifier, fieldMapping, ); const externalIdValue = getValueFromMessage(message.context.traits, configuredPayloadProperty); if (!isDefinedAndNotNull(externalIdValue)) { - throw new InstrumentationError(''); + throw new InstrumentationError( + `No value found in payload for contact custom identifier of id ${emersysIdentifier}`, + ); } const payload = { key_id: emersysIdentifier, @@ -162,30 +184,29 @@ function ensureSizeConstraints(contacts) { } function createIdentifyBatches(events) { - // Grouping payloads by key_id and contact_list_id const groupedIdentifyPayload = lodash.groupBy( events, (item) => `${item.message.body.JSON.destinationPayload.key_id}-${item.message.body.JSON.destinationPayload.contact_list_id}`, ); - // Process each group return lodash.flatMap(groupedIdentifyPayload, (group) => { const firstItem = group[0].message.body.JSON.destinationPayload; // eslint-disable-next-line @typescript-eslint/naming-convention const { key_id, contact_list_id } = firstItem; - // Collect all contacts const allContacts = lodash.flatMap( group, (item) => item.message.body.JSON.destinationPayload.contacts, ); - // Chunk by the number of items first, then size const initialChunks = lodash.chunk(allContacts, MAX_BATCH_SIZE); const finalChunks = lodash.flatMap(initialChunks, ensureSizeConstraints); - // Create payloads for each chunk - return finalChunks.map((contacts) => createPayload(key_id, contacts, contact_list_id)); + // Include metadata for each chunk + return finalChunks.map((contacts) => ({ + payload: createPayload(key_id, contacts, contact_list_id), + metadata: group.map((g) => g.metadata), // assuming metadata is needed per original event grouping + })); }); } @@ -201,18 +222,19 @@ function createGroupBatches(events) { const keyId = group[0].message.body.JSON.destinationPayload.payload.key_id; const { contactListId } = group[0].message.body.JSON.destinationPayload; const combinedExternalIds = group.reduce((acc, item) => { - const ids = item.message.body.JSON.destinationPayload.payload.external_ids; - return acc.concat(ids); + acc.push(...item.message.body.JSON.destinationPayload.payload.external_ids); + return acc; }, []); const idChunks = lodash.chunk(combinedExternalIds, MAX_BATCH_SIZE); - // Map each chunk to a payload configuration + return idChunks.map((chunk) => ({ endpoint: `https://api.emarsys.net/api/v2/contactlist/${contactListId}/add`, payload: { key_id: keyId, external_ids: chunk, }, + metadata: group.map((g) => g.metadata), // assuming metadata is needed per original event grouping })); }); } @@ -227,7 +249,7 @@ function buildBatchedRequest(batches, method, constants) { return batches.map((batch) => ({ batchedRequest: { body: { - JSON: batch.payload, // Directly use the payload from the batch + JSON: batch.payload, JSON_ARRAY: {}, XML: {}, FORM: {}, @@ -240,7 +262,7 @@ function buildBatchedRequest(batches, method, constants) { params: {}, files: {}, }, - metadata: chunkedMetadata, + metadata: batch.metadata, batched: true, statusCode: 200, destination: constants.destination, @@ -319,4 +341,8 @@ module.exports = { buildHeader, deduceEndPoint, batchResponseBuilder, + base64Sha, + getWsseHeader, + findRudderPropertyByEmersysProperty, + formatIdentifyPayloadsWithEndpoint, }; diff --git a/src/cdk/v2/destinations/emersys/utils.test.js b/src/cdk/v2/destinations/emersys/utils.test.js new file mode 100644 index 0000000000..d45171a82c --- /dev/null +++ b/src/cdk/v2/destinations/emersys/utils.test.js @@ -0,0 +1,290 @@ +const { EVENT_TYPE } = require('rudder-transformer-cdk/build/constants'); +const { + buildIdentifyPayload, + buildGroupPayload, + deduceEndPoint, + batchResponseBuilder, + base64Sha, + getWsseHeader, + findRudderPropertyByEmersysProperty, + formatIdentifyPayloadsWithEndpoint, +} = require('./utils'); +const crypto = require('crypto'); +const { InstrumentationError } = require('@rudderstack/integrations-lib'); + +describe('base64Sha', () => { + it('should return a base64 encoded SHA1 hash of the input string', () => { + const input = 'test'; + const expected = 'YTk0YThmZTVjY2IxOWJhNjFjNGMwODczZDM5MWU5ODc5ODJmYmJkMw=='; + const result = base64Sha(input); + expect(result).toEqual(expected); + }); + + it('should return an empty string when input is empty', () => { + const input = ''; + const expected = 'ZGEzOWEzZWU1ZTZiNGIwZDMyNTViZmVmOTU2MDE4OTBhZmQ4MDcwOQ=='; + const result = base64Sha(input); + expect(result).toEqual(expected); + }); +}); + +describe('getWsseHeader', () => { + beforeEach(() => { + jest + .spyOn(crypto, 'randomBytes') + .mockReturnValue(Buffer.from('abcdef1234567890abcdef1234567890', 'hex')); + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2024-04-28T12:34:56.789Z'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should generate a correct WSSE header', () => { + const user = 'testUser'; + const secret = 'testSecret'; + const expectedNonce = 'abcdef1234567890abcdef1234567890'; + const expectedTimestamp = '2024-04-28T12:34:56.789Z'; + const expectedDigest = base64Sha(expectedNonce + expectedTimestamp + secret); + const expectedHeader = `UsernameToken Username="${user}", PasswordDigest="${expectedDigest}", Nonce="${expectedNonce}", Created="${expectedTimestamp}"`; + const result = getWsseHeader(user, secret); + + expect(result).toBe(expectedHeader); + }); +}); + +describe('buildIdentifyPayload', () => { + it('should correctly build payload with field mapping', () => { + const message = { + type: 'identify', + traits: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + optin: 1, + }, + }; + const destination = { + Config: { + fieldMapping: [ + { rudderProperty: 'firstName', emersysProperty: '1' }, + { rudderProperty: 'lastName', emersysProperty: '2' }, + { rudderProperty: 'email', emersysProperty: '3' }, + { rudderProperty: 'optin', emersysProperty: '31' }, + ], + defaultContactList: 'dummyContactList', + }, + }; + const expectedPayload = { + contact_list_id: 'dummyContactList', + contacts: [ + { + 1: 'John', + 2: 'Doe', + 3: 'john.doe@example.com', + 31: 1, + }, + ], + key_id: 3, + }; + + const result = buildIdentifyPayload(message, destination); + + expect(result.eventType).toBe(EVENT_TYPE.IDENTIFY); + expect(result.destinationPayload).toEqual(expectedPayload); + }); + + it('should throw error when opt-in field value is not allowed', () => { + const message = { + type: 'identify', + traits: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + optin: 3, + }, + }; + const destination = { + Config: { + fieldMapping: [ + { rudderProperty: 'firstName', emersysProperty: '1' }, + { rudderProperty: 'lastName', emersysProperty: '2' }, + { rudderProperty: 'email', emersysProperty: '3' }, + { rudderProperty: 'optin', emersysProperty: '31' }, + ], + defaultContactList: 'dummyList', + }, + }; + expect(() => { + buildIdentifyPayload(message, destination); + }).toThrow('Only 1,2, values are allowed for optin field'); + }); + + it('should throw error when no contact list can be assigned field value is not allowed', () => { + const message = { + type: 'identify', + traits: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + optin: 1, + }, + }; + const destination = { + Config: { + fieldMapping: [ + { rudderProperty: 'firstName', emersysProperty: '1' }, + { rudderProperty: 'lastName', emersysProperty: '2' }, + { rudderProperty: 'email', emersysProperty: '3' }, + { rudderProperty: 'optin', emersysProperty: '31' }, + ], + }, + }; + expect(() => { + buildIdentifyPayload(message, destination); + }).toThrow( + 'Cannot a find a specific contact list either through configuration or via integrations object', + ); + }); + + it('should correctly build payload with field mapping present in integrations object', () => { + const message = { + type: 'identify', + traits: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + optin: 1, + }, + integrations: { + EMERSYS: { + customIdentifierId: 1, + contactListId: 'objectListId', + }, + }, + }; + const destination = { + Config: { + fieldMapping: [ + { rudderProperty: 'firstName', emersysProperty: '1' }, + { rudderProperty: 'lastName', emersysProperty: '2' }, + { rudderProperty: 'email', emersysProperty: '3' }, + { rudderProperty: 'optin', emersysProperty: '31' }, + ], + defaultContactList: 'dummyContactList', + }, + }; + const expectedPayload = { + contact_list_id: 'objectListId', + contacts: [ + { + 1: 'John', + 2: 'Doe', + 3: 'john.doe@example.com', + 31: 1, + }, + ], + key_id: 1, + }; + + const result = buildIdentifyPayload(message, destination); + + expect(result.eventType).toBe(EVENT_TYPE.IDENTIFY); + expect(result.destinationPayload).toEqual(expectedPayload); + }); +}); + +describe('buildGroupPayload', () => { + // Returns an object with eventType and destinationPayload keys when given valid message and destination inputs + it('should return an object with eventType and destinationPayload keys when given valid message and destination inputs with default externalId', () => { + const message = { + type: 'group', + groupId: 'group123', + context: { + traits: { + email: 'test@example.com', + }, + }, + }; + const destination = { + Config: { + emersysCustomIdentifier: '3', + defaultContactList: 'list123', + fieldMapping: [ + { emersysProperty: '100', rudderProperty: 'customId' }, + { emersysProperty: '3', rudderProperty: 'email' }, + ], + }, + }; + const result = buildGroupPayload(message, destination); + expect(result).toEqual({ + eventType: 'group', + destinationPayload: { + payload: { + key_id: '3', + external_ids: ['test@example.com'], + }, + contactListId: 'group123', + }, + }); + }); + + it('should return an object with eventType and destinationPayload keys when given valid message and destination inputs with configured externalId', () => { + const message = { + type: 'group', + groupId: 'group123', + context: { + traits: { + email: 'test@example.com', + customId: '123', + }, + }, + }; + const destination = { + Config: { + emersysCustomIdentifier: '100', + defaultContactList: 'list123', + fieldMapping: [ + { emersysProperty: '100', rudderProperty: 'customId' }, + { emersysProperty: '3', rudderProperty: 'email' }, + ], + }, + }; + const result = buildGroupPayload(message, destination); + expect(result).toEqual({ + eventType: 'group', + destinationPayload: { + payload: { + key_id: '100', + external_ids: ['123'], + }, + contactListId: 'group123', + }, + }); + }); + + it('should throw an InstrumentationError if emersysCustomIdentifier value is not present in payload', () => { + const message = { + type: 'group', + groupId: 'group123', + context: { + traits: { + email: 'test@example.com', + }, + }, + }; + const destination = { + Config: { + emersysCustomIdentifier: 'customId', + defaultContactList: 'list123', + fieldMapping: [ + { emersysProperty: 'customId', rudderProperty: 'customId' }, + { emersysProperty: 'email', rudderProperty: 'email' }, + ], + }, + }; + expect(() => { + buildGroupPayload(message, destination); + }).toThrow(InstrumentationError); + }); +}); diff --git a/src/constants/destinationCanonicalNames.js b/src/constants/destinationCanonicalNames.js index ee4f4f0b33..3cc05e70e3 100644 --- a/src/constants/destinationCanonicalNames.js +++ b/src/constants/destinationCanonicalNames.js @@ -166,6 +166,7 @@ const DestCanonicalNames = { ], koala: ['Koala', 'koala', 'KOALA'], bloomreach: ['Bloomreach', 'bloomreach', 'BLOOMREACH'], + emersys: ['EMERSYS', 'Emersys', 'emersys'], }; module.exports = { DestHandlerMap, DestCanonicalNames };