From 04514ec9ab06d20958426dcb66f2589a58044a17 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Fri, 26 Jan 2024 17:00:34 -0500 Subject: [PATCH] feat: Implement identity caching (#834) --- src/constants.ts | 5 + src/identity-utils.ts | 247 ++++++++++ src/identity.js | 114 +++-- src/identityApiClient.js | 7 +- src/mockBatchCreator.ts | 4 +- src/mp-instance.js | 17 + src/mparticle-instance-manager.js | 2 +- src/sdkRuntimeModels.ts | 6 +- src/store.ts | 3 + src/validators.ts | 3 +- test/jest/utils.ts | 1 - test/src/_test.index.ts | 1 + test/src/config/constants.ts | 5 +- test/src/config/utils.js | 1 + test/src/tests-batchUploader.ts | 1 - test/src/tests-identities-attributes.js | 8 +- test/src/tests-identity-utils.ts | 583 ++++++++++++++++++++++++ test/src/tests-identity.js | 358 ++++++++++++++- test/src/tests-persistence.ts | 106 ++++- test/src/tests-self-hosting-specific.js | 1 + test/src/tests-session-manager.ts | 14 +- test/src/tests-store.ts | 3 + 22 files changed, 1398 insertions(+), 92 deletions(-) create mode 100644 src/identity-utils.ts create mode 100644 test/src/tests-identity-utils.ts diff --git a/src/constants.ts b/src/constants.ts index 0e63b088..803c074e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -169,6 +169,7 @@ const Constants = { EventBatchingIntervalMillis: 'eventBatchingIntervalMillis', OfflineStorage: 'offlineStorage', DirectUrlRouting: 'directURLRouting', + CacheIdentity: 'cacheIdentity', }, DefaultInstance: 'default_instance', CCPAPurpose: 'data_sale_opt_out', @@ -181,3 +182,7 @@ const Constants = { } as const; export default Constants; + +// https://go.mparticle.com/work/SQDSDKS-6080 +export const ONE_DAY_IN_SECONDS = 60 * 60 * 24; +export const MILLIS_IN_ONE_SEC = 1000; \ No newline at end of file diff --git a/src/identity-utils.ts b/src/identity-utils.ts new file mode 100644 index 00000000..d363db77 --- /dev/null +++ b/src/identity-utils.ts @@ -0,0 +1,247 @@ +import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants'; +import { Dictionary, parseNumber, isObject, generateHash } from './utils'; +import { BaseVault } from './vault'; +import Types from './types'; +import { IdentityApiData, UserIdentities, IdentityCallback } from '@mparticle/web-sdk'; +import { IdentityAPIMethod, MParticleWebSDK } from './sdkRuntimeModels'; + +const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; + +export type IParseCachedIdentityResponse = ( + cachedIdentity: ICachedIdentityCall, + mpid: string, + callback: IdentityCallback, + identityApiData: IdentityApiData, + identityMethod: string, + knownIdentities: IKnownIdentities, + fromCachedIdentity: boolean +) => void + +export interface IKnownIdentities extends UserIdentities { + device_application_stamp?: string; +} + +export interface ICachedIdentityCall { + responseText: string; + status: number; + expireTimestamp: number; +} + +export const cacheOrClearIdCache = ( + method: string, + knownIdentities: IKnownIdentities, + idCache: BaseVault>, + xhr: XMLHttpRequest, + parsingCachedResponse: boolean, +): void => { + // when parsing a response that has already been cached, simply return instead of attempting another cache + if (parsingCachedResponse) { return; } + + const CACHE_HEADER = 'x-mp-max-age'; + + // default the expire timestamp to one day in milliseconds unless a header comes back + let now = new Date().getTime(); + let expireTimestamp = now + ONE_DAY_IN_SECONDS * MILLIS_IN_ONE_SEC; + if (xhr.getAllResponseHeaders().includes(CACHE_HEADER)) { + expireTimestamp = + now + (parseNumber(xhr.getResponseHeader(CACHE_HEADER)) * MILLIS_IN_ONE_SEC); + } + + switch (method) { + case Login: + case Identify: + cacheIdentityRequest( + method, + knownIdentities, + expireTimestamp, + idCache, + xhr + ); + break; + case Modify: + case Logout: + idCache.purge(); + break; + } +} + +export const cacheIdentityRequest = ( + method: IdentityAPIMethod, + identities: IKnownIdentities, + expireTimestamp: number, + idCache: BaseVault>, + xhr: XMLHttpRequest +): void => { + const cache: Dictionary = idCache.retrieve() || ({} as Dictionary); + const cacheKey = concatenateIdentities(method, identities); + const hashedKey = generateHash(cacheKey); + + cache[hashedKey] = { responseText: xhr.responseText, status: xhr.status, expireTimestamp}; + idCache.store(cache); +}; + +// We need to ensure that identities are concatenated in a deterministic way, so +// we sort the identities based on their enum. +// we create an array, set the user identity at the index of the user identity type +export const concatenateIdentities = ( + method: IdentityAPIMethod, + userIdentities: IKnownIdentities +): string => { + const DEVICE_APPLICATION_STAMP = 'device_application_stamp'; + // set DAS first since it is not an official identity type + let cacheKey: string = `${method}:${DEVICE_APPLICATION_STAMP}=${userIdentities.device_application_stamp};`; + const idLength: number = Object.keys(userIdentities).length; + let concatenatedIdentities: string = ''; + + if (idLength) { + let userIDArray: Array = new Array(); + // create an array where each index is equal to the user identity type + for (let key in userIdentities) { + if (key === DEVICE_APPLICATION_STAMP) { + continue; + } else { + userIDArray[Types.IdentityType.getIdentityType(key)] = + userIdentities[key]; + } + } + + concatenatedIdentities = userIDArray.reduce( + (prevValue: string, currentValue: string, index: number) => { + const idName: string = Types.IdentityType.getIdentityName(index); + return `${prevValue}${idName}=${currentValue};`; + }, + cacheKey + ); + } + + return concatenatedIdentities; +}; + +export const hasValidCachedIdentity = ( + method: IdentityAPIMethod, + proposedUserIdentities: IKnownIdentities, + idCache?: BaseVault> +): boolean => { + // There is an edge case where multiple identity calls are taking place + // before identify fires, so there may not be a cache. See what happens when + // the ? in idCache is removed to the following test + // "queued events contain login mpid instead of identify mpid when calling + // login immediately after mParticle initializes" + const cache = idCache?.retrieve(); + + // if there is no cache, then there is no valid cached identity + if (!cache) { + return false; + } + + const cacheKey: string = concatenateIdentities( + method, + proposedUserIdentities + ); + const hashedKey = generateHash(cacheKey); + + // if cache doesn't have the cacheKey, there is no valid cached identity + if (!cache.hasOwnProperty(hashedKey)) { + return false; + } + + // If there is a valid cache key, compare the expireTimestamp to the current time. + // If the current time is greater than the expireTimestamp, it is not a valid + // cached identity. + const expireTimestamp = cache[hashedKey].expireTimestamp; + + if (expireTimestamp < new Date().getTime()) { + return false; + } else { + return true; + } +}; + +export const getCachedIdentity = ( + method: IdentityAPIMethod, + proposedUserIdentities: IKnownIdentities, + idCache: BaseVault> +): ICachedIdentityCall | null => { + const cacheKey: string = concatenateIdentities( + method, + proposedUserIdentities + ); + const hashedKey = generateHash(cacheKey); + + const cache = idCache.retrieve(); + const cachedIdentity = cache ? cache[hashedKey] : null; + + return cachedIdentity; +}; + +// https://go.mparticle.com/work/SQDSDKS-6079 +export const createKnownIdentities = ( + identityApiData: IdentityApiData, + deviceId: string +): IKnownIdentities => { + const identitiesResult: IKnownIdentities = {}; + + if (isObject(identityApiData?.userIdentities)) { + for (let identity in identityApiData.userIdentities) { + identitiesResult[identity] = + identityApiData.userIdentities[identity]; + } + } + identitiesResult.device_application_stamp = deviceId; + + return identitiesResult; +}; + +export const removeExpiredIdentityCacheDates = (idCache: BaseVault>) => { + const cache: Dictionary = idCache.retrieve() || {} as Dictionary; + + const currentTime: number = new Date().getTime(); + + // Iterate over the cache and remove any key/value pairs that are expired + for (let key in cache) { + if (cache[key].expireTimestamp < currentTime) { + delete cache[key]; + } + }; + + idCache.store(cache); +} + +export const tryCacheIdentity = ( + knownIdentities: IKnownIdentities, + idCache: BaseVault>, + parseIdentityResponse: IParseCachedIdentityResponse, + mpid: string, + callback: IdentityCallback, + identityApiData: IdentityApiData, + identityMethod: IdentityAPIMethod +): boolean => { + // https://go.mparticle.com/work/SQDSDKS-6095 + const shouldReturnCachedIdentity = hasValidCachedIdentity( + identityMethod, + knownIdentities, + idCache + ); + + // If Identity is cached, then immediately parse the identity response + if (shouldReturnCachedIdentity) { + const cachedIdentity = getCachedIdentity( + identityMethod, + knownIdentities, + idCache + ); + + parseIdentityResponse( + cachedIdentity, + mpid, + callback, + identityApiData, + identityMethod, + knownIdentities, + true + ); + + return true; + } + return false; +} \ No newline at end of file diff --git a/src/identity.js b/src/identity.js index 043a3a7b..923a4f16 100644 --- a/src/identity.js +++ b/src/identity.js @@ -1,13 +1,20 @@ import Constants from './constants'; import Types from './types'; +import { + cacheOrClearIdCache, + createKnownIdentities, + tryCacheIdentity, +} from './identity-utils'; var Messages = Constants.Messages, HTTPCodes = Constants.HTTPCodes; -const { Identify, Modify } = Constants.IdentityMethods; +const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; export default function Identity(mpInstance) { var self = this; + this.idCache = null; + this.checkIdentitySwap = function( previousMPID, currentMPID, @@ -24,24 +31,6 @@ export default function Identity(mpInstance) { }; this.IdentityRequest = { - createKnownIdentities: function(identityApiData, deviceId) { - var identitiesResult = {}; - - if ( - identityApiData && - identityApiData.userIdentities && - mpInstance._Helpers.isObject(identityApiData.userIdentities) - ) { - for (var identity in identityApiData.userIdentities) { - identitiesResult[identity] = - identityApiData.userIdentities[identity]; - } - } - identitiesResult.device_application_stamp = deviceId; - - return identitiesResult; - }, - preProcessIdentityRequest: function(identityApiData, callback, method) { mpInstance.Logger.verbose( Messages.InformationMessages.StartingLogEvent + ': ' + method @@ -103,7 +92,7 @@ export default function Identity(mpInstance) { request_id: mpInstance._Helpers.generateUniqueId(), request_timestamp_ms: new Date().getTime(), previous_mpid: mpid || null, - known_identities: this.createKnownIdentities( + known_identities: createKnownIdentities( identityApiData, deviceId ), @@ -246,7 +235,7 @@ export default function Identity(mpInstance) { preProcessResult = mpInstance._Identity.IdentityRequest.preProcessIdentityRequest( identityApiData, callback, - 'identify' + Identify ); if (currentUser) { mpid = currentUser.getMPID(); @@ -262,6 +251,25 @@ export default function Identity(mpInstance) { mpInstance._Store.context, mpid ); + if ( + mpInstance._Helpers.getFeatureFlag( + Constants.FeatureFlags.CacheIdentity + ) + ) { + const successfullyCachedIdentity = tryCacheIdentity( + identityApiRequest.known_identities, + self.idCache, + self.parseIdentityResponse, + mpid, + callback, + identityApiData, + Identify + ); + + if (successfullyCachedIdentity) { + return; + } + } if (mpInstance._Helpers.canLog()) { if (mpInstance._Store.webviewBridgeEnabled) { @@ -281,11 +289,12 @@ export default function Identity(mpInstance) { } else { mpInstance._IdentityAPIClient.sendIdentityRequest( identityApiRequest, - 'identify', + Identify, callback, identityApiData, self.parseIdentityResponse, - mpid + mpid, + identityApiRequest.known_identities ); } } else { @@ -319,7 +328,7 @@ export default function Identity(mpInstance) { preProcessResult = mpInstance._Identity.IdentityRequest.preProcessIdentityRequest( identityApiData, callback, - 'logout' + Logout ); if (currentUser) { mpid = currentUser.getMPID(); @@ -355,7 +364,7 @@ export default function Identity(mpInstance) { } else { mpInstance._IdentityAPIClient.sendIdentityRequest( identityApiRequest, - 'logout', + Logout, callback, identityApiData, self.parseIdentityResponse, @@ -408,8 +417,9 @@ export default function Identity(mpInstance) { preProcessResult = mpInstance._Identity.IdentityRequest.preProcessIdentityRequest( identityApiData, callback, - 'login' + Login ); + if (currentUser) { mpid = currentUser.getMPID(); } @@ -425,6 +435,26 @@ export default function Identity(mpInstance) { mpid ); + if ( + mpInstance._Helpers.getFeatureFlag( + Constants.FeatureFlags.CacheIdentity + ) + ) { + const successfullyCachedIdentity = tryCacheIdentity( + identityApiRequest.known_identities, + self.idCache, + self.parseIdentityResponse, + mpid, + callback, + identityApiData, + Login + ); + + if (successfullyCachedIdentity) { + return; + } + } + if (mpInstance._Helpers.canLog()) { if (mpInstance._Store.webviewBridgeEnabled) { mpInstance._NativeSdkHelpers.sendToNative( @@ -443,11 +473,12 @@ export default function Identity(mpInstance) { } else { mpInstance._IdentityAPIClient.sendIdentityRequest( identityApiRequest, - 'login', + Login, callback, identityApiData, self.parseIdentityResponse, - mpid + mpid, + identityApiRequest.known_identities ); } } else { @@ -481,7 +512,7 @@ export default function Identity(mpInstance) { preProcessResult = mpInstance._Identity.IdentityRequest.preProcessIdentityRequest( identityApiData, callback, - 'modify' + Modify ); if (currentUser) { mpid = currentUser.getMPID(); @@ -520,11 +551,12 @@ export default function Identity(mpInstance) { } else { mpInstance._IdentityAPIClient.sendIdentityRequest( identityApiRequest, - 'modify', + Modify, callback, identityApiData, self.parseIdentityResponse, - mpid + mpid, + identityApiRequest.known_identities ); } } else { @@ -1423,7 +1455,9 @@ export default function Identity(mpInstance) { previousMPID, callback, identityApiData, - method + method, + knownIdentities, + parsingCachedResponse ) { var prevUser = mpInstance.Identity.getUser(previousMPID), newUser, @@ -1478,6 +1512,20 @@ export default function Identity(mpInstance) { } if (xhr.status === 200) { + if ( + mpInstance._Helpers.getFeatureFlag( + Constants.FeatureFlags.CacheIdentity + ) + ) { + cacheOrClearIdCache( + method, + knownIdentities, + self.idCache, + xhr, + parsingCachedResponse + ); + } + if (method === Modify) { newIdentitiesByType = mpInstance._Identity.IdentityRequest.combineUserIdentities( previousUIByName, @@ -1715,7 +1763,7 @@ export default function Identity(mpInstance) { var currentUserInMemory, userIdentityChangeEvent; if (!mpid) { - if (method !== 'modify') { + if (method !== Modify) { return; } } diff --git a/src/identityApiClient.js b/src/identityApiClient.js index 8d07b3fd..d97d2396 100644 --- a/src/identityApiClient.js +++ b/src/identityApiClient.js @@ -66,7 +66,8 @@ export default function IdentityAPIClient(mpInstance) { callback, originalIdentityApiData, parseIdentityResponse, - mpid + mpid, + knownIdentities ) { var xhr, previousMPID, @@ -80,7 +81,9 @@ export default function IdentityAPIClient(mpInstance) { previousMPID, callback, originalIdentityApiData, - method + method, + knownIdentities, + false ); } }; diff --git a/src/mockBatchCreator.ts b/src/mockBatchCreator.ts index f07bd2d4..f5781f55 100644 --- a/src/mockBatchCreator.ts +++ b/src/mockBatchCreator.ts @@ -12,7 +12,7 @@ const mockFunction = function() { }; export default class _BatchValidator { private getMPInstance() { - return { + return ({ // Certain Helper, Store, and Identity properties need to be mocked to be used in the `returnBatch` method _Helpers: { sanitizeAttributes: window.mParticle.getInstance()._Helpers @@ -119,7 +119,7 @@ export default class _BatchValidator { logLevel: 'none', setPosition: mockFunction, upload: mockFunction, - } as MParticleWebSDK; + } as unknown) as MParticleWebSDK; } private createSDKEventFunction(event): SDKEvent { diff --git a/src/mp-instance.js b/src/mp-instance.js index 8006fcc9..eed6fa9e 100644 --- a/src/mp-instance.js +++ b/src/mp-instance.js @@ -36,6 +36,8 @@ import Consent from './consent'; import KitBlocker from './kitBlocking'; import ConfigAPIClient from './configAPIClient'; import IdentityAPIClient from './identityApiClient'; +import { LocalStorageVault } from './vault'; +import { removeExpiredIdentityCacheDates } from './identity-utils'; var Messages = Constants.Messages, HTTPCodes = Constants.HTTPCodes; @@ -1302,12 +1304,27 @@ function completeSDKInitialization(apiKey, config, mpInstance) { } } + // add a new function to apply items to the store that require config to be returned mpInstance._Store.storageName = mpInstance._Helpers.createMainStorageName( config.workspaceToken ); mpInstance._Store.prodStorageName = mpInstance._Helpers.createProductStorageName( config.workspaceToken ); + + // idCache is instantiated here as opposed to when _Identity is instantiated + // because it depends on _Store.storageName, which is not sent until above + // because it is a setting on config which returns asyncronously + // in self hosted mode + mpInstance._Identity.idCache = new LocalStorageVault( + `${mpInstance._Store.storageName}-id-cache`, + { + logger: mpInstance.Logger, + } + ); + + removeExpiredIdentityCacheDates(mpInstance._Identity.idCache); + if (config.hasOwnProperty('workspaceToken')) { mpInstance._Store.SDKConfig.workspaceToken = config.workspaceToken; } else { diff --git a/src/mparticle-instance-manager.js b/src/mparticle-instance-manager.js index 0b2e7c47..d2f4d9cc 100644 --- a/src/mparticle-instance-manager.js +++ b/src/mparticle-instance-manager.js @@ -355,7 +355,7 @@ function mParticle() { }; this.Identity = { - HTTPCodes: self.getInstance().Identity.HTTPCodes, + HTTPCodes: Constants.HTTPCodes, aliasUsers: function(aliasRequest, callback) { self.getInstance().Identity.aliasUsers(aliasRequest, callback); }, diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index 95533781..c0251e81 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -3,7 +3,7 @@ import { DataPlanVersion } from '@mparticle/data-planning-models'; import { MPConfiguration, IdentityApiData } from '@mparticle/web-sdk'; import { IStore } from './store'; import Validators from './validators'; -import { Dictionary } from './utils'; +import { Dictionary, valueof } from './utils'; import { IServerModel } from './serverModel'; import { IKitConfigs } from './configAPIClient'; import { SDKConsentApi, SDKConsentState } from './consent'; @@ -11,6 +11,7 @@ import { IPersistence } from './persistence.interfaces'; import { IMPSideloadedKit } from './sideloadedKit'; import { ISessionManager } from './sessionManager'; import { Kit, MPForwarder } from './forwarders.interfaces'; +import Constants from './constants'; // TODO: Resolve this with version in @mparticle/web-sdk export type SDKEventCustomFlags = Dictionary; @@ -52,6 +53,8 @@ export interface SDKEvent { LaunchReferral?: string; } +export type IdentityAPIMethod = valueof; + export interface SDKGeoLocation { lat: number | string; lng: number | string; @@ -238,6 +241,7 @@ export interface SDKIdentityApi { login; logout; modify; + getUser(mpid: string): MParticleUser; } export interface SDKHelpersApi { diff --git a/src/store.ts b/src/store.ts index a982a6e1..892a8d6e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -116,6 +116,7 @@ export interface IFeatureFlags { eventBatchingIntervalMillis?: number; offlineStorage?: string; directURLRouting?: boolean; + cacheIdentity?: boolean; } // Temporary Interface until Store can be refactored as a class @@ -429,6 +430,7 @@ export function processFlags( EventBatchingIntervalMillis, OfflineStorage, DirectUrlRouting, + CacheIdentity, } = Constants.FeatureFlags; if (!config.flags) { @@ -443,6 +445,7 @@ export function processFlags( Constants.DefaultConfig.uploadInterval; flags[OfflineStorage] = config.flags[OfflineStorage] || '0'; flags[DirectUrlRouting] = config.flags[DirectUrlRouting] === 'True'; + flags[CacheIdentity] = config.flags[CacheIdentity] === 'True'; return flags; } diff --git a/src/validators.ts b/src/validators.ts index e0fccf7a..efe069ca 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -8,8 +8,7 @@ import { } from './utils'; import Constants from './constants'; import { IdentityApiData } from '@mparticle/web-sdk'; - -type IdentityAPIMethod = 'login' | 'logout' | 'identify' | 'modify'; +import { IdentityAPIMethod } from './sdkRuntimeModels'; type ValidationIdentitiesReturn = { valid: boolean; diff --git a/test/jest/utils.ts b/test/jest/utils.ts index aeb96360..94ff377a 100644 --- a/test/jest/utils.ts +++ b/test/jest/utils.ts @@ -1,4 +1,3 @@ -import { MPConfiguration } from "@mparticle/web-sdk"; import { UnregisteredKit } from "../../src/forwarders.interfaces"; import { SDKInitConfig } from "../../src/sdkRuntimeModels"; diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index d7134b5c..f73603c3 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -29,3 +29,4 @@ import './tests-utils'; import './tests-session-manager'; import './tests-store'; import './tests-config-api-client'; +import './tests-identity-utils'; \ No newline at end of file diff --git a/test/src/config/constants.ts b/test/src/config/constants.ts index 869e8fb9..6f645a13 100644 --- a/test/src/config/constants.ts +++ b/test/src/config/constants.ts @@ -1,4 +1,5 @@ import { SDKInitConfig } from "../../../src/sdkRuntimeModels"; +import { MILLIS_IN_ONE_SEC, ONE_DAY_IN_SECONDS } from "../../../src/constants"; export const urls = { events: 'https://jssdks.mparticle.com/v3/JS/test_key/events', @@ -11,7 +12,8 @@ export const urls = { forwarding: 'https://jssdks.mparticle.com/v1/JS/test_key/Forwarding' }; -export const MILLISECONDS_IN_ONE_MINUTE = 60000; +export const MILLISECONDS_IN_ONE_DAY = ONE_DAY_IN_SECONDS * MILLIS_IN_ONE_SEC +export const MILLISECONDS_IN_ONE_DAY_PLUS_ONE_SECOND = MILLISECONDS_IN_ONE_DAY + 1; export const mParticle = window.mParticle; @@ -20,6 +22,7 @@ export const testMPID = 'testMPID'; export const v3CookieKey = 'mprtcl-v3'; export const v3LSKey = v3CookieKey; export const localStorageProductsV4 = 'mprtcl-prodv4'; +export const localStorageIDKey = 'mparticle-id-cache'; export const v4CookieKey = 'mprtcl-v4'; export const v4LSKey = 'mprtcl-v4'; export const workspaceToken = 'abcdef'; diff --git a/test/src/config/utils.js b/test/src/config/utils.js index e3095f64..e7d05790 100644 --- a/test/src/config/utils.js +++ b/test/src/config/utils.js @@ -279,6 +279,7 @@ var pluses = /\+/g, if (returnedReqs[0] && returnedReqs[0].requestBody) { return JSON.parse(returnedReqs[0].requestBody); } + return null; }, forwarderDefaultConfiguration = function( forwarderName, diff --git a/test/src/tests-batchUploader.ts b/test/src/tests-batchUploader.ts index 33a612aa..cf625b64 100644 --- a/test/src/tests-batchUploader.ts +++ b/test/src/tests-batchUploader.ts @@ -960,7 +960,6 @@ describe('batch uploader', () => { window.localStorage.getItem(batchStorageKey), 'Offline Batch Storage should be empty' ).to.equal(''); - debugger; // To verify the sequence, we should look at what has been uploaded // as the upload queue and Offline Storage should be empty diff --git a/test/src/tests-identities-attributes.js b/test/src/tests-identities-attributes.js index 60c8148d..de9e8e51 100644 --- a/test/src/tests-identities-attributes.js +++ b/test/src/tests-identities-attributes.js @@ -3,7 +3,9 @@ import sinon from 'sinon'; import fetchMock from 'fetch-mock/esm/client'; import { urls, apiKey, testMPID, - MPConfig } from './config/constants'; + MPConfig, + MILLISECONDS_IN_ONE_DAY_PLUS_ONE_SECOND +} from './config/constants'; const findEventFromRequest = Utils.findEventFromRequest, findBatch = Utils.findBatch, @@ -981,6 +983,7 @@ describe('identities and attributes', function() { }); it('should send historical UIs on batches when MPID changes', function(done) { + const clock = sinon.useFakeTimers(); mParticle._resetForTests(MPConfig); window.mParticle.config.identifyRequest = { @@ -1043,6 +1046,8 @@ describe('identities and attributes', function() { JSON.stringify({ mpid: testMPID, is_logged_in: true }), ]); + clock.tick(MILLISECONDS_IN_ONE_DAY_PLUS_ONE_SECOND) + mParticle.Identity.login(loginUser); // switching back to logged in user shoudl not result in any UIC events @@ -1054,6 +1059,7 @@ describe('identities and attributes', function() { batch.user_identities.should.have.property('email', 'initial@gmail.com'); batch.user_identities.should.have.property('customer_id', 'customerid1'); + clock.restore(); done(); }); diff --git a/test/src/tests-identity-utils.ts b/test/src/tests-identity-utils.ts new file mode 100644 index 00000000..fd5ad716 --- /dev/null +++ b/test/src/tests-identity-utils.ts @@ -0,0 +1,583 @@ +import { + cacheOrClearIdCache, + cacheIdentityRequest, + concatenateIdentities, + hasValidCachedIdentity, + createKnownIdentities, + removeExpiredIdentityCacheDates, + tryCacheIdentity, + IKnownIdentities, + ICachedIdentityCall +} from "../../src/identity-utils"; +import { LocalStorageVault } from "../../src/vault"; +import { Dictionary, generateHash } from "../../src/utils"; +import { expect } from 'chai'; +import { + apiKey, MPConfig, + MILLISECONDS_IN_ONE_DAY, + MILLISECONDS_IN_ONE_DAY_PLUS_ONE_SECOND, + testMPID, + localStorageIDKey +} from './config/constants'; +import { IdentityApiData } from '@mparticle/web-sdk'; +import Identity from "../../src/identity"; + +import Constants from '../../src/constants'; +const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; + +import sinon from 'sinon'; + +const DEVICE_ID = 'test-device-id' + +const knownIdentities: IKnownIdentities = createKnownIdentities({ + userIdentities: {customerid: 'id1'}}, + DEVICE_ID +); + +const cacheVault = new LocalStorageVault(localStorageIDKey); + +const identifyResponse = { + context: null, + matched_identities: { + device_application_stamp: "test-das" + }, + is_ephemeral: false, + mpid: testMPID, + is_logged_in: false +}; + +const jsonString = JSON.stringify(identifyResponse); + +const xhr: XMLHttpRequest = { + status: 200, + responseText: jsonString, + getAllResponseHeaders: ()=> {return 'x-mp-max-age: 1'}, + getResponseHeader: (name: string) => { + return '1'; + }, +} as XMLHttpRequest; + +describe('identity-utils', () => { + beforeEach(()=> { + window.localStorage.clear(); + }); + + describe('#cacheOrClearIdCache', () => { + afterEach(()=>{ + sinon.restore(); + }); + + it('should cache if method is identify', () => { + const retrieveSpy = sinon.spy(cacheVault, 'retrieve'); + const storeSpy = sinon.spy(cacheVault, 'store'); + const purgeSpy = sinon.spy(cacheVault, 'purge'); + + cacheOrClearIdCache( + Identify, + knownIdentities, + cacheVault, + xhr, + false + ); + + expect(retrieveSpy.called).to.eq(true); + expect(storeSpy.called).to.eq(true); + expect(purgeSpy.called).to.eq(false); + }); + + it('should cache if method is login', () => { + const retrieveSpy = sinon.spy(cacheVault, 'retrieve'); + const storeSpy = sinon.spy(cacheVault, 'store'); + const purgeSpy = sinon.spy(cacheVault, 'purge'); + + cacheOrClearIdCache( + Login, + knownIdentities, + cacheVault, + xhr, + false + ); + + expect(retrieveSpy.called).to.eq(true); + expect(storeSpy.called).to.eq(true); + expect(purgeSpy.called).to.eq(false); + }); + + it('should clear cache if method is logout', () => { + const retrieveSpy = sinon.spy(cacheVault, 'retrieve'); + const storeSpy = sinon.spy(cacheVault, 'store'); + const purgeSpy = sinon.spy(cacheVault, 'purge'); + + cacheOrClearIdCache( + Logout, + knownIdentities, + cacheVault, + xhr, + false + ); + + expect(retrieveSpy.called).to.eq(false); + expect(storeSpy.called).to.eq(false); + expect(purgeSpy.called).to.eq(true); + }); + + it('should not cache identities if using a cached response argument is `true`', () => { + const retrieveSpy = sinon.spy(cacheVault, 'retrieve'); + const storeSpy = sinon.spy(cacheVault, 'store'); + const purgeSpy = sinon.spy(cacheVault, 'purge'); + + cacheOrClearIdCache( + Identify, + knownIdentities, + cacheVault, + xhr, + true + ); + + expect(retrieveSpy.called).to.eq(false); + expect(storeSpy.called).to.eq(false); + expect(purgeSpy.called).to.eq(false); + }); + }); + + describe('#cacheIdentityRequest', () => { + it('should save an identify request to local storage', () => { + const mpIdCache = window.localStorage.getItem(localStorageIDKey); + expect(mpIdCache).to.equal(null); + + const currentTime = new Date().getTime(); + + cacheIdentityRequest( + Identify, + knownIdentities, + currentTime, + cacheVault, + xhr + ); + + const updatedMpIdCache = cacheVault.retrieve(); + + expect(Object.keys(updatedMpIdCache!).length).to.equal(1); + const cachedKey = + generateHash('identify:device_application_stamp=test-device-id;customerid=id1;'); + + expect(updatedMpIdCache!.hasOwnProperty(cachedKey)).to.equal(true); + + const cachedIdentityCall: ICachedIdentityCall = updatedMpIdCache![cachedKey]; + + expect(cachedIdentityCall).hasOwnProperty('responseText'); + expect(cachedIdentityCall).hasOwnProperty('status'); + expect(cachedIdentityCall).hasOwnProperty('expireTimestamp'); + + expect(cachedIdentityCall.status).to.equal(200); + expect(cachedIdentityCall.expireTimestamp).to.equal(currentTime); + + const responseText = JSON.parse(cachedIdentityCall.responseText); + expect(responseText).to.deep.equal(identifyResponse); + }); + + it('should save a login request to local storage', () => { + const mpIdCache = window.localStorage.getItem(localStorageIDKey); + expect(mpIdCache).to.equal(null); + + const loginResponse = { + ...identifyResponse, + is_logged_in: true, + }; + + + const jsonString = JSON.stringify(loginResponse); + + const xhr: XMLHttpRequest = { + status: 200, + responseText: jsonString, + } as XMLHttpRequest; + + const currentTime = new Date().getTime(); + + cacheIdentityRequest( + Login, + knownIdentities, + currentTime, + cacheVault, + xhr + ); + + const updatedMpIdCache = cacheVault.retrieve(); + + expect(Object.keys(updatedMpIdCache!).length).to.equal(1); + const cachedKey = generateHash('login:device_application_stamp=test-device-id;customerid=id1;'); + expect(updatedMpIdCache!.hasOwnProperty(cachedKey)).to.equal(true); + + const cachedLoginCall: ICachedIdentityCall = updatedMpIdCache![ + cachedKey + ]; + + expect(cachedLoginCall).hasOwnProperty('responseText'); + expect(cachedLoginCall).hasOwnProperty('status'); + expect(cachedLoginCall).hasOwnProperty('expireTimestamp'); + + expect(cachedLoginCall.status).to.equal(200); + expect(cachedLoginCall.expireTimestamp).to.equal(currentTime); + + const responseText = JSON.parse(cachedLoginCall.responseText); + expect(responseText).to.deep.equal(loginResponse); + }); + }); + + describe('#concatenateIdentities', () => { + it('should return a concatenated user identity string based on the order of IdentityType enum', () => { + const userIdentities: IKnownIdentities = { + device_application_stamp: 'first', + customerid: '01', + email: '07', + other: '00', + other2: '10', + other3: '11', + other4: '12', + other5: '13', + other6: '14', + other7: '15', + other8: '16', + other9: '17', + other10: '18', + mobile_number: '19', + phone_number_2: '20', + phone_number_3: '21', + facebook: '02', + facebookcustomaudienceid: '09', + google: '04', + twitter: '03', + microsoft: '05', + yahoo: '06', + }; + + const key: string = concatenateIdentities('identify', userIdentities); + const expectedResult: string = 'identify:device_application_stamp=first;other=00;customerid=01;facebook=02;twitter=03;google=04;microsoft=05;yahoo=06;email=07;facebookcustomaudienceid=09;other2=10;other3=11;other4=12;other5=13;other6=14;other7=15;other8=16;other9=17;other10=18;mobile_number=19;phone_number_2=20;phone_number_3=21;'; + + expect(key).to.equal(expectedResult); + }); + }); + + describe('#hasValidCachedIdentity', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + const userIdentities: IKnownIdentities = { + device_application_stamp: 'first', + customerid: '01', + email: '07', + other: '00', + other2: '10', + other3: '11', + other4: '12', + other5: '13', + other6: '14', + other7: '15', + other8: '16', + other9: '17', + other10: '18', + mobile_number: '19', + phone_number_2: '20', + phone_number_3: '21', + facebook: '02', + facebookcustomaudienceid: '09', + google: '04', + twitter: '03', + microsoft: '05', + yahoo: '06', + }; + + it('should return false if idCache is empty', () => { + const mpIdCache = window.localStorage.getItem(localStorageIDKey); + expect(mpIdCache).to.equal(null); + + const cacheVault = new LocalStorageVault(localStorageIDKey); + + const result = hasValidCachedIdentity('identify', userIdentities, cacheVault); + expect(result).to.equal(false); + }); + + it('should return true if idCache has the identity method and new identity call is within 1 day of previously cached identity call', () => { + const mpIdCache = window.localStorage.getItem(localStorageIDKey); + expect(mpIdCache).to.equal(null); + + const cacheVault = new LocalStorageVault(localStorageIDKey); + + // check to ensure there is nothing on the cache first + const result1 = hasValidCachedIdentity('identify', userIdentities, cacheVault); + expect(result1).to.equal(false); + + const oneDayInMS = 86400 * 60 * 60 * 24; + + const expireTime = new Date().getTime() + oneDayInMS; + + cacheIdentityRequest( + Identify, + userIdentities, + expireTime, + cacheVault, + xhr + ); + + // tick forward less than oneDayInMS + clock.tick(5000); + const result2 = hasValidCachedIdentity('identify', userIdentities, cacheVault); + expect(result2).to.equal(true); + }); + + it('should return true if idCache has the identity method and new identity call is beyond of 1 day of previously cached identity call', () => { + const mpIdCache = window.localStorage.getItem(localStorageIDKey); + expect(mpIdCache).to.equal(null); + + const oneDayInMS = 86400 * 60 * 60 * 24; + + const expireTime = new Date().getTime() + oneDayInMS; + + cacheIdentityRequest( + Identify, + userIdentities, + expireTime, + cacheVault, + xhr + ); + + clock.tick(oneDayInMS +1); + const result3 = hasValidCachedIdentity('identify', userIdentities, cacheVault); + expect(result3).to.equal(false); + }); + }); + + describe('#createKnownIdentities', () => { + it('should return an object whose keys are each identity type passed to it from the userIdentities object, in addition to device_application_stamp', () => { + const identities = { + userIdentities: { + other: 'other', + customerid: 'customerid', + facebook: 'facebook', + twitter: 'twitter', + google: 'google', + microsoft: 'microsoft', + yahoo: 'yahoo', + email: 'email', + facebookcustomaudienceid: 'facebookcustomaudienceid', + other2: 'other2', + other3: 'other3', + other4: 'other4', + other5: 'other5', + other6: 'other6', + other7: 'other7', + other8: 'other8', + other9: 'other9', + other10: 'other10', + mobile_number: 'mobile_number', + phone_number_2: 'phone_number_2', + phone_number_3: 'phone_number_3', + }}; + + const knownIdentities: IKnownIdentities = createKnownIdentities( + identities, + DEVICE_ID + ); + + const expectedResult: IKnownIdentities = { + ...identities.userIdentities, + device_application_stamp: DEVICE_ID, + }; + + expect(knownIdentities).to.deep.equal(expectedResult); + }); + }); + + describe('#removeExpiredIdentityCacheDates', () => { + it('remove any timestamps that are expired, and keep any timestamps that are not expired', () => { + // set up clock in order to force some time stamps to expire later in the test + const clock = sinon.useFakeTimers(); + + const cacheVault = new LocalStorageVault(localStorageIDKey); + const knownIdentities1: IKnownIdentities = createKnownIdentities({ + userIdentities: {customerid: 'id1'}}, + DEVICE_ID + ); + const knownIdentities2: IKnownIdentities = createKnownIdentities({ + userIdentities: {customerid: 'id2'}}, + DEVICE_ID + ); + + const identifyResponse = { + context: null, + matched_identities: { + device_application_stamp: "test-das" + }, + is_ephemeral: false, + mpid: testMPID, + is_logged_in: false + }; + + const xhr: XMLHttpRequest = { + status: 200, + responseText: JSON.stringify(identifyResponse), + } as XMLHttpRequest; + + // Cache 1st identity response to expire in 1 day + cacheIdentityRequest( + 'identify', + knownIdentities1, + MILLISECONDS_IN_ONE_DAY, + cacheVault, + xhr + ); + + // Cache 2nd identity response to expire in 1 day + 100ms + cacheIdentityRequest( + 'identify', + knownIdentities2, + MILLISECONDS_IN_ONE_DAY + 100, + cacheVault, + xhr + ); + + const updatedMpIdCache = cacheVault.retrieve(); + + const knownIdentities1CachedKey = + generateHash('identify:device_application_stamp=test-device-id;customerid=id1;'); + const knownIdentities2CachedKey = + generateHash('identify:device_application_stamp=test-device-id;customerid=id2;'); + + + // both known identities cache keys should exist on the cacheVault + expect(updatedMpIdCache!.hasOwnProperty(knownIdentities1CachedKey)).to.equal(true); + expect(updatedMpIdCache!.hasOwnProperty(knownIdentities2CachedKey)).to.equal(true); + + // we do not tick the clock forward at all, so the expiration date should not yet be reached + // meaning both cached keys are still in the cache vault + removeExpiredIdentityCacheDates(cacheVault); + const updatedMpIdCache2 = cacheVault.retrieve(); + expect(updatedMpIdCache2!.hasOwnProperty(knownIdentities1CachedKey)).to.equal(true); + expect(updatedMpIdCache2!.hasOwnProperty(knownIdentities2CachedKey)).to.equal(true); + + // tick the clock forward 1 day + 1 ms, expiring knownIdentities1CachedKey but not knownIdentities2CachedKey + clock.tick(MILLISECONDS_IN_ONE_DAY_PLUS_ONE_SECOND); + + removeExpiredIdentityCacheDates(cacheVault); + const updatedMpIdCache3 = cacheVault.retrieve(); + + expect(updatedMpIdCache3!.hasOwnProperty(knownIdentities1CachedKey)).to.equal(false); + expect(updatedMpIdCache3!.hasOwnProperty(knownIdentities2CachedKey)).to.equal(true); + + clock.restore(); + }); + }); + + describe('#tryCacheIdentity', () => { + it('returns true if trying to cache an identity that has already been cached', () => { + window.mParticle._resetForTests(MPConfig); + const clock = sinon.useFakeTimers(); + + window.mParticle.config.flags = {cacheIdentity: 'True'}; + window.mParticle.init(apiKey, window.mParticle.config); + + const mpInstance = window.mParticle.getInstance(); + + const cacheVault = new LocalStorageVault(localStorageIDKey); + + const identifyResponse = { + context: null, + matched_identities: { + device_application_stamp: "test-das" + }, + is_ephemeral: false, + mpid: testMPID, + is_logged_in: false + }; + + const xhr: XMLHttpRequest = { + status: 200, + responseText: JSON.stringify(identifyResponse), + } as XMLHttpRequest; + + const customerId = {customerid: 'id1'} + const knownIdentities1: IKnownIdentities = createKnownIdentities({ + userIdentities: customerId}, + DEVICE_ID + ); + + // Cache 1st identity response to expire in 1 day + cacheIdentityRequest( + 'identify', + knownIdentities, + MILLISECONDS_IN_ONE_DAY, + cacheVault, + xhr + ); + + const identityInstance = new Identity(mpInstance); + + const identityApiData: IdentityApiData = { + userIdentities: customerId + }; + const callback = sinon.spy(); + + const successfullyCachedIdentity = tryCacheIdentity( + knownIdentities1, + cacheVault, + identityInstance.parseIdentityResponse, + testMPID, + callback, + identityApiData, + 'identify' + ); + + expect(successfullyCachedIdentity).to.equal(true); + expect(callback.called).to.equal(true); + clock.restore(); + }); + + it('returns false if trying to cache an identity that has not been cached', () => { + window.mParticle._resetForTests(MPConfig); + const clock = sinon.useFakeTimers(); + + window.mParticle.config.flags = {cacheIdentity: 'True'}; + + window.mParticle.init(apiKey, window.mParticle.config); + + const mpInstance = window.mParticle.getInstance(); + const cacheVault = new LocalStorageVault(localStorageIDKey); + + const customerId = {customerid: 'id1'} + const knownIdentities1: IKnownIdentities = createKnownIdentities({ + userIdentities: customerId}, + DEVICE_ID + ); + + const identityInstance = new Identity(mpInstance); + + const identityApiData: IdentityApiData = { + userIdentities: customerId + }; + const callback = sinon.spy(); + debugger; + const successfullyCachedIdentity = tryCacheIdentity( + knownIdentities1, + cacheVault, + identityInstance.parseIdentityResponse, + testMPID, + callback, + identityApiData, + 'login' + ); + + expect(successfullyCachedIdentity).to.equal(false); + // callback does not get called in the above method + expect(callback.called).to.equal(false); + clock.restore(); + }); + }); +}); \ No newline at end of file diff --git a/test/src/tests-identity.js b/test/src/tests-identity.js index 43af8746..26b61ee2 100644 --- a/test/src/tests-identity.js +++ b/test/src/tests-identity.js @@ -5,7 +5,8 @@ import fetchMock from 'fetch-mock/esm/client'; import { urls, apiKey, testMPID, MPConfig, - workspaceCookieName } from './config/constants'; + workspaceCookieName, +} from './config/constants'; const getLocalStorage = Utils.getLocalStorage, setLocalStorage = Utils.setLocalStorage, @@ -26,6 +27,7 @@ describe('identity', function() { fetchMock.post(urls.events, 200); mockServer = sinon.createFakeServer(); mockServer.respondImmediately = true; + localStorage.clear(); mockServer.respondWith(urls.identify, [ 200, @@ -160,7 +162,13 @@ describe('identity', function() { JSON.stringify({ mpid: 'otherMPID', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); const localStorageDataBeforeSessionEnd = mParticle .getInstance() ._Persistence.getLocalStorage(); @@ -174,7 +182,7 @@ describe('identity', function() { localStorageDataAfterSessionEnd1.gs.should.not.have.property('csm'); mParticle.logEvent('hi'); - mParticle.Identity.login(); + mParticle.Identity.login(userIdentities1); const localStorageAfterLoggingEvent = mParticle .getInstance() @@ -202,7 +210,13 @@ describe('identity', function() { JSON.stringify({ mpid: 'otherMPID', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); const cookiesAfterMPIDChange = mParticle .getInstance() ._Persistence.getLocalStorage(); @@ -272,7 +286,13 @@ describe('identity', function() { JSON.stringify({ mpid: 'otherMPID', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); const cookiesAfterMPIDChange = findCookie(); cookiesAfterMPIDChange.should.have.properties([ @@ -1048,12 +1068,21 @@ describe('identity', function() { it('getUsers should return all mpids available in local storage', function(done) { mParticle._resetForTests(MPConfig); - const user1 = {}; - const user2 = {}; - const user3 = { + const userIdentities1 = { userIdentities: { - customerid: 'customerid3', - email: 'email3@test.com', + customerid: 'foo1', + }, + }; + + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + + const userIdentities3 = { + userIdentities: { + customerid: 'foo3', }, }; @@ -1066,7 +1095,7 @@ describe('identity', function() { JSON.stringify({ mpid: 'user1', is_logged_in: false }), ]); - mParticle.Identity.login(user1); + mParticle.Identity.login(userIdentities1); // get user 2 into cookies mockServer.respondWith(urls.login, [ @@ -1075,7 +1104,7 @@ describe('identity', function() { JSON.stringify({ mpid: 'user2', is_logged_in: false }), ]); - mParticle.Identity.login(user2); + mParticle.Identity.login(userIdentities2); // get user 3 into cookies mockServer.respondWith(urls.login, [ @@ -1084,7 +1113,7 @@ describe('identity', function() { JSON.stringify({ mpid: 'user3', is_logged_in: false }), ]); - mParticle.Identity.login(user3); + mParticle.Identity.login(userIdentities3); // init again using user 1 mockServer.respondWith(urls.login, [ @@ -1093,7 +1122,8 @@ describe('identity', function() { JSON.stringify({ mpid: 'user1', is_logged_in: false }), ]); - mParticle.identifyRequest = user1; + + mParticle.identifyRequest = userIdentities1; mParticle.init(apiKey, window.mParticle.config); const users = mParticle.Identity.getUsers(); @@ -1106,6 +1136,7 @@ describe('identity', function() { Should.not.exist(mParticle.Identity.getUser('cu')); Should.not.exist(mParticle.Identity.getUser('0')); Should.not.exist(mParticle.Identity.getUser('user4')); + done(); }); @@ -1595,6 +1626,7 @@ describe('identity', function() { result = null; }); + // for some reason this is returning -4 for a good identity. validUserIdentities.forEach(function(goodIdentities) { mParticle.Identity[identityMethod](goodIdentities, callback); result.httpCode.should.equal(200); @@ -2281,7 +2313,7 @@ describe('identity', function() { mockServer.respondWith(urls.identify, [ 200, {}, - JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), + JSON.stringify({ mpid: testMPID, is_logged_in: false }), ]); mockServer.requests = []; @@ -2299,35 +2331,41 @@ describe('identity', function() { mockServer.respondWith(urls.login, [ 200, {}, - JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), + JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + mockServer.requests = []; - mParticle.Identity.login(); + mParticle.Identity.login(userIdentities1); currentUser = mParticle.Identity.getCurrentUser(); - currentUser.getMPID().should.equal('MPID2'); + currentUser.getMPID().should.equal('MPID1'); - //new user's firstSeenTime should be greater than or equal to the preceeding user's lastSeenTime + // new user's firstSeenTime should be greater than or equal to the preceeding user's lastSeenTime (currentUser.getFirstSeenTime() >= user1LastSeen).should.equal(true); currentUser.getFirstSeenTime().should.equal(120); clock.tick(20); - const user1 = mParticle.Identity.getUser('MPID1'); + const user1 = mParticle.Identity.getUser(testMPID); user1.getFirstSeenTime().should.equal(user1FirstSeen); mockServer.respondWith(urls.login, [ 200, {}, - JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), + JSON.stringify({ mpid: testMPID, is_logged_in: false }), ]); mockServer.requests = []; mParticle.Identity.login(); currentUser = mParticle.Identity.getCurrentUser(); - currentUser.getMPID().should.equal('MPID1'); + currentUser.getMPID().should.equal(testMPID); currentUser.getFirstSeenTime().should.equal(user1FirstSeen); (currentUser.getLastSeenTime() > user1LastSeen).should.equal(true); @@ -3048,13 +3086,287 @@ describe('identity', function() { window.mParticle.config.deviceId = 'foo-guid'; mParticle.init(apiKey, window.mParticle.config); - const data = getIdentityEvent(mockServer.requests, 'identify'); data.known_identities.device_application_stamp.should.equal('foo-guid'); done(); }); + describe('identity caching', function() { + afterEach(function() { + sinon.restore(); + }); + + it('should use header `x-mp-max-age` as expiration date for cache', function() { + const clock = sinon.useFakeTimers(); + + // tick forward 1 second + clock.tick(1); + const X_MP_MAX_AGE = '1'; + mParticle._resetForTests(MPConfig); + mockServer.respondWith(urls.identify, [ + 200, + {'x-mp-max-age': X_MP_MAX_AGE}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + + localStorage.clear(); + mParticle.config.flags.cacheIdentity = 'True'; + + mParticle.init(apiKey, window.mParticle.config); + + let idCache = JSON.parse(localStorage.getItem('mprtcl-v4_abcdef-id-cache')); + + // a single identify cache key will be on the idCache + Should(Object.keys(idCache).length).equal(1); + for (let key in idCache) { + // we previously ticked forward 1 second, so the expire timestamp should be 1 second more than the X_MP_MAX_AGE + Should(idCache[key].expireTimestamp).equal(X_MP_MAX_AGE * 1000 + 1) + } + }); + + it('should not call identify if no identities have changed within the expiration time', function() { + mParticle._resetForTests(MPConfig); + mockServer.respondWith(urls.identify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + mParticle.init(apiKey, window.mParticle.config); + + const initialIdentityCall = getIdentityEvent(mockServer.requests, 'identify'); + + initialIdentityCall.should.be.ok(); + mockServer.requests = []; + const callback = sinon.spy(); + mParticle.Identity.identify(identities, callback); + + const duplicateIdentityCall = getIdentityEvent(mockServer.requests, 'identify'); + + Should(duplicateIdentityCall).not.be.ok(); + + // callback still gets called even if the identity call is not made` + Should(callback.called).equal(true); + }); + + it('should call identify if no identities have changed but we are outside the expiration time', function() { + const clock = sinon.useFakeTimers(); + const X_MP_MAX_AGE = '1'; + mParticle._resetForTests(MPConfig); + mockServer.respondWith(urls.identify, [ + 200, + {'x-mp-max-age': X_MP_MAX_AGE}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + mParticle.init(apiKey, window.mParticle.config); + + const initialIdentityCall = getIdentityEvent(mockServer.requests, 'identify'); + initialIdentityCall.should.be.ok(); + mockServer.requests = []; + const callback = sinon.spy(); + + // cached time will be 1000 if header returns '1' + clock.tick(1001); + mParticle.Identity.identify(identities, callback); + const duplicateIdentityCall = getIdentityEvent(mockServer.requests, 'identify'); + + Should(duplicateIdentityCall).be.ok(); + Should(callback.called).equal(true); + }); + + it('should not call login if previously cached within the expiration time', function() { + mParticle._resetForTests(MPConfig); + mockServer.respondWith(urls.identify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.respondWith(urls.login, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + mParticle.init(apiKey, window.mParticle.config); + + const callback = sinon.spy(); + + mParticle.Identity.login(identities, callback); + const firstLoginCall = getIdentityEvent(mockServer.requests, 'login'); + + Should(firstLoginCall).be.ok(); + mockServer.requests = []; + + mParticle.Identity.login(identities); + const secondLoginCall = getIdentityEvent(mockServer.requests, 'login'); + + Should(secondLoginCall).not.be.ok(); + Should(callback.called).equal(true); + }); + + it('should call login if duplicate login happens after expiration time', function() { + const clock = sinon.useFakeTimers(); + const X_MP_MAX_AGE = '1'; + mParticle._resetForTests(MPConfig); + + mockServer.respondWith(urls.login, [ + 200, + {'x-mp-max-age': X_MP_MAX_AGE}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + mParticle.init(apiKey, window.mParticle.config); + + const callback = sinon.spy(); + + mParticle.Identity.login(identities); + const firstLoginCall = getIdentityEvent(mockServer.requests, 'login'); + + Should(firstLoginCall).be.ok(); + mockServer.requests = []; + + // cached time will be 1000 if header returns '1' + clock.tick(1001); + mParticle.Identity.login(identities, callback); + const secondLoginCall = getIdentityEvent(mockServer.requests, 'login'); + + Should(secondLoginCall).be.ok(); + Should(callback.called).equal(true); + }); + + it('should clear cache when modify is called', function() { + mParticle._resetForTests(MPConfig); + + mockServer.respondWith(urls.identify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + mockServer.respondWith(urls.modify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + + mParticle.init(apiKey, window.mParticle.config); + + let idCache = localStorage.getItem('mprtcl-v4_abcdef-id-cache'); + Should(idCache).be.ok(); + + mParticle.Identity.modify({userIdentities: { + customerid: 'abc1', + }}); + let secondIdCache = localStorage.getItem('mprtcl-v4_abcdef-id-cache'); + Should(secondIdCache).not.be.ok(); + }); + + it('should clear cache when logout is called', function() { + mParticle._resetForTests(MPConfig); + + mockServer.respondWith(urls.identify, [ + 200, + {}, + JSON.stringify({ mpid: testMPID, is_logged_in: false }), + ]); + mockServer.respondWith(urls.logout, [ + 200, + {}, + JSON.stringify({ mpid: 'otherMPID', is_logged_in: false }), + ]); + + mockServer.requests = []; + + const identities = { + userIdentities: { + customerid: 'abc', + email: 'test@gmail.com' + } + } + + mParticle.config.identifyRequest = identities; + mParticle.config.flags.cacheIdentity = 'True'; + + mParticle.init(apiKey, window.mParticle.config); + + let idCache = localStorage.getItem('mprtcl-v4_abcdef-id-cache'); + Should(idCache).be.ok(); + + mParticle.Identity.logout(); + let secondIdCache = localStorage.getItem('mprtcl-v4_abcdef-id-cache'); + Should(secondIdCache).not.be.ok(); + }); + }); + describe('Deprecate Cart', function() { afterEach(function() { sinon.restore(); diff --git a/test/src/tests-persistence.ts b/test/src/tests-persistence.ts index 246df899..ebe0f26e 100644 --- a/test/src/tests-persistence.ts +++ b/test/src/tests-persistence.ts @@ -11,7 +11,7 @@ import { localStorageProductsV4, LocalStorageProductsV4WithWorkSpaceName, workspaceCookieName, - v4LSKey, + v4LSKey } from './config/constants'; import { expect } from 'chai'; import { @@ -32,7 +32,7 @@ const { let mockServer; -describe('migrations and persistence-related', () => { +describe('persistence', () => { beforeEach(() => { fetchMock.post(urls.events, 200); mockServer = sinon.createFakeServer(); @@ -585,9 +585,9 @@ describe('migrations and persistence-related', () => { const user1 = { userIdentities: { customerid: 'customerid1' } }; const user2 = { userIdentities: { customerid: 'customerid2' } }; - mParticle.init(apiKey, mParticle.config); + // set user attributes on testMPID mParticle .getInstance() .Identity.getCurrentUser() @@ -611,10 +611,12 @@ describe('migrations and persistence-related', () => { mParticle.Identity.login(user1); + // modify user1's identities mParticle.Identity.modify({ userIdentities: { email: 'email@test.com' }, }); + // set user attributes on mpid1 mParticle .getInstance() .Identity.getCurrentUser() @@ -634,6 +636,8 @@ describe('migrations and persistence-related', () => { ]); mParticle.Identity.login(user2); + + // set user attributes on user 2 mParticle .getInstance() .Identity.getCurrentUser() @@ -748,13 +752,19 @@ describe('migrations and persistence-related', () => { mParticle.config.maxCookieSize = 1000; mParticle.init(apiKey, mParticle.config); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1' + } + } + mockServer.respondWith(urls.login, [ 200, {}, JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); - mParticle.Identity.login(); + mParticle.Identity.login(userIdentities1); let cookieData: Partial = findCookie(); cookieData.gs.csm[0].should.be.equal('testMPID'); @@ -766,7 +776,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + + mParticle.Identity.login(userIdentities2); cookieData = findCookie(); cookieData.gs.csm[0].should.be.equal('testMPID'); @@ -779,7 +795,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'testMPID', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities3 = { + userIdentities: { + customerid: 'foo3', + }, + }; + + mParticle.Identity.login(userIdentities3); cookieData = findCookie(); cookieData.gs.csm[0].should.be.equal('MPID1'); @@ -792,7 +814,7 @@ describe('migrations and persistence-related', () => { it('integration test - should remove a previous MPID as a key from cookies if new user attribute added and exceeds the size of the max cookie size', done => { mParticle._resetForTests(MPConfig); mParticle.config.useCookieStorage = true; - mParticle.config.maxCookieSize = 650; + mParticle.config.maxCookieSize = 700; mParticle.init(apiKey, mParticle.config); @@ -823,7 +845,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); mParticle .getInstance() @@ -856,7 +884,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + + mParticle.Identity.login(userIdentities2); mParticle .getInstance() @@ -1213,7 +1247,12 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + mParticle.Identity.login(userIdentities1); mParticle .getInstance() @@ -1242,7 +1281,12 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + mParticle.Identity.login(userIdentities2); mParticle .getInstance() @@ -1307,6 +1351,8 @@ describe('migrations and persistence-related', () => { mParticle.config.useCookieStorage = false; mParticle.init(apiKey, mParticle.config); + + // testMPID mParticle .getInstance() .Identity.getCurrentUser() @@ -1334,8 +1380,15 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); + // MPID1 mParticle .getInstance() .Identity.getCurrentUser() @@ -1363,8 +1416,15 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + + mParticle.Identity.login(userIdentities2); + // MPID2 mParticle .getInstance() .Identity.getCurrentUser() @@ -1471,7 +1531,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID1', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities1 = { + userIdentities: { + customerid: 'foo1', + }, + }; + + mParticle.Identity.login(userIdentities1); let user1StoredConsentState: ConsentState = mParticle .getInstance() .Identity.getCurrentUser() @@ -1497,7 +1563,13 @@ describe('migrations and persistence-related', () => { JSON.stringify({ mpid: 'MPID2', is_logged_in: false }), ]); - mParticle.Identity.login(); + const userIdentities2 = { + userIdentities: { + customerid: 'foo2', + }, + }; + + mParticle.Identity.login(userIdentities2); let user2StoredConsentState: ConsentState = mParticle .getInstance() @@ -1767,7 +1839,7 @@ describe('migrations and persistence-related', () => { const cookies = JSON.stringify({ gs: { - sid: 'lst Test', + sid: 'fst Test', les: new Date().getTime(), }, cu: 'test', @@ -1799,7 +1871,7 @@ describe('migrations and persistence-related', () => { const cookies = JSON.stringify({ gs: { - sid: 'lst Test', + sid: 'fst Test', les: new Date().getTime(), }, current: {}, diff --git a/test/src/tests-self-hosting-specific.js b/test/src/tests-self-hosting-specific.js index a14e266a..136ee117 100644 --- a/test/src/tests-self-hosting-specific.js +++ b/test/src/tests-self-hosting-specific.js @@ -94,6 +94,7 @@ describe('/config self-hosting integration tests', function() { urls.config, [200, {}, JSON.stringify({ workspaceToken: 'workspaceTokenTest' })] ); + mockServer.respondWith(urls.identify, [ 200, {}, diff --git a/test/src/tests-session-manager.ts b/test/src/tests-session-manager.ts index 56af5f4b..a1accf95 100644 --- a/test/src/tests-session-manager.ts +++ b/test/src/tests-session-manager.ts @@ -7,9 +7,9 @@ import { testMPID, urls, MessageType, - MILLISECONDS_IN_ONE_MINUTE, } from './config/constants'; import { IdentityApiData } from '@mparticle/web-sdk'; +import { MILLIS_IN_ONE_SEC } from '../../src/constants'; import Constants from '../../src/constants'; const { Messages } = Constants; @@ -75,7 +75,7 @@ describe('SessionManager', () => { }); it('ends the previous session and creates a new session if Store contains a sessionId and dateLastEventSent beyond the timeout window', () => { - const timePassed = 11 * MILLISECONDS_IN_ONE_MINUTE; + const timePassed = 11 * (MILLIS_IN_ONE_SEC * 60); const generateUniqueIdSpy = sinon.stub( mParticle.getInstance()._Helpers, @@ -98,7 +98,7 @@ describe('SessionManager', () => { }); it('resumes the previous session if session ID exists and dateLastSent is within the timeout window', () => { - const timePassed = 8 * MILLISECONDS_IN_ONE_MINUTE; + const timePassed = 8 * (MILLIS_IN_ONE_SEC * 60); mParticle.init(apiKey, window.mParticle.config); const mpInstance = mParticle.getInstance(); @@ -298,7 +298,7 @@ describe('SessionManager', () => { 'update' ); - clock.tick(31 * MILLISECONDS_IN_ONE_MINUTE); + clock.tick(31 * (MILLIS_IN_ONE_SEC * 60)); mpInstance._SessionManager.endSession(); expect(mpInstance._Store.sessionId).to.equal(null); @@ -591,11 +591,11 @@ describe('SessionManager', () => { mpInstance._SessionManager.setSessionTimer(); // Progress 29 minutes to make sure end session has not fired - clock.tick(29 * MILLISECONDS_IN_ONE_MINUTE); + clock.tick(29 * (MILLIS_IN_ONE_SEC * 60)); expect(endSessionSpy.called).to.equal(false); // Progress one minute to make sure end session fires - clock.tick(1 * MILLISECONDS_IN_ONE_MINUTE); + clock.tick(1 * (MILLIS_IN_ONE_SEC * 60)); expect(endSessionSpy.called).to.equal(true); }); @@ -871,7 +871,7 @@ describe('SessionManager', () => { }); // trigger a session end - clock.tick(60 * MILLISECONDS_IN_ONE_MINUTE); + clock.tick(60 * (MILLIS_IN_ONE_SEC * 60)); expect(mpInstance._Store.sessionId).to.equal(null); expect(mpInstance._Store.dateLastEventSent).to.equal(null); diff --git a/test/src/tests-store.ts b/test/src/tests-store.ts index 992c805b..bf3512d2 100644 --- a/test/src/tests-store.ts +++ b/test/src/tests-store.ts @@ -213,6 +213,7 @@ describe('Store', () => { eventBatchingIntervalMillis: 0, offlineStorage: '0', directURLRouting: false, + cacheIdentity: false, }; expect(flags).to.deep.equal(expectedResult); @@ -224,6 +225,7 @@ describe('Store', () => { eventBatchingIntervalMillis: 5000, offlineStorage: '100', directURLRouting: 'True', + cacheIdentity: 'True', }; const flags = processFlags({flags: cutomizedFlags} as unknown as SDKInitConfig, {} as SDKConfig); @@ -233,6 +235,7 @@ describe('Store', () => { eventBatchingIntervalMillis: 5000, offlineStorage: '100', directURLRouting: true, + cacheIdentity: true } expect(flags).to.deep.equal(expectedResult);