diff --git a/src/Rokt-Kit.js b/src/Rokt-Kit.js index 1e11709..dd9a66e 100644 --- a/src/Rokt-Kit.js +++ b/src/Rokt-Kit.js @@ -36,8 +36,150 @@ var constructor = function () { self.userAttributes = {}; self.testHelpers = null; self.placementEventMappingLookup = {}; + self.placementEventAttributeMappingLookup = {}; self.eventQueue = []; + function getEventAttributeValue(event, eventAttributeKey) { + var attributes = event && event.EventAttributes; + if (!attributes) { + return null; + } + + if (typeof attributes[eventAttributeKey] === 'undefined') { + return null; + } + + return attributes[eventAttributeKey]; + } + + function doesEventAttributeConditionMatch(condition, actualValue) { + if (!condition || typeof condition.operator !== 'string') { + return false; + } + + var operator = condition.operator.toLowerCase(); + var expectedValue = condition.attributeValue; + + if (operator === 'exists') { + return actualValue !== null; + } + + if (operator === 'equals') { + return actualValue === expectedValue; + } + + if (operator === 'contains') { + if ( + typeof actualValue !== 'string' || + typeof expectedValue !== 'string' + ) { + return false; + } + return actualValue.indexOf(expectedValue) !== -1; + } + + return false; + } + + function doesEventMatchRule(event, rule) { + if (!rule || typeof rule.eventAttributeKey !== 'string') { + return false; + } + + var conditions = rule.conditions; + if (!Array.isArray(conditions)) { + return false; + } + + if (conditions.length === 0) { + return true; + } + + var actualValue = getEventAttributeValue(event, rule.eventAttributeKey); + for (var i = 0; i < conditions.length; i++) { + if (!doesEventAttributeConditionMatch(conditions[i], actualValue)) { + return false; + } + } + + return true; + } + + function generateMappedEventAttributeLookup( + placementEventAttributeMapping + ) { + var mappedAttributeKeys = {}; + if (!Array.isArray(placementEventAttributeMapping)) { + return mappedAttributeKeys; + } + for (var i = 0; i < placementEventAttributeMapping.length; i++) { + var mapping = placementEventAttributeMapping[i]; + if ( + !mapping || + typeof mapping.value !== 'string' || + typeof mapping.map !== 'string' + ) { + continue; + } + + var mappedAttributeKey = mapping.value; + var eventAttributeKey = mapping.map; + + if (!mappedAttributeKeys[mappedAttributeKey]) { + mappedAttributeKeys[mappedAttributeKey] = []; + } + + mappedAttributeKeys[mappedAttributeKey].push({ + eventAttributeKey: eventAttributeKey, + conditions: Array.isArray(mapping.conditions) + ? mapping.conditions + : [], + }); + } + return mappedAttributeKeys; + } + + function applyPlacementEventAttributeMapping(event) { + if ( + !self.placementEventAttributeMappingLookup || + isEmpty(self.placementEventAttributeMappingLookup) + ) { + return; + } + + var mappedAttributeKeys = Object.keys( + self.placementEventAttributeMappingLookup + ); + for (var i = 0; i < mappedAttributeKeys.length; i++) { + var mappedAttributeKey = mappedAttributeKeys[i]; + var rulesForMappedAttributeKey = + self.placementEventAttributeMappingLookup[mappedAttributeKey]; + if ( + !rulesForMappedAttributeKey || + !rulesForMappedAttributeKey.length + ) { + continue; + } + + // Require ALL rules for the same key to match (AND). + var allMatch = true; + for (var j = 0; j < rulesForMappedAttributeKey.length; j++) { + if (!doesEventMatchRule(event, rulesForMappedAttributeKey[j])) { + allMatch = false; + break; + } + } + if (!allMatch) { + continue; + } + + window.mParticle.Rokt.setLocalSessionAttribute( + mappedAttributeKey, + true + ); + } + } + /** * Generates the Rokt launcher script URL with optional domain override and extensions * @param {string} domain - The CNAME domain to use for overriding the launcher url @@ -92,6 +234,12 @@ var constructor = function () { placementEventMapping ); + var placementEventAttributeMapping = parseSettingsString( + settings.placementEventAttributeMapping + ); + self.placementEventAttributeMappingLookup = + generateMappedEventAttributeLookup(placementEventAttributeMapping); + // Set dynamic OTHER_IDENTITY based on server settings // Convert to lowercase since server sends TitleCase (e.g., 'Other' -> 'other') if (settings.hashedEmailUserIdentityType) { @@ -115,6 +263,8 @@ var constructor = function () { hashEventMessage: hashEventMessage, parseSettingsString: parseSettingsString, generateMappedEventLookup: generateMappedEventLookup, + generateMappedEventAttributeLookup: + generateMappedEventAttributeLookup, }; attachLauncher(accountId, launcherOptions); return; @@ -172,13 +322,18 @@ var constructor = function () { function returnLocalSessionAttributes() { if ( - isEmpty(self.placementEventMappingLookup) || !window.mParticle.Rokt || typeof window.mParticle.Rokt.getLocalSessionAttributes !== 'function' ) { return {}; } + if ( + isEmpty(self.placementEventMappingLookup) && + isEmpty(self.placementEventAttributeMappingLookup) + ) { + return {}; + } return window.mParticle.Rokt.getLocalSessionAttributes(); } @@ -306,12 +461,17 @@ var constructor = function () { } if ( - isEmpty(self.placementEventMappingLookup) || typeof window.mParticle.Rokt.setLocalSessionAttribute !== 'function' ) { return; } + applyPlacementEventAttributeMapping(event); + + if (isEmpty(self.placementEventMappingLookup)) { + return; + } + var hashedEvent = hashEventMessage( event.EventDataType, event.EventCategory, diff --git a/test/src/tests.js b/test/src/tests.js index 0b75846..bc38396 100644 --- a/test/src/tests.js +++ b/test/src/tests.js @@ -3055,6 +3055,148 @@ describe('Rokt Forwarder', () => { }); }); + describe('#generateMappedEventAttributeLookup', () => { + beforeEach(async () => { + window.Rokt = new MockRoktForwarder(); + window.mParticle.Rokt = window.Rokt; + + await window.mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true + ); + }); + + it('should generate a lookup table from placementEventAttributeMapping', () => { + const placementEventAttributeMapping = [ + { + jsmap: null, + map: 'number_of_products', + maptype: 'EventAttributeClass.Name', + value: 'tof_products_2', + conditions: [ + { + operator: 'equals', + attributeValue: 2, + }, + ], + }, + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'saleSeeker', + conditions: [ + { + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + ]; + + window.mParticle.forwarder.testHelpers + .generateMappedEventAttributeLookup( + placementEventAttributeMapping + ) + .should.deepEqual({ + tof_products_2: [ + { + eventAttributeKey: 'number_of_products', + conditions: [ + { + operator: 'equals', + attributeValue: 2, + }, + ], + }, + ], + saleSeeker: [ + { + eventAttributeKey: 'URL', + conditions: [ + { + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + ], + }); + }); + + it('should default conditions to an empty array when missing', () => { + const placementEventAttributeMapping = [ + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'hasUrl', + }, + ]; + + window.mParticle.forwarder.testHelpers + .generateMappedEventAttributeLookup( + placementEventAttributeMapping + ) + .should.deepEqual({ + hasUrl: [ + { + eventAttributeKey: 'URL', + conditions: [], + }, + ], + }); + }); + + it('should return an empty object when placementEventAttributeMapping is null', () => { + window.mParticle.forwarder.testHelpers + .generateMappedEventAttributeLookup(null) + .should.deepEqual({}); + }); + + it('should ignore invalid mappings (non-string map/value)', () => { + const placementEventAttributeMapping = [ + { + jsmap: null, + map: null, + maptype: 'EventAttributeClass.Name', + value: 'bad', + conditions: [], + }, + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: null, + conditions: [], + }, + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'good', + conditions: [], + }, + ]; + + window.mParticle.forwarder.testHelpers + .generateMappedEventAttributeLookup( + placementEventAttributeMapping + ) + .should.deepEqual({ + good: [ + { + eventAttributeKey: 'URL', + conditions: [], + }, + ], + }); + }); + }); + describe('#processEvent', () => { beforeEach(() => { window.Rokt = new MockRoktForwarder(); @@ -3148,6 +3290,323 @@ describe('Rokt Forwarder', () => { }); }); + it('should set local session attribute only when placementEventAttributeMapping conditions match (URL contains)', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'saleSeeker', + conditions: [ + { + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/home', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + }); + }); + + it('should support exists operator for placementEventAttributeMapping conditions', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'hasUrl', + conditions: [{ operator: 'exists' }], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/anything', + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + hasUrl: true, + }); + }); + + it('should evaluate equals type-sensitively for placementEventAttributeMapping conditions', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'number_of_products', + maptype: 'EventAttributeClass.Name', + value: 'multipleproducts', + conditions: [ + { + operator: 'equals', + attributeValue: 2, + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: 2, + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + multipleproducts: true, + }); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: '2', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should treat contains as string-only (non-strings do not match) for placementEventAttributeMapping', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'number_of_products', + maptype: 'EventAttributeClass.Name', + value: 'containsNumber', + conditions: [ + { + operator: 'contains', + attributeValue: '2', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: 2, + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should require ALL rules for the same mapped key to match (AND across rules)', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'saleSeeker', + conditions: [ + { + operator: 'contains', + attributeValue: 'sale', + }, + { + operator: 'contains', + attributeValue: 'items', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // Matches only 1/2 rules => should NOT set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + + // Matches both rules => should set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + }); + }); + it('should map multiple attributes for the same mapped key (AND across rules)', async () => { + const placementEventAttributeMapping = JSON.stringify([ + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'saleSeeker', + conditions: [ + { + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + { + jsmap: null, + map: 'URL', + maptype: 'EventAttributeClass.Name', + value: 'saleSeeker1', + conditions: [ + { + operator: 'contains', + attributeValue: 'items', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventAttributeMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // Matches only 1/2 rules => should NOT set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + }); + + // Matches both rules => should set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + saleSeeker1: true, + }); + }); it('should add the event to the event queue if the kit is not initialized', async () => { await window.mParticle.forwarder.init( {