diff --git a/src/constants.ts b/src/constants.ts index 2fbfd343..ffa6a9dd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -174,6 +174,7 @@ const Constants = { DirectUrlRouting: 'directURLRouting', CacheIdentity: 'cacheIdentity', AudienceAPI: 'audienceAPI', + CaptureIntegrationSpecificIds: 'captureIntegrationSpecificIds', }, DefaultInstance: 'default_instance', CCPAPurpose: 'data_sale_opt_out', diff --git a/src/integrationCapture.ts b/src/integrationCapture.ts new file mode 100644 index 00000000..7799b6e3 --- /dev/null +++ b/src/integrationCapture.ts @@ -0,0 +1,153 @@ +import { SDKEventCustomFlags } from './sdkRuntimeModels'; +import { + Dictionary, + queryStringParser, + getCookies, + getHref, + isEmpty, +} from './utils'; + +export interface IntegrationCaptureProcessorFunction { + (clickId: string, url: string, timestamp?: number): string; +} + +// Facebook Click ID has specific formatting rules +// The formatted ClickID value must be of the form version.subdomainIndex.creationTime., where: +// - version is always this prefix: fb +// - subdomainIndex is which domain the cookie is defined on ('com' = 0, 'example.com' = 1, 'www.example.com' = 2) +// - creationTime is the UNIX time since epoch in milliseconds when the _fbc was stored. If you don't save the _fbc cookie, use the timestamp when you first observed or received this fbclid value +// - is the value for the fbclid query parameter in the page URL. +// https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc +export const facebookClickIdProcessor: IntegrationCaptureProcessorFunction = ( + clickId: string, + url: string, + timestamp?: number, +): string => { + if (!clickId || !url) { + return ''; + } + + const urlSegments = url?.split('//') + if (!urlSegments) { + return ''; + } + + const urlParts = urlSegments[1].split('/'); + const domainParts = urlParts[0].split('.'); + let subdomainIndex: number = 1; + + // The rules for subdomainIndex are for parsing the domain portion + // of the URL for cookies, but in this case we are parsing the URL + // itself, so we can ignore the use of 0 for 'com' + if (domainParts.length >= 3) { + subdomainIndex = 2; + } + + // If timestamp is not provided, use the current time + const _timestamp = timestamp || Date.now(); + + return `fb.${subdomainIndex}.${_timestamp}.${clickId}`; +}; +interface IntegrationIdMapping { + [key: string]: { + mappedKey: string; + processor?: IntegrationCaptureProcessorFunction; + }; +} + +const integrationMapping: IntegrationIdMapping = { + fbclid: { + mappedKey: 'Facebook.ClickId', + processor: facebookClickIdProcessor, + }, + _fbp: { + mappedKey: 'Facebook.BrowserId', + }, + _fbc: { + mappedKey: 'Facebook.ClickId', + }, +}; + +export default class IntegrationCapture { + public clickIds: Dictionary; + public readonly initialTimestamp: number; + + constructor() { + this.initialTimestamp = Date.now(); + } + + /** + * Captures Integration Ids from cookies and query params and stores them in clickIds object + */ + public capture(): void { + const queryParams = this.captureQueryParams() || {}; + const cookies = this.captureCookies() || {}; + + // Exclude _fbc if fbclid is present + // https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc#retrieve-from-fbclid-url-query-parameter + if (queryParams['fbclid'] && cookies['_fbc']) { + delete cookies['_fbc']; + } + + this.clickIds = { ...this.clickIds, ...queryParams, ...cookies }; + } + + /** + * Captures cookies based on the integration ID mapping. + */ + public captureCookies(): Dictionary { + const cookies = getCookies(Object.keys(integrationMapping)); + return this.applyProcessors(cookies); + } + + /** + * Captures query parameters based on the integration ID mapping. + */ + public captureQueryParams(): Dictionary { + const queryParams = this.getQueryParams(); + return this.applyProcessors(queryParams, getHref(), this.initialTimestamp); + } + + /** + * Gets the query parameters based on the integration ID mapping. + * @returns {Dictionary} The query parameters. + */ + public getQueryParams(): Dictionary { + return queryStringParser(getHref(), Object.keys(integrationMapping)); + } + + /** + * Converts captured click IDs to custom flags for SDK events. + * @returns {SDKEventCustomFlags} The custom flags. + */ + public getClickIdsAsCustomFlags(): SDKEventCustomFlags { + const customFlags: SDKEventCustomFlags = {}; + + if (!this.clickIds) { + return customFlags; + } + + for (const [key, value] of Object.entries(this.clickIds)) { + const mappedKey = integrationMapping[key]?.mappedKey; + if (!isEmpty(mappedKey)) { + customFlags[mappedKey] = value; + } + } + return customFlags; + } + + private applyProcessors(clickIds: Dictionary, url?: string, timestamp?: number): Dictionary { + const processedClickIds: Dictionary = {}; + + for (const [key, value] of Object.entries(clickIds)) { + const processor = integrationMapping[key]?.processor; + if (processor) { + processedClickIds[key] = processor(value, url, timestamp); + } else { + processedClickIds[key] = value; + } + } + + return processedClickIds; + } +} diff --git a/src/mp-instance.js b/src/mp-instance.js index b65d5d96..14a1ffd9 100644 --- a/src/mp-instance.js +++ b/src/mp-instance.js @@ -39,9 +39,10 @@ import IdentityAPIClient from './identityApiClient'; import { isEmpty, isFunction } from './utils'; import { LocalStorageVault } from './vault'; import { removeExpiredIdentityCacheDates } from './identity-utils'; +import IntegrationCapture from './integrationCapture'; const { Messages, HTTPCodes, FeatureFlags } = Constants; -const { ReportBatching } = FeatureFlags; +const { ReportBatching, CaptureIntegrationSpecificIds } = FeatureFlags; const { StartingInitialization } = Messages.InformationMessages; /** @@ -77,6 +78,7 @@ export default function mParticleInstance(instanceName) { integrationDelays: {}, forwarderConstructors: [], }; + this._IntegrationCapture = new IntegrationCapture(); // required for forwarders once they reference the mparticle instance this.IdentityType = Types.IdentityType; @@ -1336,6 +1338,10 @@ function completeSDKInitialization(apiKey, config, mpInstance) { mpInstance._ForwardingStatsUploader.startForwardingStatsTimer(); } + if (mpInstance._Helpers.getFeatureFlag(CaptureIntegrationSpecificIds)) { + mpInstance._IntegrationCapture.capture(); + } + mpInstance._Forwarders.processForwarders( config, mpInstance._APIClient.prepareForwardingStats diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index e9f35f81..8bf74bfb 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -30,6 +30,7 @@ import { ISDKUserAttributes, } from './identity-user-interfaces'; import { IIdentityType } from './types.interfaces'; +import IntegrationCapture from './integrationCapture'; // TODO: Resolve this with version in @mparticle/web-sdk export type SDKEventCustomFlags = Dictionary; @@ -150,6 +151,7 @@ interface IEvents { export interface MParticleWebSDK { addForwarder(mockForwarder: MPForwarder): void; + _IntegrationCapture: IntegrationCapture; IdentityType: IIdentityType; _Identity: IIdentity; Identity: SDKIdentityApi; diff --git a/src/serverModel.ts b/src/serverModel.ts index 4f50196d..8136cf4f 100644 --- a/src/serverModel.ts +++ b/src/serverModel.ts @@ -8,6 +8,7 @@ import { BaseEvent, MParticleWebSDK, SDKEvent, + SDKEventCustomFlags, SDKGeoLocation, SDKProduct, } from './sdkRuntimeModels'; @@ -317,6 +318,18 @@ export default function ServerModel( event.messageType === Types.MessageType.OptOut || mpInstance._Store.webviewBridgeEnabled ) { + let customFlags: SDKEventCustomFlags = {...event.customFlags}; + + // https://go.mparticle.com/work/SQDSDKS-5053 + if (mpInstance._Helpers.getFeatureFlag && mpInstance._Helpers.getFeatureFlag(Constants.FeatureFlags.CaptureIntegrationSpecificIds)) { + + // Attempt to recapture click IDs in case a third party integration + // has added or updated new click IDs since the last event was sent. + mpInstance._IntegrationCapture.capture(); + const transformedClickIDs = mpInstance._IntegrationCapture.getClickIdsAsCustomFlags(); + customFlags = {...transformedClickIDs, ...customFlags}; + } + if (event.hasOwnProperty('toEventAPIObject')) { eventObject = event.toEventAPIObject(); } else { @@ -336,7 +349,7 @@ export default function ServerModel( event.sourceMessageId || mpInstance._Helpers.generateUniqueId(), EventDataType: event.messageType, - CustomFlags: event.customFlags || {}, + CustomFlags: customFlags, UserAttributeChanges: event.userAttributeChanges, UserIdentityChanges: event.userIdentityChanges, }; diff --git a/src/store.ts b/src/store.ts index 121f8af1..d48158e1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -710,6 +710,7 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags { DirectUrlRouting, CacheIdentity, AudienceAPI, + CaptureIntegrationSpecificIds, } = Constants.FeatureFlags; if (!config.flags) { @@ -727,6 +728,7 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags { flags[DirectUrlRouting] = config.flags[DirectUrlRouting] === 'True'; flags[CacheIdentity] = config.flags[CacheIdentity] === 'True'; flags[AudienceAPI] = config.flags[AudienceAPI] === 'True'; + flags[CaptureIntegrationSpecificIds] = config.flags[CaptureIntegrationSpecificIds] === 'True'; return flags; } diff --git a/src/utils.ts b/src/utils.ts index 77c1663d..f25b7e35 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -222,7 +222,7 @@ const isDataPlanSlug = (str: string): boolean => str === toDataPlanSlug(str); const isStringOrNumber = (value: any): boolean => isString(value) || isNumber(value); -const isEmpty = (value: Dictionary | null | undefined): boolean => +const isEmpty = (value: Dictionary | string | null | undefined): boolean => value == null || !(Object.keys(value) || value).length; const mergeObjects = (...objects: T[]): T => { @@ -232,6 +232,107 @@ const mergeObjects = (...objects: T[]): T => { const moveElementToEnd = (array: T[], index: number): T[] => array.slice(0, index).concat(array.slice(index + 1), array[index]); +const queryStringParser = ( + url: string, + keys: string[] = [] +): Dictionary => { + let urlParams: URLSearchParams | URLSearchParamsFallback; + let results: Dictionary = {}; + + if (!url) return results; + + if (typeof URL !== 'undefined' && typeof URLSearchParams !== 'undefined') { + const urlObject = new URL(url); + urlParams = new URLSearchParams(urlObject.search); + } else { + urlParams = queryStringParserFallback(url); + } + + if (isEmpty(keys)) { + urlParams.forEach((value, key) => { + results[key] = value; + }); + } else { + keys.forEach(key => { + const value = urlParams.get(key); + if (value) { + results[key] = value; + } + }); + } + + return results; +}; + +interface URLSearchParamsFallback { + get: (key: string) => string | null; + forEach: (callback: (value: string, key: string) => void) => void; +} + +const queryStringParserFallback = (url: string): URLSearchParamsFallback => { + let params: Dictionary = {}; + const queryString = url.split('?')[1] || ''; + const pairs = queryString.split('&'); + + pairs.forEach(pair => { + var [key, value] = pair.split('='); + if (key && value) { + params[key] = decodeURIComponent(value || ''); + } + }); + + return { + get: function(key: string) { + return params[key]; + }, + forEach: function(callback: (value: string, key: string) => void) { + for (var key in params) { + if (params.hasOwnProperty(key)) { + callback(params[key], key); + } + } + }, + }; +}; + +// Get cookies as a dictionary +const getCookies = (keys?: string[]): Dictionary => { + // Helper function to parse cookies from document.cookie + const parseCookies = (): string[] => { + if (typeof window === 'undefined') { + return []; + } + return window.document.cookie.split(';').map(cookie => cookie.trim()); + }; + + // Helper function to filter cookies by keys + const filterCookies = ( + cookies: string[], + keys?: string[] + ): Dictionary => { + const results: Dictionary = {}; + for (const cookie of cookies) { + const [key, value] = cookie.split('='); + if (!keys || keys.includes(key)) { + results[key] = value; + } + } + return results; + }; + + // Parse cookies from document.cookie + const parsedCookies: string[] = parseCookies(); + + // Filter cookies by keys if provided + return filterCookies(parsedCookies, keys); +}; + +const getHref = (): string => { + return typeof window !== 'undefined' && window.location + ? window.location.href + : ''; +}; + export { createCookieString, revertCookieString, @@ -262,4 +363,7 @@ export { isValidCustomFlagProperty, mergeObjects, moveElementToEnd, + queryStringParser, + getCookies, + getHref, }; diff --git a/test/jest/integration-capture.spec.ts b/test/jest/integration-capture.spec.ts new file mode 100644 index 00000000..cef4f972 --- /dev/null +++ b/test/jest/integration-capture.spec.ts @@ -0,0 +1,349 @@ +import IntegrationCapture, { + facebookClickIdProcessor, +} from '../../src/integrationCapture'; +import { deleteAllCookies } from './utils'; + +describe('Integration Capture', () => { + describe('constructor', () => { + it('should initialize with clickIds as undefined', () => { + const integrationCapture = new IntegrationCapture(); + expect(integrationCapture.clickIds).toBeUndefined(); + }); + }); + + describe('#capture', () => { + const originalLocation = window.location; + + beforeEach(() => { + delete (window as any).location; + (window as any).location = { + href: '', + search: '', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + }; + + deleteAllCookies(); + }); + + afterEach(() => { + window.location = originalLocation; + jest.restoreAllMocks(); + }); + + it('should call captureCookies and captureQueryParams', () => { + const integrationCapture = new IntegrationCapture(); + integrationCapture.captureCookies = jest.fn(); + integrationCapture.captureQueryParams = jest.fn(); + + integrationCapture.capture(); + + expect(integrationCapture.captureCookies).toHaveBeenCalled(); + expect(integrationCapture.captureQueryParams).toHaveBeenCalled(); + }); + + it('should pass all clickIds to clickIds object', () => { + jest.spyOn(Date, 'now').mockImplementation(() => 42); + + const url = new URL('https://www.example.com/?fbclid=12345&'); + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = '_fbp=54321'; + window.document.cookie = 'baz=qux'; + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + expect(integrationCapture.clickIds).toEqual({ + fbclid: 'fb.2.42.12345', + _fbp: '54321', + }); + }); + + it('should format fbclid correctly', () => { + jest.spyOn(Date, 'now').mockImplementation(() => 42); + + const url = new URL( + 'https://www.example.com/?fbclid=AbCdEfGhIjKlMnOpQrStUvWxYz1234567890' + ); + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = 'baz=qux'; + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + expect(integrationCapture.clickIds).toEqual({ + fbclid: + 'fb.2.42.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', + }); + }); + + it('should pass the _fbc value unaltered', () => { + const url = new URL('https://www.example.com/?foo=bar'); + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = + '_fbc=fb.1.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + window.document.cookie = 'baz=qux'; + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + expect(integrationCapture.clickIds).toEqual({ + _fbc: 'fb.1.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', + }); + }); + + it('should pass the _fbp value unaltered', () => { + const url = new URL('https://www.example.com/?foo=bar'); + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = '_fbp=54321'; + window.document.cookie = 'baz=qux'; + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + expect(integrationCapture.clickIds).toEqual({ + _fbp: '54321', + }); + }); + + it('should prioritize fbclid over _fbc', () => { + jest.spyOn(Date, 'now').mockImplementation(() => 42); + + const url = new URL('https://www.example.com/?fbclid=12345&'); + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = '_fbc=fb.1.23.654321'; + window.document.cookie = 'baz=qux'; + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + expect(integrationCapture.clickIds).toEqual({ + fbclid: 'fb.2.42.12345', + }); + }); + + }); + + describe('#captureQueryParams', () => { + const originalLocation = window.location; + + beforeEach(() => { + delete (window as any).location; + (window as any).location = { + href: '', + search: '', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + }; + }); + + afterEach(() => { + window.location = originalLocation; + jest.restoreAllMocks(); + }); + + it('should capture specific query params into clickIds object', () => { + jest.spyOn(Date, 'now').mockImplementation(() => 42); + + const url = new URL( + 'https://www.example.com/?ttclid=12345&fbclid=67890&gclid=54321' + ); + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + const clickIds = integrationCapture.captureQueryParams(); + + expect(clickIds).toEqual({ + fbclid: 'fb.2.42.67890', + }); + }); + + it('should NOT capture query params if they are not mapped', () => { + const url = new URL( + 'https://www.example.com/?invalidid=12345&foo=bar' + ); + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + const clickIds = integrationCapture.captureQueryParams(); + + expect(clickIds).toEqual({}); + }); + + it('should format fbclid correctly with the same timestamp on subsequent captures', () => { + const url = new URL( + 'https://www.example.com/?fbclid=AbCdEfGhIjKlMnOpQrStUvWxYz1234567890' + ); + + window.location.href = url.href; + window.location.search = url.search; + + const integrationCapture = new IntegrationCapture(); + integrationCapture.capture(); + + const firstCapture = integrationCapture.captureQueryParams(); + + integrationCapture.capture(); + + const secondCapture = integrationCapture.captureQueryParams(); + + expect(firstCapture).toEqual(secondCapture); + }); + }); + + describe('#captureCookies', () => { + beforeEach(() => { + deleteAllCookies(); + }); + + it('should capture specific cookies into clickIds object', () => { + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = '_fbp=54321'; + window.document.cookie = 'baz=qux'; + + const integrationCapture = new IntegrationCapture(); + const clickIds = integrationCapture.captureCookies(); + + expect(clickIds).toEqual({ + _fbp: '54321', + }); + }); + + it('should NOT capture cookies if they are not mapped', () => { + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = 'baz=qux'; + + const integrationCapture = new IntegrationCapture(); + const clickIds = integrationCapture.captureCookies(); + + expect(clickIds).toEqual({}); + }); + }); + + describe('#getClickIdsAsCustomFlags', () => { + it('should return clickIds as custom flags', () => { + const integrationCapture = new IntegrationCapture(); + integrationCapture.clickIds = { + fbclid: '67890', + _fbp: '54321', + }; + + const customFlags = integrationCapture.getClickIdsAsCustomFlags(); + + expect(customFlags).toEqual({ + 'Facebook.ClickId': '67890', + 'Facebook.BrowserId': '54321', + }); + }); + + it('should return empty object if clickIds is empty or undefined', () => { + const integrationCapture = new IntegrationCapture(); + const customFlags = integrationCapture.getClickIdsAsCustomFlags(); + + expect(customFlags).toEqual({}); + }); + }); + + describe('#facebookClickIdProcessor', () => { + it('returns a formatted clickId if it is passed in as a partial click id', () => { + const partialClickId = 'AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + const expectedClickId = + 'fb.2.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + expect( + facebookClickIdProcessor( + partialClickId, + 'https://www.example.com/', + 1554763741205, + ) + ).toEqual(expectedClickId); + }); + + it('should start with a prefix of `fb`', () => { + const url = 'https://example.com/path/to/something'; + expect(facebookClickIdProcessor('AbCdEfGhI', url, 1554763741205)).toMatch(/^fb\./); + }); + + it('should have `1` in the second portion if the host is example.com', () => { + const url = 'https://example.com/path/to/something'; + const partialClickId = 'AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + const expectedClickId = 'fb.1.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + expect(facebookClickIdProcessor(partialClickId, url, 1554763741205)).toEqual( + expectedClickId + ); + }); + + it('should have `2` in the second portion if the host is www.example.com', () => { + const url = 'https://www.example.com/path/to/something'; + const partialClickId = 'AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + const expectedClickId = 'fb.2.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + expect(facebookClickIdProcessor(partialClickId, url, 1554763741205)).toEqual( + expectedClickId + ); + }); + + it('should have `2` in the second portion if the host is nested subdomains', () => { + const url = 'https://extra.subdomain.web-3.example.com/path/to/something'; + const partialClickId = 'AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + const expectedClickId = 'fb.2.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890'; + + expect(facebookClickIdProcessor(partialClickId, url, 1554763741205)).toEqual( + expectedClickId + ); + }); + + it('returns an empty string if the clickId or url is not valid', () => { + const expectedClickId = ''; + + expect(facebookClickIdProcessor(null, null)).toEqual(expectedClickId); + expect(facebookClickIdProcessor(undefined, undefined)).toEqual( + expectedClickId + ); + expect(facebookClickIdProcessor('', '')).toEqual(expectedClickId); + expect( + facebookClickIdProcessor((NaN as unknown) as string, (NaN as unknown) as string) + ).toEqual(expectedClickId); + expect(facebookClickIdProcessor((0 as unknown) as string, (0 as unknown) as string)).toEqual( + expectedClickId + ); + }); + }); +}); diff --git a/test/jest/utils.spec.ts b/test/jest/utils.spec.ts new file mode 100644 index 00000000..8ae27d15 --- /dev/null +++ b/test/jest/utils.spec.ts @@ -0,0 +1,162 @@ +import { queryStringParser, getCookies, getHref } from '../../src/utils'; +import { deleteAllCookies } from './utils'; + + +describe('Utils', () => { + describe('getCookies', () => { + beforeEach(() => { + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = 'foo=bar'; + window.document.cookie = 'baz=qux'; + }); + + afterEach(() => { + deleteAllCookies(); + }); + + it('returns all cookies as an object', () => { + const expectedResult = { + foo: 'bar', + '_cookie1': '1234', + '_cookie2': '39895811.9165333198', + baz: 'qux', + }; + + expect(getCookies()).toEqual(expectedResult); + }); + + it('returns only the cookies that match the keys', () => { + const expectedResult = { + foo: 'bar', + baz: 'qux', + }; + + expect(getCookies(['foo', 'baz'])).toEqual(expectedResult); + }); + + it('returns an empty object if no keys are found', () => { + expect(getCookies(['quux', 'corge'])).toEqual({}); + }); + + it('returns an empty object if there are no cookies', () => { + deleteAllCookies(); + + expect(getCookies()).toEqual({}); + }); + + it('returns an empty object if window is undefined', () => { + const originalWindow = global.window; + delete global.window; + + expect(getCookies()).toEqual({}); + + global.window = originalWindow + }); + }); + + describe('queryStringParser', () => { + const url = 'https://www.example.com?q=z&foo=bar&baz=qux&narf=poit'; + + describe('with URLSearchParams', () => { + it('returns an object with the query string parameters that match an array of keys', () => { + const keys = ['foo', 'narf']; + + const expectedResult = { + foo: 'bar', + narf: 'poit', + }; + + expect(queryStringParser(url, keys)).toEqual(expectedResult); + }); + + it('returns an empty object if no keys are found', () => { + const keys = ['quux', 'corge']; + + expect(queryStringParser(url, keys)).toEqual({}); + }); + + it('returns an empty object if the URL is empty', () => { + const keys = ['foo', 'narf']; + + expect(queryStringParser('', keys)).toEqual({}); + }); + + it('returns an empty object if there are no query parameters', () => { + expect(queryStringParser('https://www.example.com', ['foo', 'narf'])).toEqual({}); + }); + + it('returns an object with all the query string parameters if no keys are passed', () => { + const expectedResult = { + q: 'z', + foo: 'bar', + baz: 'qux', + narf: 'poit', + }; + + expect(queryStringParser(url)).toEqual(expectedResult); + }); + }); + + + describe('without URLSearchParams', () => { + beforeEach(() => { + URL = undefined; + URLSearchParams = undefined; + }); + + it('returns an object with the query string parameters that match an array of keys', () => { + const keys = ['foo', 'narf']; + + const expectedResult = { + foo: 'bar', + narf: 'poit', + }; + expect(queryStringParser(url, keys)).toEqual(expectedResult); + }); + + it('returns an empty object if no keys are found', () => { + const keys = ['quux', 'corge']; + + expect(queryStringParser(url, keys)).toEqual({}); + }); + + it('returns an empty object if the URL is empty', () => { + const keys = ['foo', 'narf']; + + expect(queryStringParser('', keys)).toEqual({}); + }); + + it('returns an empty object if there are no query parameters', () => { + expect(queryStringParser('https://www.example.com', ['foo', 'narf'])).toEqual({}); + }); + + it('returns an object with all the query string parameters if no keys are passed', () => { + const expectedResult = { + q: 'z', + foo: 'bar', + baz: 'qux', + narf: 'poit', + }; + + expect(queryStringParser(url, [])).toEqual(expectedResult); + }); + }); + }); + + describe('getHref', () => { + it('returns the current URL', () => { + expect(getHref()).toEqual(window.location.href); + }); + + it('returns an empty string if window is not defined', () => { + const originalWindow = global.window; + delete global.window; + + expect(getHref()).toEqual(''); + + global.window = originalWindow; + }); + }); + +}); diff --git a/test/jest/utils.ts b/test/jest/utils.ts index 94ff377a..fdbd2acc 100644 --- a/test/jest/utils.ts +++ b/test/jest/utils.ts @@ -195,4 +195,12 @@ export interface IMockSideloadedKit extends MockForwarder { export interface IMockSideloadedKitConstructor { new(unregisteredKitInstance: UnregisteredKit): IMockSideloadedKit; +} + +export const deleteAllCookies = ():void => { + document.cookie.split(';').forEach(cookie => { + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie; + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; + }); } \ No newline at end of file diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 9dd2ec5f..94ad40ed 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -35,3 +35,4 @@ import './tests-feature-flags'; import './tests-user'; import './tests-legacy-alias-requests'; import './tests-aliasRequestApiClient'; +import './tests-integration-capture'; \ No newline at end of file diff --git a/test/src/tests-feature-flags.ts b/test/src/tests-feature-flags.ts index 37f4db66..c1d42407 100644 --- a/test/src/tests-feature-flags.ts +++ b/test/src/tests-feature-flags.ts @@ -1,11 +1,15 @@ import Constants from '../../src/constants'; import { MParticleWebSDK } from '../../src/sdkRuntimeModels'; import sinon from 'sinon'; +import { expect } from 'chai'; import fetchMock from 'fetch-mock/esm/client'; import { urls, apiKey, testMPID, MPConfig, } from './config/constants'; +import Utils from './config/utils'; + +const { deleteAllCookies } = Utils; let mockServer; @@ -102,4 +106,69 @@ describe('feature-flags', function() { bond.called.should.eql(false); }); }); + + describe('capture integration specific ids', () => { + beforeEach(() => { + fetchMock.post(urls.events, 200); + mockServer = sinon.createFakeServer(); + mockServer.respondImmediately = true; + + window.document.cookie = '_cookie1=1234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = 'foo=bar'; + window.document.cookie = '_fbp=54321'; + window.document.cookie = 'baz=qux'; + }); + + afterEach(() => { + fetchMock.restore(); + deleteAllCookies(); + sinon.restore(); // Restore all stubs and spies + deleteAllCookies(); + }); + + it('should capture click ids when feature flag is true', () => { + window.mParticle.config.flags = { + captureIntegrationSpecificIds: 'True' + }; + window.mParticle._resetForTests(MPConfig); + + sinon.stub(window.mParticle.getInstance()._IntegrationCapture, 'getQueryParams').returns({ + fbclid: '1234', + }); + + const captureSpy = sinon.spy(window.mParticle.getInstance()._IntegrationCapture, 'capture'); + const clickIdSpy = sinon.spy(window.mParticle.getInstance()._IntegrationCapture, 'getClickIdsAsCustomFlags'); + + // initialize mParticle with feature flag + window.mParticle.init(apiKey, window.mParticle.config); + + const initialTimestamp = window.mParticle.getInstance()._IntegrationCapture.initialTimestamp; + + expect(initialTimestamp).to.be.a('number'); + expect(window.mParticle.getInstance()._IntegrationCapture.clickIds).to.deep.equal({ + fbclid: `fb.1.${initialTimestamp}.1234`, + '_fbp': '54321', + }); + expect(captureSpy.called, 'capture()').to.equal(true); + expect(clickIdSpy.called, 'getClickIdsAsCustomFlags').to.equal(true); + }); + + it('should NOT capture click ids when feature flag is false', () => { + window.mParticle.config.flags = { + captureIntegrationSpecificIds: 'False' + }; + window.mParticle._resetForTests(MPConfig); + + const captureSpy = sinon.spy(window.mParticle.getInstance()._IntegrationCapture, 'capture'); + const clickIdSpy = sinon.spy(window.mParticle.getInstance()._IntegrationCapture, 'getClickIdsAsCustomFlags'); + + // initialize mParticle with feature flag + window.mParticle.init(apiKey, window.mParticle.config); + + expect(window.mParticle.getInstance()._IntegrationCapture.clickIds).not.be.ok; + expect(captureSpy.called, 'capture()').to.equal(false); + expect(clickIdSpy.called, 'getClickIdsAsCustomFlags').to.equal(false); + }); + }); }); diff --git a/test/src/tests-integration-capture.ts b/test/src/tests-integration-capture.ts new file mode 100644 index 00000000..450bcc7c --- /dev/null +++ b/test/src/tests-integration-capture.ts @@ -0,0 +1,73 @@ +import sinon from 'sinon'; +import { expect} from 'chai'; +import Utils from './config/utils'; +import fetchMock from 'fetch-mock/esm/client'; +import { urls, apiKey, testMPID, MPConfig } from "./config/constants"; + +const findEventFromRequest = Utils.findEventFromRequest; +const mParticle = window.mParticle; +let mockServer; + +describe('Integration Capture', () => { + beforeEach(() => { + mParticle._resetForTests(MPConfig); + fetchMock.post(urls.events, 200); + delete mParticle._instances['default_instance']; + mockServer = sinon.createFakeServer(); + mockServer.respondImmediately = true; + + window.mParticle.config.flags = { + captureIntegrationSpecificIds: 'True' + }; + + mockServer.respondWith(urls.identify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + window.document.cookie = '_cookie1=234'; + window.document.cookie = '_cookie2=39895811.9165333198'; + window.document.cookie = 'foo=bar'; + window.document.cookie = '_fbp=54321'; + window.document.cookie = 'baz=qux'; + + + // Mock the query params capture function because we cannot mock window.location.href + sinon.stub(window.mParticle.getInstance()._IntegrationCapture, 'getQueryParams').returns({ + fbclid: '1234', + }); + + mParticle.init(apiKey, window.mParticle.config); + }); + + afterEach(function() { + sinon.restore(); + mockServer.restore(); + fetchMock.restore(); + mParticle._resetForTests(MPConfig); + }); + + + it('should add captured integrations to event custom flags', (done) => { + window.mParticle.logEvent( + 'Test Event', + mParticle.EventType.Navigation, + { mykey: 'myvalue' } + ); + + const testEvent = findEventFromRequest(fetchMock.calls(), 'Test Event'); + + const initialTimestamp = window.mParticle.getInstance()._IntegrationCapture.initialTimestamp; + + expect(testEvent).to.have.property('data'); + expect(testEvent.data).to.have.property('event_name', 'Test Event'); + expect(testEvent.data).to.have.property('custom_flags'); + expect(testEvent.data.custom_flags).to.deep.equal({ + 'Facebook.ClickId': `fb.1.${initialTimestamp}.1234`, + 'Facebook.BrowserId': '54321', + }); + + done(); + }); +}); \ No newline at end of file diff --git a/test/src/tests-store.ts b/test/src/tests-store.ts index 4b003407..0901aad3 100644 --- a/test/src/tests-store.ts +++ b/test/src/tests-store.ts @@ -1239,6 +1239,7 @@ describe('Store', () => { directURLRouting: 'False', cacheIdentity: 'False', audienceAPI: 'False', + captureIntegrationSpecificIds: 'False', }, }; @@ -1256,6 +1257,7 @@ describe('Store', () => { directURLRouting: false, cacheIdentity: false, audienceAPI: false, + captureIntegrationSpecificIds: false, }; expect(store.SDKConfig.flags).to.deep.equal(expectedResult); @@ -1456,6 +1458,7 @@ describe('Store', () => { directURLRouting: false, cacheIdentity: false, audienceAPI: false, + captureIntegrationSpecificIds: false, }; expect(flags).to.deep.equal(expectedResult); @@ -1469,6 +1472,7 @@ describe('Store', () => { directURLRouting: 'True', cacheIdentity: 'True', audienceAPI: 'True', + captureIntegrationSpecificIds: 'True', }; const flags = processFlags( @@ -1482,6 +1486,7 @@ describe('Store', () => { directURLRouting: true, cacheIdentity: true, audienceAPI: true, + captureIntegrationSpecificIds: true, }; expect(flags).to.deep.equal(expectedResult); diff --git a/test/src/tests-utils.ts b/test/src/tests-utils.ts index 3dfd2650..4ae0e2a2 100644 --- a/test/src/tests-utils.ts +++ b/test/src/tests-utils.ts @@ -354,6 +354,14 @@ describe('Utils', () => { it('returns true if object is undefined', () => { expect(isEmpty(undefined)).to.equal(true); }); + + it('returns true if object is an empty string', () => { + expect(isEmpty('')).to.equal(true); + }); + + it('returns false if object is a string', () => { + expect(isEmpty('string')).to.equal(false); + }); }); describe('#isValidCustomFlagProperty', () => {