diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index eb8157cd..ff079b4c 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -176,7 +176,7 @@ export class ExperimentClient implements Client { ? `${this.config.instanceName}-${internalInstanceName}` : this.config.instanceName; if (this.isWebExperiment) { - storage = new SessionStorage(); + storage = config?.['consentAwareStorage']?.['sessionStorage']; } else { storage = new LocalStorage(); } diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 25f0aa6e..2cd61f4a 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -71,7 +71,12 @@ const newExperimentClient = ( ): ExperimentClient => { return new ExperimentClient(apiKey, { ...config, - userProvider: new DefaultUserProvider(config?.userProvider, apiKey), + userProvider: new DefaultUserProvider( + config?.userProvider, + apiKey, + config?.['consentAwareStorage']?.localStorage, + config?.['consentAwareStorage']?.sessionStorage, + ), }); }; diff --git a/packages/experiment-browser/src/providers/default.ts b/packages/experiment-browser/src/providers/default.ts index ca4ae5e1..cbce1d2b 100644 --- a/packages/experiment-browser/src/providers/default.ts +++ b/packages/experiment-browser/src/providers/default.ts @@ -4,6 +4,7 @@ import { UAParser } from '@amplitude/ua-parser-js'; import { LocalStorage } from '../storage/local-storage'; import { SessionStorage } from '../storage/session-storage'; import { ExperimentUserProvider } from '../types/provider'; +import { Storage } from '../types/storage'; import { ExperimentUser } from '../types/user'; export class DefaultUserProvider implements ExperimentUserProvider { @@ -13,17 +14,24 @@ export class DefaultUserProvider implements ExperimentUserProvider { ? this.globalScope?.navigator.userAgent : undefined; private readonly ua = new UAParser(this.userAgent).getResult(); - private readonly localStorage = new LocalStorage(); - private readonly sessionStorage = new SessionStorage(); + private readonly localStorage: Storage; + private readonly sessionStorage: Storage; private readonly storageKey: string; public readonly userProvider: ExperimentUserProvider | undefined; private readonly apiKey?: string; - constructor(userProvider?: ExperimentUserProvider, apiKey?: string) { + constructor( + userProvider?: ExperimentUserProvider, + apiKey?: string, + customLocalStorage?: Storage, + customSessionStorage?: Storage, + ) { this.userProvider = userProvider; this.apiKey = apiKey; this.storageKey = `EXP_${this.apiKey?.slice(0, 10)}_DEFAULT_USER_PROVIDER`; + this.localStorage = customLocalStorage || new LocalStorage(); + this.sessionStorage = customSessionStorage || new SessionStorage(); } getUser(): ExperimentUser { diff --git a/packages/experiment-browser/src/types/exposure.ts b/packages/experiment-browser/src/types/exposure.ts index 237f8ef6..53c78581 100644 --- a/packages/experiment-browser/src/types/exposure.ts +++ b/packages/experiment-browser/src/types/exposure.ts @@ -45,6 +45,10 @@ export type Exposure = { * evaluation for the user. Used for system purposes. */ metadata?: Record; + /** + * (Optional) The time the exposure occurred. + */ + time?: number; }; /** diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 4fa66d5c..e1de9f0a 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -15,10 +15,29 @@ import { import * as FeatureExperiment from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; +import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; +import { + ConsentAwareLocalStorage, + ConsentAwareSessionStorage, + ConsentAwareStorage, +} from './storage/consent-aware-storage'; +import { + getExperimentStorageKey, + getPreviewModeSessionKey, + getRedirectStorageKey, + getVisualEditorSessionKey, +} from './storage/keys'; +import { + deletePersistedData, + getAndParseStorageItem, + setAndStringifyStorageItem, +} from './storage/storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { + ConsentOptions, + ConsentStatus, Defaults, WebExperimentClient, WebExperimentConfig, @@ -34,15 +53,9 @@ import { } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { enrichUserWithCampaignData } from './util/campaign'; -import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; -import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; +import { WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { - getStorageItem, - setStorageItem, - removeStorageItem, -} from './util/storage'; import { getUrlParams, removeQueryParams, @@ -57,7 +70,6 @@ const MUTATE_ACTION = 'mutate'; export const INJECT_ACTION = 'inject'; const REDIRECT_ACTION = 'redirect'; export const PREVIEW_MODE_PARAM = 'PREVIEW'; -export const PREVIEW_MODE_SESSION_KEY = 'amp-preview-mode'; const VISUAL_EDITOR_PARAM = 'VISUAL_EDITOR'; safeGlobal.Experiment = FeatureExperiment; @@ -104,6 +116,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // Preview mode is set by url params, postMessage or session storage, not chrome extension isPreviewMode = false; previewFlags: Record = {}; + private consentOptions: ConsentOptions = { + status: ConsentStatus.GRANTED, + }; + private storage: ConsentAwareStorage; + private consentAwareExposureHandler: ConsentAwareExposureHandler; constructor( apiKey: string, @@ -128,6 +145,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; + if (this.config.consentOptions) { + this.consentOptions = this.config.consentOptions; + } + + this.storage = new ConsentAwareStorage(this.consentOptions.status); + + this.consentAwareExposureHandler = new ConsentAwareExposureHandler( + this.consentOptions.status, + ); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -149,6 +176,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore internalInstanceNameSuffix: 'web', + consentAwareStorage: { + localStorage: new ConsentAwareLocalStorage(this.storage), + sessionStorage: new ConsentAwareSessionStorage(this.storage), + }, initialFlags: initialFlagsString, // timeout for fetching remote flags fetchTimeoutMillis: 1000, @@ -176,7 +207,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const urlParams = getUrlParams(); this.isVisualEditorMode = urlParams[VISUAL_EDITOR_PARAM] === 'true' || - getStorageItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== null; + this.storage.getItem('sessionStorage', getVisualEditorSessionKey()) !== + null; this.subscriptionManager = new SubscriptionManager( this, this.messageBus, @@ -209,9 +241,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // fire url_change upon landing on page, set updateActivePagesOnly to not trigger variant actions this.messageBus.publish('url_change', { updateActivePages: true }); - const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; + const experimentStorageName = getExperimentStorageKey(this.apiKey); const user = - getStorageItem( + this.storage.getItem( 'localStorage', experimentStorageName, ) || {}; @@ -223,10 +255,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!user.web_exp_id) { user.web_exp_id = user.device_id || UUID(); delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } else if (user.web_exp_id && user.device_id) { delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } // evaluate variants for page targeting @@ -248,7 +280,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } - const enrichedUser = await enrichUserWithCampaignData(this.apiKey, user); + const enrichedUser = await enrichUserWithCampaignData( + this.apiKey, + user, + this.storage, + ); // If no integration has been set, use an Amplitude integration. if (!this.globalScope.experimentIntegration) { @@ -260,6 +296,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ); } this.globalScope.experimentIntegration.type = 'integration'; + this.consentAwareExposureHandler.wrapExperimentIntegrationTrack(); this.experimentClient.addPlugin(this.globalScope.experimentIntegration); this.experimentClient.setUser(enrichedUser); @@ -527,6 +564,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + public setConsentStatus(consentStatus: ConsentStatus) { + if ( + consentStatus == undefined || + consentStatus === this.consentOptions.status + ) { + return; + } + this.consentOptions.status = consentStatus; + this.storage.setConsentStatus(consentStatus); + if (consentStatus === ConsentStatus.REJECTED) { + deletePersistedData(this.apiKey, this.config); + } + this.consentAwareExposureHandler.setConsentStatus(consentStatus); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -581,7 +633,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - setMarketingCookie(this.apiKey).then(); + this.storage.setMarketingCookie(this.apiKey).then(); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); @@ -799,19 +851,19 @@ export class DefaultWebExperimentClient implements WebExperimentClient { variant: Variant, redirectUrl: string, ) { - const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; + const redirectStorageKey = getRedirectStorageKey(this.apiKey); // Store the current flag and variant for exposure tracking after redirect const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; storedRedirects[flagKey] = { redirectUrl, variant }; - setStorageItem('sessionStorage', redirectStorageKey, storedRedirects); + this.storage.setItem('sessionStorage', redirectStorageKey, storedRedirects); } private fireStoredRedirectImpressions() { // Check for stored redirects and process them - const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; + const redirectStorageKey = getRedirectStorageKey(this.apiKey); const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; // If we have stored redirects, track exposures for them if (Object.keys(storedRedirects).length > 0) { @@ -836,7 +888,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // track exposure with timeout of 500ms this.globalScope.setTimeout(() => { const redirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; for (const storedFlagKey in redirects) { this.exposureWithDedupe( storedFlagKey, @@ -844,10 +896,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { true, ); } - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); }, 500); } else { - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); } } @@ -860,9 +912,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } }); - setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { - previewFlags: this.previewFlags, - }); + setAndStringifyStorageItem( + 'sessionStorage', + getPreviewModeSessionKey(), + { + previewFlags: this.previewFlags, + }, + ); const previewParamsToRemove = [ ...Object.keys(this.previewFlags), PREVIEW_MODE_PARAM, @@ -878,9 +934,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // if in preview mode, listen for ForceVariant messages WindowMessenger.setup(); } else { - const previewState: PreviewState | null = getStorageItem( + const previewState = getAndParseStorageItem( 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, + getPreviewModeSessionKey(), ); if (previewState) { this.previewFlags = previewState.previewFlags; diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts new file mode 100644 index 00000000..00745837 --- /dev/null +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -0,0 +1,100 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; +import { + ExperimentEvent, + IntegrationPlugin, +} from '@amplitude/experiment-js-client'; + +import { ConsentStatus } from '../types'; + +/** + * Consent-aware exposure handler that wraps window.experimentIntegration.track + */ +export class ConsentAwareExposureHandler { + private pendingEvents: ExperimentEvent[] = []; + private consentStatus: ConsentStatus = ConsentStatus.PENDING; + private originalTrack: ((event: ExperimentEvent) => boolean) | null = null; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Wrap the experimentIntegration.track method with consent-aware logic + * Prevents nested wrapping by checking if already wrapped + */ + public wrapExperimentIntegrationTrack(): void { + const globalScope = getGlobalScope(); + const experimentIntegration = + globalScope?.experimentIntegration as IntegrationPlugin; + if (experimentIntegration?.track) { + if (this.isIntegrationWrapped(experimentIntegration)) { + return; + } + + this.originalTrack = experimentIntegration.track.bind( + experimentIntegration, + ); + const wrappedTrack = this.createConsentAwareTrack(this.originalTrack); + (experimentIntegration as any).__isConsentAwareWrapped = true; + experimentIntegration.track = wrappedTrack; + } + } + + /** + * Check if a track method is already wrapped + */ + private isIntegrationWrapped(integration: IntegrationPlugin): boolean { + return (integration as any).__isConsentAwareWrapped === true; + } + + /** + * Create a consent-aware wrapper for the track method + */ + private createConsentAwareTrack( + originalTrack: (event: ExperimentEvent) => boolean, + ) { + return (event: ExperimentEvent): boolean => { + if (event?.eventProperties) { + event.eventProperties.time = Date.now(); + } + try { + if (this.consentStatus === ConsentStatus.PENDING) { + this.pendingEvents.push(event); + return true; + } else if (this.consentStatus === ConsentStatus.GRANTED) { + return originalTrack(event); + } + return false; + } catch (error) { + console.warn('Failed to track event:', error); + return false; + } + }; + } + + /** + * Set the consent status and handle pending events accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; + + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + for (const event of this.pendingEvents) { + if (this.originalTrack) { + try { + this.originalTrack(event); + } catch (error) { + console.warn('Failed to track pending event:', error); + } + } + } + this.pendingEvents = []; + } else if (consentStatus === ConsentStatus.REJECTED) { + // Delete all pending events + this.pendingEvents = []; + } + } + } +} diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index 3ddf260d..d9ff1c81 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -3,7 +3,8 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient } from './experiment'; import { HttpClient } from './preview/http'; import { SdkPreviewApi } from './preview/preview-api'; -import { WebExperimentConfig } from './types'; +import { deletePersistedData } from './storage/storage'; +import { ConsentStatus, WebExperimentConfig } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { isPreviewMode } from './util/url'; @@ -13,6 +14,13 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { + if ( + getGlobalScope()?.experimentConfig?.consentOptions?.status === + ConsentStatus.REJECTED + ) { + deletePersistedData(apiKey, config); + return; + } const shouldFetchConfigs = isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts new file mode 100644 index 00000000..014daf43 --- /dev/null +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -0,0 +1,215 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; + +import { ConsentStatus } from '../types'; + +import { + getAndParseStorageItem, + getRawStorageItem, + removeStorageItem, + setAndStringifyStorageItem, + setRawStorageItem, + StorageType, +} from './storage'; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private inMemoryRawStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); + private consentStatus: ConsentStatus; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + this.consentStatus = consentStatus; + + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + setAndStringifyStorageItem(storageType, key, value); + } + } + for (const [ + storageType, + storageMap, + ] of this.inMemoryRawStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + setRawStorageItem(storageType, key, value); + } + } + this.inMemoryStorage.clear(); + this.inMemoryRawStorage.clear(); + this.persistMarketingCookies().catch(); + } + } + + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + const value = getAndParseStorageItem(storageType, key); + return value as T; + } + + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap?.has(key)) { + return storageMap.get(key) as T; + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setAndStringifyStorageItem(storageType, key, value); + } else { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + } + + /** + * Get a raw string value from storage with consent awareness + * This is used by Storage interface implementations that expect raw strings + */ + public getRawItem(storageType: StorageType, key: string): string { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getRawStorageItem(storageType, key); + } + + const storageMap = this.inMemoryRawStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) || ''; + } + + return ''; + } + + /** + * Set a raw string value in storage with consent awareness + * This is used by Storage interface implementations that work with raw strings + */ + public setRawItem( + storageType: StorageType, + key: string, + value: string, + ): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setRawStorageItem(storageType, key, value); + } else { + if (!this.inMemoryRawStorage.has(storageType)) { + this.inMemoryRawStorage.set(storageType, new Map()); + } + this.inMemoryRawStorage.get(storageType)?.set(key, value); + } + } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } +} + +export class ConsentAwareLocalStorage { + constructor(private consentAwareStorage: ConsentAwareStorage) {} + + get(key: string): string { + return this.consentAwareStorage.getRawItem('localStorage', key); + } + + put(key: string, value: string): void { + this.consentAwareStorage.setRawItem('localStorage', key, value); + } + + delete(key: string): void { + this.consentAwareStorage.removeItem('localStorage', key); + } +} + +export class ConsentAwareSessionStorage { + constructor(private consentAwareStorage: ConsentAwareStorage) {} + + get(key: string): string { + return this.consentAwareStorage.getRawItem('sessionStorage', key); + } + + put(key: string, value: string): void { + this.consentAwareStorage.setRawItem('sessionStorage', key, value); + } + + delete(key: string): void { + this.consentAwareStorage.removeItem('sessionStorage', key); + } +} diff --git a/packages/experiment-tag/src/storage/keys.ts b/packages/experiment-tag/src/storage/keys.ts new file mode 100644 index 00000000..e659543d --- /dev/null +++ b/packages/experiment-tag/src/storage/keys.ts @@ -0,0 +1,33 @@ +import { MKTG } from '@amplitude/analytics-core'; + +import { WebExperimentConfig } from '../types'; + +export const getExperimentStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}`; +}; + +export const getDefaultUserProviderStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}_DEFAULT_USER_PROVIDER`; +}; + +export const getUnsentEventsStorageKey = ( + config: WebExperimentConfig, +): string => { + return `EXP_unsent_${config.instanceName ?? 'default_instance'}`; +}; + +export const getRedirectStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}_REDIRECT`; +}; + +export const getPreviewModeSessionKey = (): string => { + return 'amp-preview-mode'; +}; + +export const getVisualEditorSessionKey = (): string => { + return 'visual-editor-state'; +}; + +export const getPersistedURLParamsKey = (apiKey: string): string => { + return `EXP_${MKTG}_${apiKey.substring(0, 10)}`; +}; diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts new file mode 100644 index 00000000..9a9571a2 --- /dev/null +++ b/packages/experiment-tag/src/storage/storage.ts @@ -0,0 +1,105 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; + +import { WebExperimentConfig } from '../types'; + +import { + getDefaultUserProviderStorageKey, + getExperimentStorageKey, + getPersistedURLParamsKey, + getUnsentEventsStorageKey, +} from './keys'; + +export type StorageType = 'localStorage' | 'sessionStorage'; + +/** + * Get a JSON string value from storage + * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') + * @param key - The key to retrieve the value for + * @returns The JSON string value, or null if not found + */ +export const getRawStorageItem = ( + storageType: StorageType, + key: string, +): string => { + return getStorage(storageType)?.getItem(key) || ''; +}; + +/** + * Set a JSON string value in storage + * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') + * @param key - The key to set the value for + * @param value - The JSON string value to set + */ +export const setRawStorageItem = ( + storageType: StorageType, + key: string, + value: string, +): void => { + getStorage(storageType)?.setItem(key, value); +}; + +export const getAndParseStorageItem = ( + storageType: StorageType, + key: string, +): T | null => { + const value = getRawStorageItem(storageType, key); + try { + return JSON.parse(value); + } catch { + return null; + } +}; + +export const setAndStringifyStorageItem = ( + storageType: StorageType, + key: string, + value: T, +): void => { + try { + const stringValue = JSON.stringify(value); + setRawStorageItem(storageType, key, stringValue); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } +}; + +/** + * Remove a value from the specified storage type + * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') + * @param key - The key to remove + */ +export const removeStorageItem = ( + storageType: StorageType, + key: string, +): void => { + try { + getStorage(storageType)?.removeItem(key); + } catch (error) { + console.warn(`Failed to remove item from ${storageType}:`, error); + } +}; + +const getStorage = (storageType: StorageType): Storage | null => { + const globalScope = getGlobalScope(); + if (!globalScope) { + return null; + } + return globalScope[storageType]; +}; + +export const deletePersistedData = ( + apiKey: string, + config: WebExperimentConfig, +): void => { + const experimentStorageKey = getExperimentStorageKey(apiKey); + const defaultUserProviderStorageKey = + getDefaultUserProviderStorageKey(apiKey); + const unsentEventsStorageKey = getUnsentEventsStorageKey(config); + const persistedURLParamsKey = getPersistedURLParamsKey(apiKey); + + removeStorageItem('localStorage', experimentStorageKey); + removeStorageItem('localStorage', defaultUserProviderStorageKey); + removeStorageItem('sessionStorage', defaultUserProviderStorageKey); + removeStorageItem('localStorage', unsentEventsStorageKey); + removeStorageItem('sessionStorage', persistedURLParamsKey); +}; diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 24f343d5..3f0e732f 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -2,7 +2,6 @@ import { EvaluationCondition } from '@amplitude/experiment-core'; import { ExperimentConfig, ExperimentUser, - Variant, } from '@amplitude/experiment-js-client'; import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; @@ -43,14 +42,25 @@ export type PageObject = { export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; +export enum ConsentStatus { + REJECTED = 0, + GRANTED = 1, + PENDING = 2, +} + +export type ConsentOptions = { + status: ConsentStatus; +}; + export interface WebExperimentConfig extends ExperimentConfig { /** - * Determines whether the default implementation for handling navigation will be used + * Determines whether the default implementation for handling navigation will be used * If this is set to false, for single-page applications: * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context */ useDefaultNavigationHandler?: boolean; + consentOptions?: ConsentOptions; } export const Defaults: WebExperimentConfig = { @@ -79,6 +89,8 @@ export interface WebExperimentClient { getActivePages(): PageObjects; setRedirectHandler(handler: (url: string) => void): void; + + setConsentStatus(consentStatus: ConsentStatus): void; } export type WebExperimentUser = { diff --git a/packages/experiment-tag/src/util/campaign.ts b/packages/experiment-tag/src/util/campaign.ts index 3ebe5754..c092e2a1 100644 --- a/packages/experiment-tag/src/util/campaign.ts +++ b/packages/experiment-tag/src/util/campaign.ts @@ -8,7 +8,8 @@ import { import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; -import { getStorageItem, setStorageItem } from './storage'; +import { ConsentAwareStorage } from '../storage/consent-aware-storage'; +import { getPersistedURLParamsKey } from '../storage/keys'; /** * Enriches the user object's userProperties with UTM parameters based on priority: @@ -19,12 +20,13 @@ import { getStorageItem, setStorageItem } from './storage'; export async function enrichUserWithCampaignData( apiKey: string, user: ExperimentUser, + storage: ConsentAwareStorage, ): Promise { const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; const [currentCampaign, persistedAmplitudeCampaign] = await fetchCampaignData( apiKey, ); - const persistedExperimentCampaign = getStorageItem( + const persistedExperimentCampaign = storage.getItem( 'localStorage', experimentStorageKey, ); @@ -48,7 +50,7 @@ export async function enrichUserWithCampaignData( } if (Object.keys(utmParams).length > 0) { - persistUrlParams(apiKey, utmParams); + persistUrlParams(apiKey, utmParams, storage); return { ...user, persisted_url_param: utmParams, @@ -63,9 +65,10 @@ export async function enrichUserWithCampaignData( export function persistUrlParams( apiKey: string, campaign: Record, + storage: ConsentAwareStorage, ): void { - const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; - setStorageItem('localStorage', experimentStorageKey, campaign); + const experimentStorageKey = getPersistedURLParamsKey(apiKey); + storage.setItem('localStorage', experimentStorageKey, campaign); } async function fetchCampaignData( diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts deleted file mode 100644 index 2042b693..00000000 --- a/packages/experiment-tag/src/util/cookie.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CampaignParser } from '@amplitude/analytics-core'; -import { CookieStorage } from '@amplitude/analytics-core'; -import { MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; - -/** - * Utility function to generate and set marketing cookie - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ -export async function setMarketingCookie(apiKey: string) { - const storage = new CookieStorage({ - sameSite: 'Lax', - }); - - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - await storage.set(storageKey, campaign); -} diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 97dd5db9..ad965659 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,14 +1,13 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from './storage'; +import { getVisualEditorSessionKey } from '../storage/keys'; +import { getAndParseStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; amplitudeWindowUrl: string; } -export const VISUAL_EDITOR_SESSION_KEY = 'visual-editor-state'; - export class WindowMessenger { static setup() { let state: 'closed' | 'opening' | 'open' = 'closed'; @@ -73,9 +72,9 @@ export class WindowMessenger { * Retrieve stored session data (read-only) */ private static getStoredSession(): VisualEditorSession | null { - const sessionData = getStorageItem( + const sessionData = getAndParseStorageItem( 'sessionStorage', - VISUAL_EDITOR_SESSION_KEY, + getVisualEditorSessionKey(), ); if (!sessionData) { return null; diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/util/storage.ts deleted file mode 100644 index 51c1cbf3..00000000 --- a/packages/experiment-tag/src/util/storage.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getGlobalScope } from '@amplitude/experiment-core'; - -export type StorageType = 'localStorage' | 'sessionStorage'; - -/** - * Get a JSON value from storage and parse it - * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') - * @param key - The key to retrieve - * @returns The parsed JSON value or null if not found or invalid JSON - */ -export const getStorageItem = ( - storageType: StorageType, - key: string, -): T | null => { - try { - const value = getStorage(storageType)?.getItem(key); - if (!value) { - return null; - } - return JSON.parse(value) as T; - } catch (error) { - console.warn(`Failed to get and parse JSON from ${storageType}:`, error); - return null; - } -}; - -/** - * Set a JSON value in storage by stringifying it - * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') - * @param key - The key to store the value under - * @param value - The value to stringify and store - */ -export const setStorageItem = ( - storageType: StorageType, - key: string, - value: unknown, -): void => { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to stringify and set JSON in ${storageType}:`, error); - } -}; - -/** - * Remove a value from the specified storage type - * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') - * @param key - The key to remove - */ -export const removeStorageItem = ( - storageType: StorageType, - key: string, -): void => { - try { - getStorage(storageType)?.removeItem(key); - } catch (error) { - console.warn(`Failed to remove item from ${storageType}:`, error); - } -}; - -const getStorage = (storageType: StorageType): Storage | null => { - const globalScope = getGlobalScope(); - if (!globalScope) { - return null; - } - return globalScope[storageType]; -}; diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 743f2cbc..1c1b3656 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,10 +1,10 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; +import { PREVIEW_MODE_PARAM } from '../experiment'; +import { getPreviewModeSessionKey } from '../storage/keys'; +import { getAndParseStorageItem } from '../storage/storage'; import { PreviewState } from '../types'; -import { getStorageItem } from './storage'; - export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); const searchParams = new URLSearchParams(globalScope?.location.search); @@ -89,15 +89,15 @@ export const isPreviewMode = (): boolean => { if (getUrlParams()[PREVIEW_MODE_PARAM] === 'true') { return true; } - const previewState = getStorageItem( + const previewState = getAndParseStorageItem( 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, - ) as PreviewState; - if ( - previewState?.previewFlags && - Object.keys(previewState.previewFlags).length > 0 - ) { - return true; + getPreviewModeSessionKey(), + ); + if (!previewState) { + return false; } - return false; + return ( + previewState.previewFlags && + Object.keys(previewState.previewFlags).length > 0 + ); }; diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts new file mode 100644 index 00000000..c8728a81 --- /dev/null +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -0,0 +1,288 @@ +import * as experimentCore from '@amplitude/experiment-core'; +import { + ExperimentEvent, + IntegrationPlugin, +} from '@amplitude/experiment-js-client'; +import { ConsentAwareExposureHandler } from 'src/exposure/consent-aware-exposure-handler'; +import { ConsentStatus } from 'src/types'; + +class TestIntegrationPlugin implements IntegrationPlugin { + public trackedEvents: ExperimentEvent[] = []; + public trackCount = 0; + public type = 'integration' as const; + public originalTrack: (event: ExperimentEvent) => boolean; + + constructor() { + this.originalTrack = this.track.bind(this); + } + + track(event: ExperimentEvent): boolean { + this.trackCount += 1; + this.trackedEvents.push(event); + return true; + } + + getUser() { + return { + user_id: 'test-user', + device_id: 'test-device', + }; + } + + reset(): void { + this.trackedEvents = []; + this.trackCount = 0; + // Restore original track method + this.track = this.originalTrack; + } +} + +describe('ConsentAwareExposureHandler', () => { + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); + let integrationPlugin: TestIntegrationPlugin; + let handler: ConsentAwareExposureHandler; + let mockGlobalScope: any; + + beforeEach(() => { + integrationPlugin = new TestIntegrationPlugin(); + mockGlobalScope = { + experimentIntegration: integrationPlugin, + }; + mockGetGlobalScope.mockReturnValue(mockGlobalScope); + }); + + afterEach(() => { + integrationPlugin.reset(); + jest.clearAllMocks(); + }); + + describe('when consent is granted', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + // The track method should now be wrapped + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); + }); + + test('should track multiple events immediately', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + }); + + describe('when consent is pending', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.PENDING); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should not track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + test('should store multiple events in memory', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + test('should fire all pending events when consent becomes granted', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + handler.setConsentStatus(ConsentStatus.GRANTED); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + + test('should track new events immediately after consent becomes granted', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + handler.setConsentStatus(ConsentStatus.GRANTED); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + + test('should delete all pending events when consent becomes rejected', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + handler.setConsentStatus(ConsentStatus.REJECTED); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + + handler.setConsentStatus(ConsentStatus.GRANTED); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + }); + + describe('when consent is rejected', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.REJECTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should not track events', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + test('should track events when consent becomes granted', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + expect(integrationPlugin.trackCount).toBe(0); + + handler.setConsentStatus(ConsentStatus.GRANTED); + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); + }); + }); + + describe('without experiment integration', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue({}); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + }); + + test('should not throw error when no integration exists', () => { + expect(() => + handler.setConsentStatus(ConsentStatus.PENDING), + ).not.toThrow(); + }); + }); + + describe('error handling', () => { + let errorIntegrationPlugin: IntegrationPlugin; + let errorGlobalScope: any; + + beforeEach(() => { + errorIntegrationPlugin = { + type: 'integration' as const, + track: () => { + throw new Error('Tracking failed'); + }, + getUser: () => ({ user_id: 'test', device_id: 'test' }), + }; + errorGlobalScope = { + experimentIntegration: errorIntegrationPlugin, + }; + mockGetGlobalScope.mockReturnValue(errorGlobalScope); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should handle errors gracefully when original track throws', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + expect(() => + errorGlobalScope.experimentIntegration.track(event), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 879a7a02..07137b16 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,8 +1,10 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as experimentCore from '@amplitude/experiment-core'; import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import { ConsentStatus } from 'src/types'; import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -26,6 +28,20 @@ jest.mock('src/util/messenger', () => { }; }); +jest.mock('@amplitude/analytics-core', () => ({ + ...jest.requireActual('@amplitude/analytics-core'), + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + const newMockGlobal = (overrides?: Record) => { const createStorageMock = () => { let store: Record = {}; @@ -49,7 +65,7 @@ const newMockGlobal = (overrides?: Record) => { const baseGlobal = { localStorage: createStorageMock(), sessionStorage: createStorageMock(), - document: { referrer: '' }, + document: { referrer: '', cookie: '' }, history: { replaceState: jest.fn() }, addEventListener: jest.fn(), experimentIntegration: { @@ -140,6 +156,17 @@ describe('initializeExperiment', () => { antiFlickerSpy = jest .spyOn(antiFlickerUtils, 'applyAntiFlickerCss') .mockImplementation(jest.fn()); + + const mockCampaignParser = { + parse: jest.fn().mockResolvedValue({}), + }; + MockCampaignParser.mockImplementation(() => mockCampaignParser as any); + + const mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn(), + }; + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); }); test('should initialize experiment with empty user', async () => { @@ -1221,29 +1248,662 @@ describe('initializeExperiment', () => { expect(mockGlobal.sessionStorage.getItem(redirectStorageKey)).toBeNull(); }); - describe('remote evaluation - flag already stored in session storage', () => { - const sessionStorageMock = () => { - let store = {}; - return { - getItem: jest.fn((key) => store[key] || null), - setItem: jest.fn((key, value) => { - store[key] = value; + describe('consent status initialization and storage persistence', () => { + it('should initialize experiment with PENDING consent and store data in memory only', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // With PENDING consent, data should be stored in memory only, not in actual localStorage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should initialize experiment with GRANTED consent and store data directly in actual storage', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + 'EXP_' + stringify(apiKey), + JSON.stringify({ web_exp_id: 'mock' }), + ); + }); + + it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + // Verify that previously stored data is now persisted to actual storage + expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + await client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + const experimentStorageKey = `EXP_${stringify(apiKey).slice(0, 10)}`; + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalledWith( + experimentStorageKey, + expect.stringContaining('web_exp_id'), + ); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('marketing cookie with different consent status', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + + mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser as any); + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); + }); + + it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockImplementation((key, value) => { + mockGlobal.document.cookie = `${key}=${JSON.stringify(value)}`; + return Promise.resolve(); + }), + }; + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + expect(mockGlobal.document.cookie).toContain('AMP_MKTG_ORIGINAL_'); + expect(mockGlobal.document.cookie).toContain( + stringify(apiKey).substring(0, 10), + ); + }); + + it('should store marketing cookie in memory during redirect when consent is PENDING', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(2); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); + expect(MockCookieStorage).toHaveBeenCalledTimes(1); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should not set marketing cookie during redirect when consent is REJECTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(2); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); + expect(MockCookieStorage).toHaveBeenCalledTimes(1); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); + + describe('consent aware exposure tracking', () => { + let mockExperimentIntegration: any; + let originalTrackSpy: jest.SpyInstance; + + beforeEach(() => { + // Create fresh mock experimentIntegration for each test + originalTrackSpy = jest.fn().mockReturnValue(true); + mockExperimentIntegration = { + track: originalTrackSpy, + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', + }; + }); + + afterEach(() => { + // Clear experimentIntegration between tests + if (mockGlobal?.experimentIntegration) { + mockGlobal.experimentIntegration = undefined; + } + originalTrackSpy.mockClear(); + }); + + it('should track exposures immediately when consent is GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(originalTrackSpy).toHaveBeenCalled(); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test', + variant: 'treatment', + }), + }); + }); + + it('should store exposures in memory when consent is PENDING', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should not track exposures when consent is REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should fire all pending exposures when consent changes from PENDING to GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + createMutateFlag('test-2', 'control', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify({ + 'test-1': DEFAULT_PAGE_OBJECTS.test, + 'test-2': DEFAULT_PAGE_OBJECTS.test, }), - removeItem: jest.fn((key) => { - delete store[key]; + ); + + await client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + expect(originalTrackSpy).toHaveBeenCalled(); + + const trackedEvents = originalTrackSpy.mock.calls.map((call) => call[0]); + expect(trackedEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-1', + variant: 'treatment', + }), + }), + expect.objectContaining({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-2', + variant: 'control', + }), + }), + ]), + ); + }); + + it('should delete all pending exposures when consent changes from PENDING to REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should track new exposures immediately after consent becomes GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + originalTrackSpy.mockClear(); + + client.getExperimentClient().exposure('test-1'); + + expect(originalTrackSpy).toHaveBeenCalledTimes(1); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-1', + variant: 'treatment', }), - clear: jest.fn(() => { - store = {}; + }); + }); + + it('should handle multiple consent status changes correctly', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.REJECTED); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.PENDING); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.getExperimentClient().exposure('test'); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + expect(originalTrackSpy).toHaveBeenCalledTimes(1); + }); + + it('should add timestamp to exposure events', async () => { + const mockDate = new Date('2023-01-01T00:00:00.000Z'); + const mockNow = jest + .spyOn(Date, 'now') + .mockReturnValue(mockDate.getTime()); + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(originalTrackSpy).toHaveBeenCalled(); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test', + variant: 'treatment', + time: mockDate.getTime(), }), + }); + + mockNow.mockRestore(); + }); + + it('should handle exposure tracking errors gracefully', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const errorExperimentIntegration = { + track: jest.fn().mockImplementation(() => { + throw new Error('Tracking failed'); + }), + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', }; - }; - beforeEach(() => { - Object.defineProperty(safeGlobal, 'sessionStorage', { - value: sessionStorageMock(), + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: errorExperimentIntegration, }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await expect(client.start()).resolves.not.toThrow(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to track event:', + expect.any(Error), + ); + consoleWarnSpy.mockRestore(); }); - afterEach(() => { - safeGlobal.sessionStorage.clear(); + + it('should handle pending exposure tracking errors gracefully when consent becomes granted', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const errorExperimentIntegration = { + track: jest.fn().mockImplementation(() => { + throw new Error('Tracking failed'); + }), + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', + }; + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: errorExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(() => + client.setConsentStatus(ConsentStatus.GRANTED), + ).not.toThrow(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to track pending event:', + expect.any(Error), + ); + consoleWarnSpy.mockRestore(); + }); + }); + + describe('remote evaluation - flag already stored in session storage', () => { + let testMockGlobal: any; + + beforeEach(() => { + testMockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(testMockGlobal); }); test('evaluated, applied, and impression tracked, start updates flag in storage, applied, impression deduped', async () => { const apiKey = 'api1'; @@ -1260,7 +1920,7 @@ describe('initializeExperiment', () => { flagVersion: 2, }, ); - safeGlobal.sessionStorage.setItem( + testMockGlobal.sessionStorage.setItem( storageKey, JSON.stringify({ test: storedFlag }), ); @@ -1307,7 +1967,7 @@ describe('initializeExperiment', () => { expect(mockExposure).toHaveBeenCalledTimes(1); // Check remote flag store in storage const flags = JSON.parse( - safeGlobal.sessionStorage.getItem(storageKey) as string, + testMockGlobal.sessionStorage.getItem(storageKey) as string, ); expect(flags['test'].metadata.flagVersion).toEqual(4); expect(flags['test'].metadata.evaluationMode).toEqual('local'); @@ -1334,7 +1994,7 @@ describe('initializeExperiment', () => { flagVersion: 2, }, ); - safeGlobal.sessionStorage.setItem( + testMockGlobal.sessionStorage.setItem( storageKey, JSON.stringify({ test: storedFlag }), ); @@ -1389,7 +2049,7 @@ describe('initializeExperiment', () => { expect(mockExposure).toHaveBeenCalledTimes(2); // Check remote flag store in storage const flags = JSON.parse( - safeGlobal.sessionStorage.getItem(storageKey) as string, + testMockGlobal.sessionStorage.getItem(storageKey) as string, ); expect(flags['test'].metadata.flagVersion).toEqual(4); expect(flags['test'].metadata.evaluationMode).toEqual('local'); diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts new file mode 100644 index 00000000..0e3eff17 --- /dev/null +++ b/packages/experiment-tag/test/storage.test.ts @@ -0,0 +1,408 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; +import * as coreUtil from '@amplitude/experiment-core'; +import { ConsentAwareStorage } from 'src/storage/consent-aware-storage'; +import { ConsentStatus } from 'src/types'; + +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); + +// Mock CampaignParser and CookieStorage +jest.mock('@amplitude/analytics-core', () => ({ + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + +describe('ConsentAwareStorage', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; + + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + + beforeEach(() => { + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('setItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should store data in memory for localStorage', () => { + const testData = { key: 'value', number: 42 }; + storage.setItem('localStorage', 'testKey', testData); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data in memory for sessionStorage', () => { + const testData = { key: 'value', array: [1, 2, 3] }; + storage.setItem('sessionStorage', 'testKey', testData); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should handle multiple keys in the same storage type', () => { + storage.setItem('localStorage', 'key1', 'value1'); + storage.setItem('localStorage', 'key2', 'value2'); + + expect(storage.getItem('localStorage', 'key1')).toBe('value1'); + expect(storage.getItem('localStorage', 'key2')).toBe('value2'); + }); + + it('should handle multiple storage types independently', () => { + storage.setItem('localStorage', 'key', 'localValue'); + storage.setItem('sessionStorage', 'key', 'sessionValue'); + + expect(storage.getItem('localStorage', 'key')).toBe('localValue'); + expect(storage.getItem('sessionStorage', 'key')).toBe('sessionValue'); + }); + }); + + describe('setItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should store data directly in localStorage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data directly in sessionStorage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + }); + + describe('setItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should store data in memory but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('localStorage', 'testKey')).toEqual(testData); + }); + + it('should store data in memory for sessionStorage but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('sessionStorage', 'testKey')).toEqual(testData); + }); + }); + + describe('getItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should return data from memory if available', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null if not in memory and consent is pending', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('getItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should return data from actual storage, not memory', () => { + const testData = { key: 'value' }; + + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + }); + }); + + describe('getItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should return data from memory and not access actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('removeItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should remove data from memory', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + expect(storage.getItem('localStorage', 'testKey')).not.toBeNull(); + + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'nonExistentKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should clean up empty storage maps', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); + expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); + }); + }); + + describe('removeItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should remove data from actual storage', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + }); + + describe('removeItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not remove from actual storage', () => { + storage.removeItem('localStorage', 'testKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('setConsentStatus', () => { + it('should persist in-memory data when changing from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store data in memory + const localData = { local: 'value' }; + const sessionData = { session: 'value' }; + storage.setItem('localStorage', 'localKey', localData); + storage.setItem('sessionStorage', 'sessionKey', sessionData); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify data is now accessible from actual storage (not memory) + expect(storage.getItem('localStorage', 'localKey')).toEqual(localData); + expect(storage.getItem('sessionStorage', 'sessionKey')).toEqual( + sessionData, + ); + }); + + it('should handle persistence errors gracefully', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Store data that will cause JSON.stringify to fail + const circularData: any = {}; + circularData.self = circularData; + storage.setItem('localStorage', 'circularKey', circularData); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist data for key circularKey:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should not persist data when changing from PENDING to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.REJECTED); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('setMarketingCookie', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + const testApiKey = 'test-api-key-1234567890'; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly when consent is GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should store marketing cookie in memory when consent is PENDING', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should persist marketing cookies when consent changes from PENDING to GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Set marketing cookie while consent is pending + await storage.setMarketingCookie(testApiKey); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Wait for async persistence to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should handle marketing cookie errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Cookie error'); + mockCookieStorage.set.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should handle campaign parser errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Parser error'); + mockCampaignParser.parse.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/experiment-tag/test/util/campaign.test.ts b/packages/experiment-tag/test/util/campaign.test.ts index 506f7d91..91295b5e 100644 --- a/packages/experiment-tag/test/util/campaign.test.ts +++ b/packages/experiment-tag/test/util/campaign.test.ts @@ -4,13 +4,17 @@ import { CookieStorage, getStorageKey, } from '@amplitude/analytics-core'; +import * as coreUtil from '@amplitude/experiment-core'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; +import { ConsentAwareStorage } from '../../src/storage/consent-aware-storage'; +import { ConsentStatus } from '../../src/types'; import { enrichUserWithCampaignData, persistUrlParams, } from '../../src/util/campaign'; -import * as storageUtils from '../../src/util/storage'; + +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); jest.mock('@amplitude/analytics-core', () => ({ Campaign: jest.fn(), @@ -20,25 +24,49 @@ jest.mock('@amplitude/analytics-core', () => ({ MKTG: 'MKTG', })); -jest.mock('../../src/util/storage', () => ({ - getStorageItem: jest.fn(), - setStorageItem: jest.fn(), -})); +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; describe('campaign utilities', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; let mockCampaignParser: jest.Mocked; let mockCookieStorage: jest.Mocked>; - let mockGetStorageItem: jest.MockedFunction< - typeof storageUtils.getStorageItem - >; - let mockSetStorageItem: jest.MockedFunction< - typeof storageUtils.setStorageItem - >; let mockGetStorageKey: jest.MockedFunction; + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + beforeEach(() => { jest.clearAllMocks(); + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + mockCampaignParser = { parse: jest.fn(), } as any; @@ -48,20 +76,12 @@ describe('campaign utilities', () => { set: jest.fn(), } as any; - mockGetStorageItem = storageUtils.getStorageItem as jest.MockedFunction< - typeof storageUtils.getStorageItem - >; - mockSetStorageItem = storageUtils.setStorageItem as jest.MockedFunction< - typeof storageUtils.setStorageItem - >; mockGetStorageKey = getStorageKey as jest.MockedFunction< typeof getStorageKey >; - (CampaignParser as jest.Mock).mockImplementation(() => mockCampaignParser); - (CookieStorage as unknown as jest.Mock).mockImplementation( - () => mockCookieStorage, - ); + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); }); describe('enrichUserWithCampaignData', () => { @@ -102,9 +122,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result).toEqual({ ...baseUser, @@ -118,22 +146,14 @@ describe('campaign utilities', () => { }, }); - expect(mockGetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', - ); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', - { - utm_source: 'current_source', - utm_medium: 'current_medium', - utm_campaign: 'current_campaign', - utm_term: 'experiment_term', - utm_content: 'amplitude_content', - utm_id: 'experiment_id', - }, - ); + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual({ + utm_source: 'current_source', + utm_medium: 'current_medium', + utm_campaign: 'current_campaign', + utm_term: 'experiment_term', + utm_content: 'amplitude_content', + utm_id: 'experiment_id', + }); }); it('should preserve lower priority values when current campaign has undefined values', async () => { @@ -158,9 +178,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'current_source', @@ -192,9 +220,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'test_source', @@ -226,9 +262,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'current_source', @@ -241,14 +285,16 @@ describe('campaign utilities', () => { it('should handle empty campaign data gracefully', async () => { mockCampaignParser.parse.mockResolvedValue({} as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result).toEqual({ ...baseUser, }); - expect(mockSetStorageItem).not.toHaveBeenCalled(); }); it('should handle all UTM parameter types', async () => { @@ -263,9 +309,12 @@ describe('campaign utilities', () => { mockCampaignParser.parse.mockResolvedValue(fullCampaign as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toMatchObject({ utm_source: 'test_source', @@ -280,10 +329,9 @@ describe('campaign utilities', () => { it('should handle async errors gracefully', async () => { mockCampaignParser.parse.mockRejectedValue(new Error('Parse error')); mockCookieStorage.get.mockRejectedValue(new Error('Storage error')); - mockGetStorageItem.mockReturnValue(null); await expect( - enrichUserWithCampaignData(apiKey, baseUser), + enrichUserWithCampaignData(apiKey, baseUser, storage), ).rejects.toThrow(); }); }); @@ -298,11 +346,9 @@ describe('campaign utilities', () => { utm_campaign: 'test_campaign', }; - persistUrlParams(apiKey, campaign); + persistUrlParams(apiKey, campaign, storage); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual( campaign, ); }); @@ -310,11 +356,9 @@ describe('campaign utilities', () => { it('should handle empty campaign object', () => { const emptyCampaign = {}; - persistUrlParams(apiKey, emptyCampaign); + persistUrlParams(apiKey, emptyCampaign, storage); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual( emptyCampaign, ); }); @@ -334,13 +378,12 @@ describe('campaign utilities', () => { expectedPreviousCampaign as Campaign, ); mockGetStorageKey.mockReturnValue('test-storage-key'); - mockGetStorageItem.mockReturnValue(null); - await enrichUserWithCampaignData(apiKey, { user_id: 'test' }); + await enrichUserWithCampaignData(apiKey, { user_id: 'test' }, storage); - expect(CampaignParser).toHaveBeenCalledWith(); + expect(MockCampaignParser).toHaveBeenCalledWith(); expect(mockCampaignParser.parse).toHaveBeenCalledWith(); - expect(CookieStorage).toHaveBeenCalledWith(); + expect(MockCookieStorage).toHaveBeenCalledWith(); expect(getStorageKey).toHaveBeenCalledWith(apiKey, 'MKTG'); expect(mockCookieStorage.get).toHaveBeenCalledWith('test-storage-key'); }); @@ -350,11 +393,14 @@ describe('campaign utilities', () => { mockCampaignParser.parse.mockResolvedValue(expectedCampaign as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, { - user_id: 'test', - }); + const result = await enrichUserWithCampaignData( + apiKey, + { + user_id: 'test', + }, + storage, + ); expect(result.persisted_url_param).toMatchObject({ utm_source: 'test',