diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..7d5aea1ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @mParticle/sdk-team \ No newline at end of file diff --git a/src/apiClient.ts b/src/apiClient.ts index 5e0a2fe76..9948f12c4 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -10,6 +10,7 @@ import { IMParticleUser, ISDKUserAttributes } from './identity-user-interfaces'; import { AsyncUploader, FetchUploader, XHRUploader } from './uploaders'; import { IMParticleWebSDKInstance } from './mp-instance'; import { appendUserInfo } from './user-utils'; +import { hasExplicitIdentifier } from './identity-utils'; export interface IAPIClient { uploader: BatchUploader | null; @@ -88,6 +89,35 @@ export default function APIClient( }); }; + // When noFunctional is set and there are no identities passed, the SDK will not fully initialize. + // In this case, there will be no MPID, but we still want kits to initialize and forward the event to kits. + // The original event is queued for the MP server upload path so it can be sent once an MPID is returned. + // Returns true if the event was handled by this path (caller should return early). + const handleNoFunctionalPreMpidEvent = (event: SDKEvent, mpid: string | undefined): boolean => { + const noFunctionalWithoutId = + mpInstance._CookieConsentManager?.getNoFunctional() && + !hasExplicitIdentifier(mpInstance._Store); + + if (!noFunctionalWithoutId || mpid || !mpInstance._Store.configurationLoaded || mpInstance._Store.requireDelay) { + return false; + } + + let forwarderEvent = event; + if (kitBlocker?.kitBlockingEnabled) { + forwarderEvent = kitBlocker.createBlockedEvent(event); + } + if (forwarderEvent) { + mpInstance._Forwarders.sendEventToForwarders(forwarderEvent); + event._forwardersAlreadySent = true; + } + mpInstance.Logger.verbose( + 'noFunctional event forwarded to kits and queued for MP server upload when MPID is available.' + ); + mpInstance._Store.eventQueue.push(event); + + return true; + }; + this.sendEventToServer = function(event, _options) { const defaultOptions = { shouldUploadEvent: true, @@ -112,8 +142,14 @@ export default function APIClient( mpInstance._Store.integrationDelayTimeoutStart, Date.now() ); - // We queue events if there is no MPID (MPID is null, or === 0), or there are integrations that that require this to stall because integration attributes - // need to be set, or if we are still fetching the config (self hosted only), and so require delaying events + + if (handleNoFunctionalPreMpidEvent(event, mpid)) { + return; + } + + // We queue events if there is no MPID (MPID is null, or === 0), or there are integrations that + // require this to stall because integration attributes need to be set, or if we are still + // fetching the config (self hosted only), and so require delaying events if ( !mpid || mpInstance._Store.requireDelay || @@ -146,8 +182,10 @@ export default function APIClient( } // We need to check event again, because kitblocking - // can nullify the event - if (event) { + // can nullify the event. + // Skip if forwarders were already called in the noFunctional pre-MPID path + // to prevent double-sending when the event queue is later flushed. + if (event && !event._forwardersAlreadySent) { mpInstance._Forwarders.sendEventToForwarders(event); } } diff --git a/src/batchUploader.ts b/src/batchUploader.ts index b4a5233b7..9e827326f 100644 --- a/src/batchUploader.ts +++ b/src/batchUploader.ts @@ -74,7 +74,10 @@ export class BatchUploader { // so that we don't have to check it every time this.offlineStorageEnabled = this.isOfflineStorageAvailable(); - if (this.offlineStorageEnabled) { + // When noFunctional is true, prevent events/batches storage + const noFunctional = mpInstance._CookieConsentManager?.getNoFunctional(); + + if (this.offlineStorageEnabled && !noFunctional) { this.eventVault = new SessionStorageVault( `${mpInstance._Store.storageName}-events`, { diff --git a/src/cookieSyncManager.ts b/src/cookieSyncManager.ts index afb147436..7a4ea9b63 100644 --- a/src/cookieSyncManager.ts +++ b/src/cookieSyncManager.ts @@ -73,6 +73,12 @@ export default function CookieSyncManager( return; } + // When noFunctional is true, persistence is not saved, so we cannot track cookie sync + // dates. Skip cookie sync to avoid running it on every page load. + if (mpInstance._CookieConsentManager?.getNoFunctional()) { + return; + } + const persistence = mpInstance._Persistence.getPersistence(); if (isEmpty(persistence)) { diff --git a/src/foregroundTimeTracker.ts b/src/foregroundTimeTracker.ts index c64ca3eff..198517dd6 100644 --- a/src/foregroundTimeTracker.ts +++ b/src/foregroundTimeTracker.ts @@ -8,10 +8,12 @@ export default class ForegroundTimeTracker { public startTime: number = 0; public totalTime: number = 0; - constructor(timerKey: string) { + constructor(timerKey: string, private noFunctional: boolean = false) { this.localStorageName = `mprtcl-tos-${timerKey}`; this.timerVault = new LocalStorageVault(this.localStorageName); - this.loadTimeFromStorage(); + if (!this.noFunctional) { + this.loadTimeFromStorage(); + } this.addHandlers(); if (document.hidden === false) { this.startTracking(); @@ -63,7 +65,7 @@ export default class ForegroundTimeTracker { } public updateTimeInPersistence(): void { - if (this.isTrackerActive) { + if (this.isTrackerActive && !this.noFunctional) { this.timerVault.store(Math.round(this.totalTime)); } } diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 3472d9f08..14ac5753e 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -1,5 +1,5 @@ import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants'; -import { Dictionary, parseNumber, isObject, generateHash } from './utils'; +import { Dictionary, parseNumber, isObject, generateHash, isEmpty } from './utils'; import { BaseVault } from './vault'; import Types from './types'; import { @@ -13,6 +13,7 @@ import { IIdentityResponse, IMParticleUser, } from './identity-user-interfaces'; +import { IStore } from './store'; const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; export const CACHE_HEADER = 'x-mp-max-age' as const; @@ -298,3 +299,25 @@ export const hasIdentityRequestChanged = ( JSON.stringify(currentUserIdentities) !== JSON.stringify(newIdentities) ); }; + +/** + * Checks if deviceId or other user identifiers (like email) were explicitly provided + * by the partner via config.deviceId or config.identifyRequest.userIdentities. + * When noFunctional is true, then cookies are blocked, so the partner must explicitly + * pass deviceId or other identifiers to prevent new users from being created on each page load. + * + * @param store - The SDK store (provides SDKConfig.deviceId and SDKConfig.identifyRequest.userIdentities) + * @returns true if deviceId or other identifiers were explicitly provided in config, false otherwise + */ +export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean => { + const userIdentities = store?.SDKConfig?.identifyRequest?.userIdentities; + if ( + userIdentities && + isObject(userIdentities) && + !isEmpty(userIdentities) && + Object.values(userIdentities).some(Boolean) + ) { + return true; + } + return !!store?.SDKConfig?.deviceId; +}; diff --git a/src/mp-instance.ts b/src/mp-instance.ts index ddaf16b2b..f0b509b15 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -37,8 +37,8 @@ import KitBlocker from './kitBlocking'; import ConfigAPIClient, { IKitConfigs } from './configAPIClient'; import IdentityAPIClient from './identityApiClient'; import { isFunction, parseConfig, valueof, generateDeprecationMessage } from './utils'; -import { LocalStorageVault } from './vault'; -import { removeExpiredIdentityCacheDates } from './identity-utils'; +import { DisabledVault, LocalStorageVault } from './vault'; +import { removeExpiredIdentityCacheDates, hasExplicitIdentifier } from './identity-utils'; import IntegrationCapture from './integrationCapture'; import { IPreInit, processReadyQueue } from './pre-init-utils'; import { BaseEvent, MParticleWebSDK, SDKHelpersApi } from './sdkRuntimeModels'; @@ -1135,11 +1135,16 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan * @param {String or Number} value value for session attribute */ this.setSessionAttribute = function(key, value) { - const queued = queueIfNotInitialized(function() { - self.setSessionAttribute(key, value); - }, self); + const skipQueue = + self._CookieConsentManager?.getNoFunctional() && + !hasExplicitIdentifier(self._Store); - if (queued) return; + if (!skipQueue) { + const queued = queueIfNotInitialized(function() { + self.setSessionAttribute(key, value); + }, self); + if (queued) return; + } // Logs to cookie // And logs to in-memory object @@ -1584,6 +1589,13 @@ function createKitBlocker(config, mpInstance) { } function createIdentityCache(mpInstance) { + // Identity expects mpInstance._Identity.idCache to always exist. DisabledVault + // ensures no identity response data is written to localStorage when noFunctional is true + if (mpInstance._CookieConsentManager?.getNoFunctional()) { + return new DisabledVault(`${mpInstance._Store.storageName}-id-cache`, { + logger: mpInstance.Logger, + }); + } return new LocalStorageVault(`${mpInstance._Store.storageName}-id-cache`, { logger: mpInstance.Logger, }); @@ -1666,6 +1678,15 @@ function processIdentityCallback( function queueIfNotInitialized(func, self) { // Core SDK methods must wait for Store initialization if (!self._Store?.isInitialized) { + // When noFunctional is true with no explicit identifier, the SDK will never + // receive an MPID. Let these calls through so events can still reach forwarders immediately. + // sendEventToServer handles queuing for the MP server upload path separately. + const noFunctionalWithoutId = + self._CookieConsentManager?.getNoFunctional() && + !hasExplicitIdentifier(self._Store); + if (noFunctionalWithoutId) { + return false; + } self._preInit.readyQueue.push(function() { if (self._Store?.isInitialized) { func(); diff --git a/src/persistence.js b/src/persistence.js index 7a30d754c..901d31bba 100644 --- a/src/persistence.js +++ b/src/persistence.js @@ -230,6 +230,11 @@ export default function _Persistence(mpInstance) { return; } + // Block mprtcl-v4 localStorage when noFunctional is true + if (mpInstance._CookieConsentManager?.getNoFunctional()) { + return; + } + var key = mpInstance._Store.storageName, localStorageData = self.getLocalStorage() || {}, currentUser = mpInstance.Identity.getCurrentUser(), @@ -398,6 +403,11 @@ export default function _Persistence(mpInstance) { // https://go.mparticle.com/work/SQDSDKS-5022 // https://go.mparticle.com/work/SQDSDKS-6021 this.setCookie = function() { + // Block mprtcl-v4 cookies when noFunctional is true + if (mpInstance._CookieConsentManager?.getNoFunctional()) { + return; + } + var mpid, currentUser = mpInstance.Identity.getCurrentUser(); if (currentUser) { @@ -803,6 +813,11 @@ export default function _Persistence(mpInstance) { // https://go.mparticle.com/work/SQDSDKS-6021 this.savePersistence = function(persistence) { + // Block mprtcl-v4 persistence when noFunctional is true + if (mpInstance._CookieConsentManager?.getNoFunctional()) { + return; + } + var encodedPersistence = self.encodePersistence( JSON.stringify(persistence) ), diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index 4fda419ea..0b2742c29 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -94,6 +94,7 @@ export interface SDKEvent { ExpandedEventCount: number; ActiveTimeOnSite: number; IsBackgroundAST?: boolean; + _forwardersAlreadySent?: boolean; } export interface SDKGeoLocation { diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 9b5fc59fc..4b6cf68a4 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -5,7 +5,7 @@ import Types from './types'; import { generateDeprecationMessage } from './utils'; import { IMParticleUser } from './identity-user-interfaces'; import { IMParticleWebSDKInstance } from './mp-instance'; -import { hasIdentityRequestChanged } from './identity-utils'; +import { hasIdentityRequestChanged, hasExplicitIdentifier } from './identity-utils'; const { Messages } = Constants; @@ -45,7 +45,12 @@ export default function SessionManager( const currentUser = mpInstance.Identity.getCurrentUser(); const sdkIdentityRequest = SDKConfig.identifyRequest; + const shouldSuppressIdentify = + mpInstance._CookieConsentManager?.getNoFunctional() && + !hasExplicitIdentifier(mpInstance._Store); + if ( + !shouldSuppressIdentify && hasIdentityRequestChanged(currentUser, sdkIdentityRequest) ) { mpInstance.Identity.identify( @@ -102,7 +107,11 @@ export default function SessionManager( self.setSessionTimer(); - if (!mpInstance._Store.identifyCalled) { + const shouldSuppressIdentify = + mpInstance._CookieConsentManager?.getNoFunctional() && + !hasExplicitIdentifier(mpInstance._Store); + + if (!mpInstance._Store.identifyCalled && !shouldSuppressIdentify) { mpInstance.Identity.identify( mpInstance._Store.SDKConfig.identifyRequest, mpInstance._Store.SDKConfig.identityCallback diff --git a/src/store.ts b/src/store.ts index 520cd2efd..9c4fcc18f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -732,7 +732,8 @@ export default function Store( if (workspaceToken) { this.SDKConfig.workspaceToken = workspaceToken; - mpInstance._timeOnSiteTimer = new ForegroundTimer(workspaceToken); + const noFunctional = config?.launcherOptions?.noFunctional === true; + mpInstance._timeOnSiteTimer = new ForegroundTimer(workspaceToken, noFunctional); } else { mpInstance.Logger.warning( 'You should have a workspaceToken on your config object for security purposes.' diff --git a/src/vault.ts b/src/vault.ts index 57d2a6dc1..c043ef6be 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -102,4 +102,25 @@ export class SessionStorageVault extends BaseVault { constructor(storageKey: string, options?: IVaultOptions) { super(storageKey, window.sessionStorage, options); } +} + +// DisabledVault is used when persistence is disabled by privacy flags. +export class DisabledVault extends BaseVault { + constructor(storageKey: string, options?: IVaultOptions) { + super(storageKey, window.localStorage, options); + this.contents = null; + this.storageObject.removeItem(this._storageKey); + } + + public store(_item: StorableItem): void { + this.contents = null; + } + + public retrieve(): StorableItem | null { + return this.contents; + } + + public purge(): void { + this.contents = null; + } } \ No newline at end of file diff --git a/test/jest/cookieSyncManager.spec.ts b/test/jest/cookieSyncManager.spec.ts index 3dc12ee2e..71139ca7f 100644 --- a/test/jest/cookieSyncManager.spec.ts +++ b/test/jest/cookieSyncManager.spec.ts @@ -41,6 +41,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -76,6 +77,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettingsWithoutPixelUrl], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -105,6 +107,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -126,6 +129,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: true, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -156,6 +160,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [myPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -195,6 +200,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [{...pixelSettings, ...myPixelSettings}], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -229,6 +235,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: {}}), }, @@ -266,6 +273,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: { 5: cookieSyncDateInPast } @@ -302,6 +310,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: {}}), }, @@ -337,6 +346,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [pixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({}), }, @@ -365,6 +375,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [myPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -408,6 +419,7 @@ describe('CookieSyncManager', () => { isEnabledForUserConsent: jest.fn().mockReturnValue(true), }, _CookieConsentManager: { + getNoFunctional: jest.fn().mockReturnValue(false), getNoTargeting: jest.fn().mockReturnValue(true), }, Identity: { @@ -437,6 +449,7 @@ describe('CookieSyncManager', () => { isEnabledForUserConsent: jest.fn().mockReturnValue(true), }, _CookieConsentManager: { + getNoFunctional: jest.fn().mockReturnValue(false), getNoTargeting: jest.fn().mockReturnValue(false), }, Identity: { @@ -496,6 +509,7 @@ describe('CookieSyncManager', () => { isEnabledForUserConsent: jest.fn().mockReturnValue(true), }, _CookieConsentManager: { + getNoFunctional: jest.fn().mockReturnValue(false), getNoTargeting: jest.fn().mockReturnValue(true), }, Identity: { @@ -529,6 +543,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [myPixelSettings], // empty values will make require consent to be true }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -584,6 +599,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [tradeDeskPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -625,6 +641,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [nonTradeDeskPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -673,6 +690,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [tradeDeskPixelSettings, appNexusPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} @@ -729,6 +747,7 @@ describe('CookieSyncManager', () => { webviewBridgeEnabled: false, pixelConfigurations: [tradeDeskPixelSettings], }, + _CookieConsentManager: { getNoFunctional: jest.fn().mockReturnValue(false) }, _Persistence: { getPersistence: () => ({testMPID: { csd: {} diff --git a/test/jest/identity.spec.ts b/test/jest/identity.spec.ts index 1ac8b0334..22c799e33 100644 --- a/test/jest/identity.spec.ts +++ b/test/jest/identity.spec.ts @@ -14,6 +14,8 @@ import { SDKIdentityTypeEnum, IIdentityAPIIdentityChangeData, } from '../../src/identity.interfaces'; +import { hasExplicitIdentifier } from '../../src/identity-utils'; +import { IStore } from '../../src/store'; import { MessageType } from '../../src/types'; describe('Identity', () => { @@ -269,4 +271,67 @@ describe('Identity', () => { }); }); }); + + describe('hasExplicitIdentifier', () => { + it('should return true when SDKConfig.deviceId is provided (e.g. noFunctional: false and partner passed deviceId)', () => { + const store = { + deviceId: 'test-device-id', + SDKConfig: { deviceId: 'test-device-id' }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(true); + }); + + it('should return true when userIdentities are provided', () => { + const store = { + deviceId: null, + SDKConfig: { + identifyRequest: { + userIdentities: { email: 'test@example.com' }, + }, + }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(true); + }); + + it('should return false when no deviceId or userIdentities', () => { + const store = { deviceId: null, SDKConfig: { identifyRequest: null } }; + expect(hasExplicitIdentifier(store as IStore)).toBe(false); + }); + + it('should return false when userIdentities object is empty', () => { + const store = { + deviceId: null, + SDKConfig: { identifyRequest: { userIdentities: {} } }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(false); + }); + + it('should return false when userIdentities has invalid identity with empty string value', () => { + const store = { + deviceId: null, + SDKConfig: { + identifyRequest: { userIdentities: { email: '' } }, + }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(false); + }); + + it('should return false when userIdentities has invalid identity with null value', () => { + const store = { + deviceId: null, + SDKConfig: { + identifyRequest: { userIdentities: { email: null } }, + }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(false); + }); + + it('should return false when store.deviceId is set but SDKConfig.deviceId is undefined (noFunctional: true on return visit)', () => { + const store = { + deviceId: 'stale-device-id-from-previous-session', + SDKConfig: { deviceId: undefined, identifyRequest: null }, + }; + expect(hasExplicitIdentifier(store as IStore)).toBe(false); + }); + }); }); diff --git a/test/jest/persistence.spec.ts b/test/jest/persistence.spec.ts index 4e825008e..3f87e5d6f 100644 --- a/test/jest/persistence.spec.ts +++ b/test/jest/persistence.spec.ts @@ -1,3 +1,4 @@ +import CookieConsentManager from '../../src/cookieConsentManager'; import Store, { IStore } from '../../src/store'; import { IMParticleWebSDKInstance } from '../../src/mp-instance'; import { SDKInitConfig } from '../../src/sdkRuntimeModels'; @@ -78,4 +79,33 @@ describe('Persistence', () => { expect(setLocalStorageSpy).not.toHaveBeenCalled(); }); }); + + describe('noFunctional (block mprtcl-v4 cookies)', () => { + it('should not write cookie when noFunctional is true', () => { + mockMPInstance._CookieConsentManager = new CookieConsentManager({ + noFunctional: true, + noTargeting: false, + }); + persistence = new Persistence(mockMPInstance); + + const getCookieDomainSpy = jest.spyOn(persistence, 'getCookieDomain'); + persistence.setCookie(); + + expect(getCookieDomainSpy).not.toHaveBeenCalled(); + }); + + it('should write cookie when noFunctional is false', () => { + mockMPInstance._CookieConsentManager = new CookieConsentManager({ + noFunctional: false, + noTargeting: false, + }); + persistence = new Persistence(mockMPInstance); + + jest.spyOn(persistence, 'getCookie').mockReturnValue(null); + const setCookieSpy = jest.spyOn(persistence, 'setCookie'); + persistence.update(); + + expect(setCookieSpy).toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index a75008c47..558ac5c83 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -4,8 +4,9 @@ import { SDKIdentityApi } from "../../src/identity.interfaces"; import { IMParticleWebSDKInstance } from "../../src/mp-instance"; import RoktManager, { IRoktKit, IRoktSelectPlacementsOptions } from "../../src/roktManager"; import { IStore } from "../../src/store"; -import { testMPID } from '../src/config/constants'; +import { testMPID, apiKey, urls, workspaceToken } from '../src/config/constants'; import { PerformanceMarkType } from "../../src/types"; +import { IMParticleInstanceManager, SDKInitConfig } from "../../src/sdkRuntimeModels"; import Constants from "../../src/constants"; const { ErrorMessages } = Constants.Messages; @@ -2787,4 +2788,102 @@ describe('RoktManager', () => { expect(result).toBe(true); }); }); + + describe('when MP SDK is not fully initialized', () => { + let savedLocalStorage: Storage | undefined; + + beforeEach(() => { + const win = typeof globalThis !== 'undefined' && (globalThis as any).window; + if (win && win.localStorage === undefined) { + const mockStorage: Storage = { + getItem: jest.fn().mockReturnValue(null), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn().mockReturnValue(null), + length: 0, + }; + (win as any).localStorage = mockStorage; + savedLocalStorage = undefined; + } + }); + + afterEach(() => { + if (savedLocalStorage !== undefined && (globalThis as any).window) { + (globalThis as any).window.localStorage = savedLocalStorage; + } + }); + + it('should allow Rokt Kit to initialize and selectPlacements', async () => { + jest.setTimeout(10000); + const mParticle = (globalThis as any).mParticle as IMParticleInstanceManager; + if (!mParticle) { + throw new Error('Global mParticle not available (Jest setup loads dist/mparticle.js)'); + } + + const mockFetch = jest.fn(); + const originalFetch = (globalThis as any).fetch; + (globalThis as any).fetch = mockFetch; + + // Do not delete the default instance so mParticle.Rokt refers to the same instance we init + const roktKitConfig = { name: 'Rokt', moduleId: 181 } as IKitConfigs; + const config: SDKInitConfig = { + workspaceToken, + requestConfig: false, + launcherOptions: { noFunctional: true, noTargeting: false }, + identifyRequest: undefined, + kitConfigs: [roktKitConfig], + }; + + // 1. Call mParticle.init with noFunctional = true (and no identity so SDK does not complete initialization) + mParticle.init(apiKey, config); + + // 2. Assert that the MP SDK is not initialized + expect(mParticle.isInitialized()).not.toBe(true); + + // 3. Assert that no identity calls were made + const identifyCalls = mockFetch.mock.calls.filter( + (call: any) => typeof call[0] === 'string' && call[0].indexOf(urls.identify) !== -1 + ); + expect(identifyCalls.length).toBe(0); + + (globalThis as any).fetch = originalFetch; + + const mockSelectPlacementsResult = { + close: jest.fn(), + getPlacements: jest.fn().mockResolvedValue([]), + }; + const mockRoktKit = { + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + selectPlacements: jest.fn().mockResolvedValue(mockSelectPlacementsResult), + filters: {}, + filteredUser: null, + userAttributes: {}, + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + use: jest.fn(), + }; + + mParticle.Rokt.attachKit(mockRoktKit as any); + + // 4. Call mParticle.rokt.selectPlacements and assert that the kit called selectPlacements internally + // Suppress expected console.error when SDK has no current user (setUserAttributes on null) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const result = await mParticle.Rokt.selectPlacements({ attributes: {} }); + consoleError.mockRestore(); + + expect(mockRoktKit.selectPlacements).toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(result.close).toBeDefined(); + expect(result.getPlacements).toBeDefined(); + + // Flush any pending microtasks so async work from selectPlacements completes before test ends + await new Promise(resolve => setTimeout(resolve, 0)); + delete mParticle._instances[Constants.DefaultInstance]; + }); + }); }); \ No newline at end of file diff --git a/test/jest/sessionManager.spec.ts b/test/jest/sessionManager.spec.ts new file mode 100644 index 000000000..7e478b547 --- /dev/null +++ b/test/jest/sessionManager.spec.ts @@ -0,0 +1,139 @@ +import CookieConsentManager from '../../src/cookieConsentManager'; +import SessionManager, { ISessionManager } from '../../src/sessionManager'; +import Store, { IStore } from '../../src/store'; +import { IMParticleWebSDKInstance } from '../../src/mp-instance'; +import { SDKInitConfig } from '../../src/sdkRuntimeModels'; +import { isObject, converted } from '../../src/utils'; + +describe('SessionManager', () => { + let store: IStore; + let mockMPInstance: IMParticleWebSDKInstance; + let sessionManager: ISessionManager; + let cookieConsentManager: CookieConsentManager; + + beforeEach(() => { + document.cookie.split(';').forEach(cookie => { + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim(); + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; + }); + localStorage.clear(); + + store = {} as IStore; + cookieConsentManager = new CookieConsentManager({ noFunctional: false, noTargeting: false }); + + mockMPInstance = { + _Helpers: { + isObject, + converted, + canLog: jest.fn().mockReturnValue(true), + generateUniqueId: jest.fn().mockReturnValue('test-session-id'), + extend: jest.fn((target, source) => ({ ...target, ...source })), + }, + _NativeSdkHelpers: {}, + _Store: store, + _CookieConsentManager: cookieConsentManager, + Identity: { + getCurrentUser: jest.fn().mockReturnValue({ + getMPID: () => 'test-mpid', + getUserIdentities: () => ({ userIdentities: {} }), + }), + identify: jest.fn(), + }, + Logger: { + verbose: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, + _Events: { + logEvent: jest.fn(), + }, + } as unknown as IMParticleWebSDKInstance; + + Store.call(store, {} as SDKInitConfig, mockMPInstance, 'apikey'); + store.isLocalStorageAvailable = true; + store.SDKConfig.useCookieStorage = true; + store.webviewBridgeEnabled = false; + (store.SDKConfig as any).cookieExpiration = 365; + store.SDKConfig.sessionTimeout = 30; + store.identifyCalled = false; + store.SDKConfig.identifyRequest = null; + store.SDKConfig.identityCallback = null; + store.deviceId = null; + store.identityCallInFlight = false; + + sessionManager = new SessionManager(mockMPInstance); + }); + + describe('when noFunctional is true AND no identifiers', () => { + it('should suppress automatic identify when no deviceId/userIdentities passed in', () => { + cookieConsentManager = new CookieConsentManager({ noFunctional: true, noTargeting: false }); + mockMPInstance._CookieConsentManager = cookieConsentManager; + store.deviceId = null; + store.SDKConfig.identifyRequest = null; + sessionManager = new SessionManager(mockMPInstance); + + const identifySpy = jest.spyOn(mockMPInstance.Identity, 'identify'); + sessionManager.startNewSession(); + + expect(identifySpy).not.toHaveBeenCalled(); + }); + + it('should NOT suppress identify when deviceId is provided', () => { + cookieConsentManager = new CookieConsentManager({ noFunctional: true, noTargeting: false }); + mockMPInstance._CookieConsentManager = cookieConsentManager; + store.SDKConfig.deviceId = 'explicit-device-id'; + store.SDKConfig.identifyRequest = { userIdentities: {} }; + sessionManager = new SessionManager(mockMPInstance); + + const identifySpy = jest.spyOn(mockMPInstance.Identity, 'identify'); + sessionManager.startNewSession(); + + expect(identifySpy).toHaveBeenCalled(); + }); + + it('should NOT suppress identify when userIdentities (e.g. email) are provided', () => { + cookieConsentManager = new CookieConsentManager({ noFunctional: true, noTargeting: false }); + mockMPInstance._CookieConsentManager = cookieConsentManager; + store.deviceId = null; + store.SDKConfig.identifyRequest = { + userIdentities: { email: 'test@example.com' }, + }; + sessionManager = new SessionManager(mockMPInstance); + + const identifySpy = jest.spyOn(mockMPInstance.Identity, 'identify'); + sessionManager.startNewSession(); + + expect(identifySpy).toHaveBeenCalled(); + }); + + it('should NOT suppress identify when noFunctional is false', () => { + cookieConsentManager = new CookieConsentManager({ noFunctional: false, noTargeting: false }); + mockMPInstance._CookieConsentManager = cookieConsentManager; + store.deviceId = null; + store.SDKConfig.identifyRequest = null; + sessionManager = new SessionManager(mockMPInstance); + + const identifySpy = jest.spyOn(mockMPInstance.Identity, 'identify'); + sessionManager.startNewSession(); + + expect(identifySpy).toHaveBeenCalled(); + }); + + it('should allow explicit identify call by user even when noFunctional is true', () => { + cookieConsentManager = new CookieConsentManager({ noFunctional: true, noTargeting: false }); + mockMPInstance._CookieConsentManager = cookieConsentManager; + store.deviceId = null; + store.SDKConfig.identifyRequest = null; + + const identifySpy = jest.spyOn(mockMPInstance.Identity, 'identify'); + mockMPInstance.Identity.identify({ + userIdentities: { email: 'user@example.com' }, + }); + + expect(identifySpy).toHaveBeenCalledWith({ + userIdentities: { email: 'user@example.com' }, + }); + }); + }); +}); diff --git a/test/src/config/utils.js b/test/src/config/utils.js index 1f48146f6..7e2ea6470 100644 --- a/test/src/config/utils.js +++ b/test/src/config/utils.js @@ -382,9 +382,11 @@ var pluses = /\+/g, self.testMode = testMode; }; + this.receivedEvents = []; this.process = function(event) { self.processCalled = true; this.receivedEvent = event; + this.receivedEvents.push(event); self.reportingService(self, event); self.logger.verbose(event.EventName + ' sent'); }; @@ -454,6 +456,13 @@ var pluses = /\+/g, delete this.userAttributes[key] }; + this.setSessionAttributeCalled = false; + this.sessionAttrData = []; + this.setSessionAttribute = function(args) { + this.setSessionAttributeCalled = true; + this.sessionAttrData.push(Array.isArray(args) ? args : [args]); + }; + window[this.name + this.id] = { instance: this, }; diff --git a/test/src/tests-cookie-syncing.ts b/test/src/tests-cookie-syncing.ts index 7c522ed45..02159963b 100644 --- a/test/src/tests-cookie-syncing.ts +++ b/test/src/tests-cookie-syncing.ts @@ -1308,13 +1308,16 @@ describe('cookie syncing', function() { data['newMPID'].csd.should.have.property(roktModuleId); }); - it('should not block Rokt cookie sync when only noFunctional is true', async () => { + it('should block cookie sync when noFunctional is true', async () => { window.mParticle.config.pixelConfigs = [roktPixelSettings]; window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; - await initAndWait(); + mParticle.init(apiKey, window.mParticle.config); + const spy = sinon.spy(mParticle.getInstance()._CookieSyncManager, 'performCookieSync'); + await waitForCondition(hasConfigurationReturned); - getLocalStorageData()[testMPID].csd.should.have.property(roktModuleId); + expect(spy.called).to.equal(false); + spy.restore(); }); }); }); \ No newline at end of file diff --git a/test/src/tests-forwarders.ts b/test/src/tests-forwarders.ts index d0903c9d7..852f27af5 100644 --- a/test/src/tests-forwarders.ts +++ b/test/src/tests-forwarders.ts @@ -47,9 +47,14 @@ interface IMockForwarderInstance { onModifyCompleteCalled?: boolean; onModifyCompleteFilteredUserIdentities?: UserIdentities; onModifyCompleteUser?: IMParticleUser; + onUserIdentifiedCalled?: boolean; onUserIdentifiedUser?: IMParticleUser; receivedEvent?: SDKEvent; + receivedEvents?: SDKEvent[]; removeUserAttributeCalled?: boolean; + setUserAttributeCalled?: boolean; + setSessionAttributeCalled?: boolean; + sessionAttrData?: unknown[][]; userAttributes?: UserAttributes; userIdentities?: UserIdentities; } @@ -222,6 +227,367 @@ describe('forwarders', function() { expect(enabled).to.not.be.ok; }); + describe('noFunctional integration', () => { + afterEach(() => { + delete window.mParticle.config.launcherOptions; + delete window.mParticle.config.identifyRequest; + }); + + it('should still initialize forwarders when noFunctional is true and no identity passed (when the SDK does not complete initialization)', () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + window.MockForwarder1.instance.should.have.property('initCalled', true); + expect(mParticle.isInitialized()).to.not.equal(true); + + // Events are sent to forwarders even when there is no mpid (no identity) + window.MockForwarder1.instance.receivedEvent = null; + mParticle.logEvent('NoFunctional No Identity Event', mParticle.EventType.Navigation); + expect(window.MockForwarder1.instance.receivedEvent).to.be.ok; + window.MockForwarder1.instance.receivedEvent.EventName.should.equal('NoFunctional No Identity Event'); + }); + + it('should still deliver events to forwarders when noFunctional is true when explicit identity is provided', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = { + userIdentities: { email: 'nofunctional-test@example.com' }, + }; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.isInitialized()).to.equal(true); + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + window.MockForwarder1.instance.receivedEvent = null; + + // Event is still sent to the forwarder when noFunctional is true and explicit identity is provided + mParticle.logEvent('NoFunctional Test Event', mParticle.EventType.Navigation); + + expect(window.MockForwarder1.instance.receivedEvent).to.be.ok; + window.MockForwarder1.instance.receivedEvent.EventName.should.equal('NoFunctional Test Event'); + }); + + it('when noFunctional is true, after explicit Identity.identify() returns, mParticle is fully initialized', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.isInitialized()).to.not.equal(true); + + mParticle.Identity.identify({ + userIdentities: { email: 'explicit-identify-init@example.com' }, + }); + + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.isInitialized()).to.equal(true); + }); + + it('when noFunctional and explicit identity is provided, queued events are processed after identify returns and mParticle is fully initialized', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + expect(mParticle.isInitialized()).to.not.equal(true); + + mParticle.logEvent('QueuedEvent1', mParticle.EventType.Navigation); + mParticle.logEvent('QueuedEvent2', mParticle.EventType.Navigation); + + const store = mParticle.getInstance()._Store; + // Queue contains SessionStart + AST (lifecycle events now queued) + 2 custom events + expect(store.eventQueue).to.have.length(4); + + mParticle.Identity.identify({ + userIdentities: { email: 'queue-then-identify@example.com' }, + }); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.isInitialized()).to.equal(true); + expect(store.eventQueue).to.have.length(0); + + const names = window.MockForwarder1.instance.receivedEvents!.map((e) => e.EventName); + expect(names).to.include('QueuedEvent1'); + expect(names).to.include('QueuedEvent2'); + }); + + it('should still deliver setSessionAttribute to forwarders when noFunctional is true and no identity passed', () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + expect(mParticle.isInitialized()).to.not.equal(true); + + window.MockForwarder1.instance.setSessionAttributeCalled = false; + window.MockForwarder1.instance.sessionAttrData = []; + mParticle.setSessionAttribute('nofunctional_session_key', 'nofunctional_session_value'); + + expect(window.MockForwarder1.instance.setSessionAttributeCalled).to.equal(true); + expect(window.MockForwarder1.instance.sessionAttrData).to.have.length(1); + expect(window.MockForwarder1.instance.sessionAttrData[0]).to.deep.equal([ + 'nofunctional_session_key', + 'nofunctional_session_value', + ]); + }); + + it('when noFunctional and no identity, setUserAttribute/removeUserAttribute are no-ops and do not reach forwarders (getCurrentUser is null)', () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + expect(mParticle.Identity.getCurrentUser()).to.equal(null); + + window.MockForwarder1.instance.setUserAttributeCalled = false; + window.MockForwarder1.instance.removeUserAttributeCalled = false; + mParticle.Identity.getCurrentUser()?.setUserAttribute('key', 'value'); + mParticle.Identity.getCurrentUser()?.removeUserAttribute('key'); + + expect(window.MockForwarder1.instance.setUserAttributeCalled).to.equal(false); + expect(window.MockForwarder1.instance.removeUserAttributeCalled).to.equal(false); + + mParticle.setSessionAttribute('after_ua_call', 'ok'); + expect(window.MockForwarder1.instance.setSessionAttributeCalled).to.equal(true); + }); + + it('when noFunctional and no identity, onUserIdentified and onIdentifyComplete are never called on forwarders (identity never completes)', () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + expect(window.MockForwarder1.instance.onUserIdentifiedCalled).to.not.equal(true); + expect(window.MockForwarder1.instance.onIdentifyCompleteCalled).to.not.equal(true); + }); + + it('should still deliver setSessionAttribute to forwarders when noFunctional is true and explicit identity is provided', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = { + userIdentities: { email: 'nofunctional-session-test@example.com' }, + }; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.getInstance()._getActiveForwarders().length).to.equal(1); + window.MockForwarder1.instance.setSessionAttributeCalled = false; + window.MockForwarder1.instance.sessionAttrData = []; + mParticle.setSessionAttribute('nofunctional_with_identity_key', 'nofunctional_with_identity_value'); + + expect(window.MockForwarder1.instance.setSessionAttributeCalled).to.equal(true); + expect(window.MockForwarder1.instance.sessionAttrData[0]).to.deep.equal([ + 'nofunctional_with_identity_key', + 'nofunctional_with_identity_value', + ]); + }); + + it('should still deliver setUserAttribute to forwarders when noFunctional is true and explicit identity is provided', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = { + userIdentities: { email: 'nofunctional-ua@example.com' }, + }; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + const currentUser = mParticle.Identity.getCurrentUser(); + expect(currentUser).to.be.ok; + window.MockForwarder1.instance.setUserAttributeCalled = false; + currentUser.setUserAttribute('nofunctional_ua_key', 'nofunctional_ua_value'); + + expect(window.MockForwarder1.instance.setUserAttributeCalled).to.equal(true); + expect(window.MockForwarder1.instance.userAttributes).to.have.property('nofunctional_ua_key', 'nofunctional_ua_value'); + }); + + it('should still deliver removeUserAttribute to forwarders when noFunctional is true and explicit identity is provided', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = { + userIdentities: { email: 'nofunctional-remove-ua@example.com' }, + }; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + const currentUser = mParticle.Identity.getCurrentUser(); + expect(currentUser).to.be.ok; + currentUser.setUserAttribute('key_to_remove', 'value'); + window.MockForwarder1.instance.removeUserAttributeCalled = false; + currentUser.removeUserAttribute('key_to_remove'); + + expect(window.MockForwarder1.instance.removeUserAttributeCalled).to.equal(true); + }); + + it('should still call onUserIdentified and onIdentifyComplete on forwarders when noFunctional is true and explicit identity is provided', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = { + userIdentities: { email: 'nofunctional-callbacks@example.com' }, + }; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(window.MockForwarder1.instance.onIdentifyCompleteCalled).to.equal(true); + expect(window.MockForwarder1.instance.onIdentifyCompleteUser).to.be.ok; + expect(window.MockForwarder1.instance.onUserIdentifiedCalled).to.equal(true); + expect(window.MockForwarder1.instance.onUserIdentifiedUser).to.be.ok; + }); + + it('events pre-dispatched to forwarders before identity are NOT re-dispatched when eventQueue is flushed after identify', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.isInitialized()).to.not.equal(true); + + // Log two events pre-MPID — they should be forwarded once each + mParticle.logEvent('DoubleDispatchEvent1', mParticle.EventType.Navigation); + mParticle.logEvent('DoubleDispatchEvent2', mParticle.EventType.Navigation); + + const forwarderReceivedCountBeforeIdentify = + window.MockForwarder1.instance.receivedEvents!.filter( + (e) => e.EventName === 'DoubleDispatchEvent1' || e.EventName === 'DoubleDispatchEvent2' + ).length; + expect(forwarderReceivedCountBeforeIdentify).to.equal(2); + + // Trigger identify — SDK fully initializes and flushes the event queue + mParticle.Identity.identify({ + userIdentities: { email: 'no-double-dispatch@example.com' }, + }); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.isInitialized()).to.equal(true); + expect(mParticle.getInstance()._Store.eventQueue).to.have.length(0); + + // The events must NOT have been dispatched to the forwarder a second time + const forwarderReceivedCountAfterIdentify = + window.MockForwarder1.instance.receivedEvents!.filter( + (e) => e.EventName === 'DoubleDispatchEvent1' || e.EventName === 'DoubleDispatchEvent2' + ).length; + expect(forwarderReceivedCountAfterIdentify).to.equal(2); + }); + + it('queued events are uploaded to the MP server events endpoint after Identity.identify() resolves', async () => { + window.mParticle.config.launcherOptions = { noFunctional: true, noTargeting: false }; + window.mParticle.config.identifyRequest = undefined; + + const mockForwarder = new MockForwarder(); + mockForwarder.register(window.mParticle.config); + window.mParticle.config.kitConfigs.push( + forwarderDefaultConfiguration('MockForwarder', 1) + ); + + mParticle.init(apiKey, window.mParticle.config); + + expect(mParticle.isInitialized()).to.not.equal(true); + + mParticle.logEvent('BatchUploadEvent1', mParticle.EventType.Navigation); + mParticle.logEvent('BatchUploadEvent2', mParticle.EventType.Navigation); + + // Queue contains SessionStart + AST (lifecycle events now queued) + 2 custom events + expect(mParticle.getInstance()._Store.eventQueue).to.have.length(4); + + // Reset fetch history so we only inspect calls made after identify + fetchMock.resetHistory(); + + mParticle.Identity.identify({ + userIdentities: { email: 'batch-upload-after-identify@example.com' }, + }); + await waitForCondition(() => window.mParticle.getInstance()?._Store?.identityCallInFlight === false); + + expect(mParticle.isInitialized()).to.equal(true); + expect(mParticle.getInstance()._Store.eventQueue).to.have.length(0); + + // Both queued events must appear in a POST to the events endpoint + const event1 = findEventFromRequest(fetchMock.calls(), 'BatchUploadEvent1'); + const event2 = findEventFromRequest(fetchMock.calls(), 'BatchUploadEvent2'); + expect(event1).to.be.ok; + expect(event2).to.be.ok; + }); + }); + const MockUser = function() { let consentState = null; return { diff --git a/test/src/tests-identity.ts b/test/src/tests-identity.ts index c26070135..0d023b0c1 100644 --- a/test/src/tests-identity.ts +++ b/test/src/tests-identity.ts @@ -4496,6 +4496,41 @@ describe('identity', function() { }); }); + describe('privacy flags', function() { + const idCacheStorageKey = 'mprtcl-v4_abcdef-id-cache'; + + beforeEach(function() { + mParticle._resetForTests(MPConfig); + mParticle.config.flags.cacheIdentity = 'True'; + // When noFunctional is true the SDK does not auto-populate identifyRequest from persistence. + // Provide identifyRequest so processForwarders and other init steps receive valid config. + mParticle.config.identifyRequest = { userIdentities: {} }; + localStorage.clear(); + }); + + describe('#createIdentityCache', function() { + it('should save id cache to local storage when noFunctional is false by default', async () => { + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned); + expect(localStorage.getItem(idCacheStorageKey)).to.be.ok; + }); + + it('should NOT save id cache to local storage when noFunctional is true', async () => { + mParticle.config.launcherOptions = { noFunctional: true }; + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned); + expect(localStorage.getItem(idCacheStorageKey)).not.to.be.ok; + }); + + it('should save id cache to local storage when noFunctional is false', async () => { + mParticle.config.launcherOptions = { noFunctional: false }; + mParticle.init(apiKey, window.mParticle.config); + await waitForCondition(hasIdentifyReturned); + expect(localStorage.getItem(idCacheStorageKey)).to.be.ok; + }); + }); + }); + describe('Rokt Manager', function() { let roktConfig: IKitConfigs; let roktKit: IRoktKit; diff --git a/test/src/vault.spec.ts b/test/src/vault.spec.ts index c9ee53085..00808acb6 100644 --- a/test/src/vault.spec.ts +++ b/test/src/vault.spec.ts @@ -1,7 +1,11 @@ import { Batch } from '@mparticle/event-models'; import { expect } from 'chai'; import { Dictionary } from '../../src/utils'; -import { SessionStorageVault, LocalStorageVault } from '../../src/vault'; +import { + DisabledVault, + LocalStorageVault, + SessionStorageVault, +} from '../../src/vault'; const testObject: Dictionary> = { foo: { foo: 'bar', buzz: 'bazz' }, @@ -363,6 +367,81 @@ describe('Vault', () => { }); }); + describe('DisabledVault', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + describe('#store', () => { + it('should NOT write to localStorage', () => { + const storageKey = 'test-disabled-store-empty'; + const vault = new DisabledVault(storageKey); + + vault.store('testString'); + + expect(vault.contents).to.equal(null); + expect(window.localStorage.getItem(storageKey)).to.equal(null); + }); + + it('should NOT overwrite existing localStorage value and keep contents null', () => { + const storageKey = 'test-disabled-store-existing'; + const vault = new DisabledVault(storageKey); + window.localStorage.setItem(storageKey, 'existingItem'); + + vault.store('newValue'); + + expect(vault.contents).to.equal(null); + expect(window.localStorage.getItem(storageKey)).to.equal( + 'existingItem', + ); + }); + }); + + describe('#retrieve', () => { + it('should return null when nothing is stored', () => { + const storageKey = 'test-disabled-retrieve-empty'; + const vault = new DisabledVault(storageKey); + const retrievedItem = vault.retrieve(); + expect(retrievedItem).to.equal(null); + }); + + it('should return null even if localStorage has a value', () => { + const storageKey = 'test-disabled-retrieve-existing'; + const vault = new DisabledVault(storageKey); + window.localStorage.setItem(storageKey, 'existingItem'); + + const retrievedItem = vault.retrieve(); + expect(retrievedItem).to.equal(null); + expect(window.localStorage.getItem(storageKey)).to.equal( + 'existingItem', + ); + }); + }); + + describe('#purge', () => { + it('should keep contents null when purging', () => { + const storageKey = 'test-disabled-purge-existing'; + window.localStorage.setItem(storageKey, 'existing'); + + const vault = new DisabledVault(storageKey); + + vault.purge(); + + expect(vault.contents).to.equal(null); + expect(window.localStorage.getItem(storageKey)).to.equal(null); + }); + + it('should keep contents null when purging an empty key', () => { + const storageKey = 'test-disabled-purge-empty'; + const vault = new DisabledVault(storageKey); + + vault.purge(); + expect(vault.contents).to.equal(null); + expect(window.localStorage.getItem(storageKey)).to.equal(null); + }); + }); + }); + // This is an example of how to use Vault for Batch Persistence so that we can verify // sequencing and use cases specific to batches describe('Batch Vault', () => {