diff --git a/packages/optimizely-sdk/lib/core/notification_center/notification_registry.tests.ts b/packages/optimizely-sdk/lib/core/notification_center/notification_registry.tests.ts new file mode 100644 index 000000000..3a99b052c --- /dev/null +++ b/packages/optimizely-sdk/lib/core/notification_center/notification_registry.tests.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { NotificationRegistry } from './notification_registry'; + +describe('Notification Registry', () => { + it('Returns null notification center when SDK Key is null', () => { + const notificationCenter = NotificationRegistry.getNotificationCenter(); + expect(notificationCenter).to.be.undefined; + }); + + it('Returns the same notification center when SDK Keys are the same and not null', () => { + const sdkKey = 'testSDKKey'; + const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); + const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); + expect(notificationCenterA).to.eql(notificationCenterB); + }); + + it('Returns different notification centers when SDK Keys are not the same', () => { + const sdkKeyA = 'testSDKKeyA'; + const sdkKeyB = 'testSDKKeyB'; + const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKeyA); + const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKeyB); + expect(notificationCenterA).to.not.eql(notificationCenterB); + }); + + it('Removes old notification centers from the registry when removeNotificationCenter is called on the registry', () => { + const sdkKey = 'testSDKKey'; + const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); + NotificationRegistry.removeNotificationCenter(sdkKey); + + const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); + + expect(notificationCenterA).to.not.eql(notificationCenterB); + }); + + it('Does not throw an error when calling removeNotificationCenter with a null SDK Key', () => { + const sdkKey = 'testSDKKey'; + const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); + NotificationRegistry.removeNotificationCenter(); + + const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); + + expect(notificationCenterA).to.eql(notificationCenterB); + }); +}); diff --git a/packages/optimizely-sdk/lib/core/notification_center/notification_registry.ts b/packages/optimizely-sdk/lib/core/notification_center/notification_registry.ts new file mode 100644 index 000000000..80f9eb9f6 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/notification_center/notification_registry.ts @@ -0,0 +1,68 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; +import { NotificationCenter, createNotificationCenter } from '../../core/notification_center'; + +/** + * Internal notification center registry for managing multiple notification centers. + */ +export class NotificationRegistry { + private static _notificationCenters = new Map(); + + constructor() {} + + /** + * Retrieves an SDK Key's corresponding notification center in the registry if it exists, otherwise it creates one + * @param sdkKey SDK Key to be used for the notification center tied to the ODP Manager + * @param logger Logger to be used for the corresponding notification center + * @returns {NotificationCenter | undefined} a notification center instance for ODP Manager if a valid SDK Key is provided, otherwise undefined + */ + public static getNotificationCenter( + sdkKey?: string, + logger: LogHandler = getLogger() + ): NotificationCenter | undefined { + if (!sdkKey) { + logger.log(LogLevel.ERROR, 'No SDK key provided to getNotificationCenter.'); + return undefined; + } + + let notificationCenter; + if (this._notificationCenters.has(sdkKey)) { + notificationCenter = this._notificationCenters.get(sdkKey); + } else { + notificationCenter = createNotificationCenter({ + logger, + errorHandler: { handleError: () => {} }, + }); + this._notificationCenters.set(sdkKey, notificationCenter); + } + + return notificationCenter; + } + + public static removeNotificationCenter(sdkKey?: string): void { + if (!sdkKey) { + return; + } + + const notificationCenter = this._notificationCenters.get(sdkKey); + if (notificationCenter) { + notificationCenter.clearAllNotificationListeners(); + this._notificationCenters.delete(sdkKey); + } + } +} diff --git a/packages/optimizely-sdk/lib/core/odp/odp_config.ts b/packages/optimizely-sdk/lib/core/odp/odp_config.ts index 215d3655c..3bf407281 100644 --- a/packages/optimizely-sdk/lib/core/odp/odp_config.ts +++ b/packages/optimizely-sdk/lib/core/odp/odp_config.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ * limitations under the License. */ +import { checkArrayEquality } from '../../../lib/utils/fns'; + export class OdpConfig { /** * Host of ODP audience segments API. @@ -57,26 +59,24 @@ export class OdpConfig { return this._segmentsToCheck; } - constructor(apiKey: string, apiHost: string, segmentsToCheck?: string[]) { - this._apiKey = apiKey; - this._apiHost = apiHost; + constructor(apiKey?: string, apiHost?: string, segmentsToCheck?: string[]) { + this._apiKey = apiKey ?? ''; + this._apiHost = apiHost ?? ''; this._segmentsToCheck = segmentsToCheck ?? []; } /** * Update the ODP configuration details - * @param apiKey Public API key for the ODP account - * @param apiHost Host of ODP audience segments API - * @param segmentsToCheck Audience segments + * @param {OdpConfig} config New ODP Config to potentially update self with * @returns true if configuration was updated successfully */ - public update(apiKey: string, apiHost: string, segmentsToCheck: string[]): boolean { - if (this._apiKey === apiKey && this._apiHost === apiHost && this._segmentsToCheck === segmentsToCheck) { + public update(config: OdpConfig): boolean { + if (this.equals(config)) { return false; } else { - this._apiKey = apiKey; - this._apiHost = apiHost; - this._segmentsToCheck = segmentsToCheck; + if (config.apiKey) this._apiKey = config.apiKey; + if (config.apiHost) this._apiHost = config.apiHost; + if (config.segmentsToCheck) this._segmentsToCheck = config.segmentsToCheck; return true; } @@ -88,4 +88,17 @@ export class OdpConfig { public isReady(): boolean { return !!this._apiKey && !!this._apiHost; } + + /** + * Detects if there are any changes between the current and incoming ODP Configs + * @param configToCompare ODP Configuration to check self against for equality + * @returns Boolean based on if the current ODP Config is equivalent to the incoming ODP Config + */ + public equals(configToCompare: OdpConfig): boolean { + return ( + this._apiHost === configToCompare._apiHost && + this._apiKey === configToCompare._apiKey && + checkArrayEquality(this.segmentsToCheck, configToCompare._segmentsToCheck) + ); + } } diff --git a/packages/optimizely-sdk/lib/core/odp/odp_event_manager.ts b/packages/optimizely-sdk/lib/core/odp/odp_event_manager.ts index 8ac7bb041..7eda72a3b 100644 --- a/packages/optimizely-sdk/lib/core/odp/odp_event_manager.ts +++ b/packages/optimizely-sdk/lib/core/odp/odp_event_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,11 +15,14 @@ */ import { LogHandler, LogLevel } from '../../modules/logging'; -import { OdpEvent } from './odp_event'; + import { uuid } from '../../utils/fns'; -import { ODP_USER_KEY } from '../../utils/enums'; +import { ERROR_MESSAGES, ODP_USER_KEY, ODP_EVENT_TYPE } from '../../utils/enums'; + +import { OdpEvent } from './odp_event'; import { OdpConfig } from './odp_config'; import { OdpEventApiManager } from './odp_event_api_manager'; +import { invalidOdpDataFound } from './odp_utils'; const MAX_RETRIES = 3; const DEFAULT_BATCH_SIZE = 10; @@ -145,11 +148,18 @@ export class OdpEventManager implements IOdpEventManager { } /** - * Update ODP configuration settings - * @param odpConfig New configuration to apply + * Update ODP configuration settings. + * @param newConfig New configuration to apply */ - public updateSettings(odpConfig: OdpConfig): void { - this.odpConfig = odpConfig; + public updateSettings(newConfig: OdpConfig): void { + this.odpConfig = newConfig; + } + + /** + * Cleans up all pending events; occurs every time the ODP Config is updated. + */ + public flush(): void { + this.processQueue(true); } /** @@ -181,23 +191,31 @@ export class OdpEventManager implements IOdpEventManager { const identifiers = new Map(); identifiers.set(ODP_USER_KEY.VUID, vuid); - const event = new OdpEvent('fullstack', 'client_initialized', identifiers); + const event = new OdpEvent(ODP_EVENT_TYPE, 'client_initialized', identifiers); this.sendEvent(event); } /** * Associate a full-stack userid with an established VUID - * @param userId Full-stack User ID - * @param vuid Visitor User ID + * @param {string} userId (Optional) Full-stack User ID + * @param {string} vuid (Optional) Visitor User ID */ - public identifyUser(userId: string, vuid?: string): void { + public identifyUser(userId?: string, vuid?: string): void { const identifiers = new Map(); + if (!userId && !vuid) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_UID_MISSING); + return; + } + if (vuid) { identifiers.set(ODP_USER_KEY.VUID, vuid); } - identifiers.set(ODP_USER_KEY.FS_USER_ID, userId); - const event = new OdpEvent('fullstack', 'identified', identifiers); + if (userId) { + identifiers.set(ODP_USER_KEY.FS_USER_ID, userId); + } + + const event = new OdpEvent(ODP_EVENT_TYPE, 'identified', identifiers); this.sendEvent(event); } @@ -206,7 +224,7 @@ export class OdpEventManager implements IOdpEventManager { * @param event ODP Event to forward */ public sendEvent(event: OdpEvent): void { - if (this.invalidDataFound(event.data)) { + if (invalidOdpDataFound(event.data)) { this.logger.log(LogLevel.ERROR, 'Event data found to be invalid.'); } else { event.data = this.augmentCommonData(event.data); @@ -374,23 +392,6 @@ export class OdpEventManager implements IOdpEventManager { return false; } - /** - * Validate event data value types - * @param data Event data to be validated - * @returns True if an invalid type was found in the data otherwise False - * @private - */ - private invalidDataFound(data: Map): boolean { - const validTypes: string[] = ['string', 'number', 'boolean']; - let foundInvalidValue = false; - data.forEach(value => { - if (!validTypes.includes(typeof value) && value !== null) { - foundInvalidValue = true; - } - }); - return foundInvalidValue; - } - /** * Add additional common data including an idempotent ID and execution context to event data * @param sourceData Existing event data to augment diff --git a/packages/optimizely-sdk/lib/core/odp/odp_manager.ts b/packages/optimizely-sdk/lib/core/odp/odp_manager.ts new file mode 100644 index 000000000..b77a8de74 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/odp/odp_manager.ts @@ -0,0 +1,246 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BROWSER_CLIENT_VERSION, LOG_MESSAGES } from './../../utils/enums/index'; +import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; +import { ERROR_MESSAGES, ODP_USER_KEY } from '../../utils/enums'; + +import { RequestHandler } from './../../utils/http_request_handler/http'; +import { BrowserLRUCache } from './../../utils/lru_cache/browser_lru_cache'; +import { LRUCache } from './../../utils/lru_cache/lru_cache'; + +import { VuidManager } from '../../plugins/vuid_manager'; + +import { OdpConfig } from './odp_config'; +import { OdpEventManager } from './odp_event_manager'; +import { OdpSegmentManager } from './odp_segment_manager'; +import { OdpSegmentApiManager } from './odp_segment_api_manager'; +import { OdpEventApiManager } from './odp_event_api_manager'; +import { OptimizelySegmentOption } from './optimizely_segment_option'; +import { invalidOdpDataFound } from './odp_utils'; +import { OdpEvent } from './odp_event'; + +/** + * @param {boolean} disable Flag for disabling ODP Manager. + * @param {RequestHandler} requestHandler HTTP request handler that will be used by Segment and Event Managers. + * @param {LogHandler} logger (Optional) Accepts custom LogHandler. Defaults to the default global LogHandler. + * @param {string} clientEngine (Optional) String denoting specific client engine being used. Defaults to 'javascript-sdk'. + * @param {string} clientVersion (Optional) String denoting specific client version. Defaults to current version value from package.json. + * @param {LRUCache} segmentsCache (Optional) Accepts a custom LRUCache. Defaults to BrowserLRUCache. + * @param {OdpEventManager} eventManager (Optional) Accepts a custom ODPEventManager. + * @param {OdpSegmentManager} segmentManager (Optional) Accepts a custom ODPSegmentManager. + */ +interface OdpManagerConfig { + disable: boolean; + requestHandler: RequestHandler; + logger?: LogHandler; + clientEngine?: string; + clientVersion?: string; + segmentsCache?: LRUCache; + eventManager?: OdpEventManager; + segmentManager?: OdpSegmentManager; +} + +/** + * Orchestrates segments manager, event manager, and ODP configuration + */ +export class OdpManager { + enabled: boolean; + logger: LogHandler; + odpConfig: OdpConfig = new OdpConfig(); + + /** + * ODP Segment Manager which provides an interface to the remote ODP server (GraphQL API) for audience segments mapping. + * It fetches all qualified segments for the given user context and manages the segments cache for all user contexts. + */ + public segmentManager: OdpSegmentManager | undefined; + + /** + * ODP Event Manager which provides an interface to the remote ODP server (REST API) for events. + * It will queue all pending events (persistent) and send them (in batches of up to 10 events) to the ODP server when possible. + */ + public eventManager: OdpEventManager | undefined; + + constructor({ + disable, + requestHandler, + logger, + clientEngine, + clientVersion, + segmentsCache, + eventManager, + segmentManager, + }: OdpManagerConfig) { + this.enabled = !disable; + this.logger = logger || getLogger(); + + if (!this.enabled) { + this.logger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_ENABLED); + return; + } + + // Set up Segment Manager (Audience Segments GraphQL API Interface) + if (segmentManager) { + this.segmentManager = segmentManager; + this.segmentManager.updateSettings(this.odpConfig); + } else { + this.segmentManager = new OdpSegmentManager( + this.odpConfig, + segmentsCache || new BrowserLRUCache(), + new OdpSegmentApiManager(requestHandler, this.logger) + ); + } + + // Set up Events Manager (Events REST API Interface) + if (eventManager) { + this.eventManager = eventManager; + this.eventManager.updateSettings(this.odpConfig); + } else { + this.eventManager = new OdpEventManager({ + odpConfig: this.odpConfig, + apiManager: new OdpEventApiManager(requestHandler, this.logger), + logger: this.logger, + clientEngine: clientEngine || 'javascript-sdk', + clientVersion: clientVersion || BROWSER_CLIENT_VERSION, + }); + } + + this.eventManager.start(); + } + + /** + * Provides a method to update ODP Manager's ODP Config API Key, API Host, and Audience Segments + */ + public updateSettings({ apiKey, apiHost, segmentsToCheck }: OdpConfig): boolean { + if (!this.enabled) { + return false; + } + + if (!this.eventManager) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_MANAGER_UPDATE_SETTINGS_FAILED_EVENT_MANAGER_MISSING); + return false; + } + + if (!this.segmentManager) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_MANAGER_UPDATE_SETTINGS_FAILED_SEGMENTS_MANAGER_MISSING); + return false; + } + + this.eventManager.flush(); + + const newConfig = new OdpConfig(apiKey, apiHost, segmentsToCheck); + const configDidUpdate = this.odpConfig.update(newConfig); + + if (configDidUpdate) { + this.odpConfig.update(newConfig); + this.segmentManager?.reset(); + return true; + } + + return false; + } + + /** + * Attempts to stop the current instance of ODP Manager's event manager, if it exists and is running. + */ + public close(): void { + if (!this.enabled) { + return; + } + + this.eventManager?.stop(); + } + + /** + * Attempts to fetch and return a list of a user's qualified segments from the local segments cache. + * If no cached data exists for the target user, this fetches and caches data from the ODP server instead. + * @param {string} userId - Unique identifier of a target user. + * @param {Array} options - An array of OptimizelySegmentOption used to ignore and/or reset the cache. + * @returns {Promise} A promise holding either a list of qualified segments or null. + */ + public async fetchQualifiedSegments( + userId: string, + options: Array = [] + ): Promise { + if (!this.enabled) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED); + return null; + } + + if (!this.segmentManager) { + throw new Error(ERROR_MESSAGES.ODP_FETCH_QUALIFIED_SEGMENTS_SEGMENTS_MANAGER_MISSING); + } + + if (VuidManager.isVuid(userId)) { + return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.VUID, userId, options); + } + + return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, options); + } + + /** + * Identifies a user via the ODP Event Manager + * @param {string} userId (Optional) Custom unique identifier of a target user. + * @param {string} vuid (Optional) Secondary unique identifier of a target user, primarily used by client SDKs. + * @returns + */ + public identifyUser(userId?: string, vuid?: string): void { + if (!this.enabled) { + this.logger.log(LogLevel.DEBUG, LOG_MESSAGES.ODP_IDENTIFY_FAILED_ODP_DISABLED); + return; + } + + if (!this.odpConfig.isReady()) { + this.logger.log(LogLevel.DEBUG, LOG_MESSAGES.ODP_IDENTIFY_FAILED_ODP_NOT_INTEGRATED); + return; + } + + if (!this.eventManager) { + throw new Error(ERROR_MESSAGES.ODP_IDENTIFY_FAILED_EVENT_MANAGER_MISSING); + } + + if (userId && VuidManager.isVuid(userId)) { + this.eventManager.identifyUser(undefined, userId); + return; + } + + this.eventManager.identifyUser(userId, vuid); + } + + /** + * Sends an event to the ODP Server via the ODP Events API + * @param {OdpEvent} > ODP Event to send to event manager + */ + public sendEvent({ type, action, identifiers, data }: OdpEvent): void { + if (!this.enabled) { + throw new Error(ERROR_MESSAGES.ODP_NOT_ENABLED); + } + + if (!this.odpConfig.isReady()) { + throw new Error(ERROR_MESSAGES.ODP_NOT_INTEGRATED); + } + + if (invalidOdpDataFound(data)) { + throw new Error(ERROR_MESSAGES.ODP_INVALID_DATA); + } + + if (!this.eventManager) { + throw new Error(ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_EVENT_MANAGER_MISSING); + } + + this.eventManager.sendEvent(new OdpEvent(type, action, identifiers, data)); + } +} diff --git a/packages/optimizely-sdk/lib/core/odp/odp_segment_manager.ts b/packages/optimizely-sdk/lib/core/odp/odp_segment_manager.ts index b3d86d354..8a4deb283 100644 --- a/packages/optimizely-sdk/lib/core/odp/odp_segment_manager.ts +++ b/packages/optimizely-sdk/lib/core/odp/odp_segment_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,21 +21,50 @@ import { OdpSegmentApiManager } from './odp_segment_api_manager'; import { OdpConfig } from './odp_config'; import { OptimizelySegmentOption } from './optimizely_segment_option'; -// Schedules connections to ODP for audience segmentation and caches the results. +/** + * Schedules connections to ODP for audience segmentation and caches the results. + */ export class OdpSegmentManager { - odpConfig: OdpConfig; - segmentsCache: LRUCache>; - odpSegmentApiManager: OdpSegmentApiManager; - logger: LogHandler; + /** + * ODP configuration settings in used + * @private + */ + private odpConfig: OdpConfig; + + /** + * Holds cached audience segments + * @private + */ + private _segmentsCache: LRUCache; + + /** + * Getter for private segments cache + * @public + */ + public get segmentsCache(): LRUCache { + return this._segmentsCache; + } + + /** + * GraphQL API Manager used to fetch segments + * @private + */ + private odpSegmentApiManager: OdpSegmentApiManager; + + /** + * Handler for recording execution logs + * @private + */ + private readonly logger: LogHandler; constructor( odpConfig: OdpConfig, - segmentsCache: LRUCache>, + segmentsCache: LRUCache, odpSegmentApiManager: OdpSegmentApiManager, logger?: LogHandler ) { this.odpConfig = odpConfig; - this.segmentsCache = segmentsCache; + this._segmentsCache = segmentsCache; this.odpSegmentApiManager = odpSegmentApiManager; this.logger = logger || getLogger('OdpSegmentManager'); } @@ -52,7 +81,7 @@ export class OdpSegmentManager { userKey: ODP_USER_KEY, userValue: string, options: Array - ): Promise | null> { + ): Promise { const { apiHost: odpApiHost, apiKey: odpApiKey } = this.odpConfig; if (!odpApiKey || !odpApiHost) { @@ -74,7 +103,7 @@ export class OdpSegmentManager { if (resetCache) this.reset(); if (!ignoreCache && !resetCache) { - const cachedSegments = this.segmentsCache.lookup(cacheKey); + const cachedSegments = this._segmentsCache.lookup(cacheKey); if (cachedSegments) { this.logger.log(LogLevel.DEBUG, 'ODP cache hit. Returning segments from cache "%s".', cacheKey); return cachedSegments; @@ -92,7 +121,7 @@ export class OdpSegmentManager { segmentsToCheck ); - if (segments && !ignoreCache) this.segmentsCache.save({ key: cacheKey, value: segments }); + if (segments && !ignoreCache) this._segmentsCache.save({ key: cacheKey, value: segments }); return segments; } @@ -101,7 +130,7 @@ export class OdpSegmentManager { * Clears the segments cache */ reset(): void { - this.segmentsCache.reset(); + this._segmentsCache.reset(); } /** @@ -113,4 +142,12 @@ export class OdpSegmentManager { makeCacheKey(userKey: string, userValue: string): string { return `${userKey}-$-${userValue}`; } + + /** + * Updates the ODP Config settings of ODP Segment Manager + * @param config New ODP Config that will overwrite the existing config + */ + public updateSettings(config: OdpConfig): void { + this.odpConfig = config; + } } diff --git a/packages/optimizely-sdk/lib/core/odp/odp_types.ts b/packages/optimizely-sdk/lib/core/odp/odp_types.ts index cb034a3c9..3fed03408 100644 --- a/packages/optimizely-sdk/lib/core/odp/odp_types.ts +++ b/packages/optimizely-sdk/lib/core/odp/odp_types.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/core/odp/odp_utils.ts b/packages/optimizely-sdk/lib/core/odp/odp_utils.ts new file mode 100644 index 000000000..875b7e091 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/odp/odp_utils.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Validate event data value types + * @param data Event data to be validated + * @returns True if an invalid type was found in the data otherwise False + * @private + */ +export function invalidOdpDataFound(data: Map): boolean { + const validTypes: string[] = ['string', 'number', 'boolean']; + let foundInvalidValue = false; + data.forEach(value => { + if (!validTypes.includes(typeof value) && value !== null) { + foundInvalidValue = true; + } + }); + return foundInvalidValue; +} diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index 479d7c18c..fa4c03d16 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -1,11 +1,11 @@ /** - * Copyright 2016-2022, Optimizely + * Copyright 2016-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -836,8 +836,8 @@ describe('lib/core/project_config', function () { }) it('should contain all expected unique odp segments in allSegments', () => { - assert.equal(config.allSegments.size, 3) - assert.deepEqual(config.allSegments, new Set(['odp-segment-1', 'odp-segment-2', 'odp-segment-3'])) + assert.equal(config.allSegments.length, 3) + assert.deepEqual(config.allSegments, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3']) }) }); @@ -863,7 +863,7 @@ describe('lib/core/project_config', function () { }) it('should contain all expected unique odp segments in all segments', () => { - assert.equal(config.allSegments.size, 0) + assert.equal(config.allSegments.length, 0) }) }); diff --git a/packages/optimizely-sdk/lib/core/project_config/index.ts b/packages/optimizely-sdk/lib/core/project_config/index.ts index aa89cc566..5d3472c2b 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.ts +++ b/packages/optimizely-sdk/lib/core/project_config/index.ts @@ -1,11 +1,11 @@ /** - * Copyright 2016-2022, Optimizely + * Copyright 2016-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -13,21 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - find, - objectEntries, - objectValues, - sprintf, - assign, - keyBy -} from '../../utils/fns'; +import { find, objectEntries, objectValues, sprintf, assign, keyBy } from '../../utils/fns'; -import { - ERROR_MESSAGES, - LOG_LEVEL, - LOG_MESSAGES, - FEATURE_VARIABLE_TYPES, -} from '../../utils/enums'; +import { ERROR_MESSAGES, LOG_LEVEL, LOG_MESSAGES, FEATURE_VARIABLE_TYPES } from '../../utils/enums'; import configValidator from '../../utils/config_validator'; import { LogHandler } from '../../modules/logging'; @@ -51,7 +39,7 @@ interface TryCreatingProjectConfigConfig { // eslint-disable-next-line @typescript-eslint/ban-types datafile: string | object; jsonSchemaValidator?: { - validate(jsonObject: unknown): boolean, + validate(jsonObject: unknown): boolean; }; logger: LogHandler; } @@ -103,7 +91,7 @@ export interface ProjectConfig { integrationKeyMap?: { [key: string]: Integration }; publicKeyForOdp?: string; hostForOdp?: string; - allSegments: Set; + allSegments: string[]; } const EXPERIMENT_RUNNING_STATUS = 'Running'; @@ -124,14 +112,14 @@ function createMutationSafeDatafileCopy(datafile: any): ProjectConfig { }); datafileCopy.groups = (datafile.groups || []).map((group: Group) => { const groupCopy = assign({}, group); - groupCopy.experiments = (group.experiments || []).map((experiment) => { + groupCopy.experiments = (group.experiments || []).map(experiment => { return assign({}, experiment); }); return groupCopy; }); datafileCopy.rollouts = (datafile.rollouts || []).map((rollout: Rollout) => { const rolloutCopy = assign({}, rollout); - rolloutCopy.experiments = (rollout.experiments || []).map((experiment) => { + rolloutCopy.experiments = (rollout.experiments || []).map(experiment => { return assign({}, experiment); }); return rolloutCopy; @@ -149,10 +137,7 @@ function createMutationSafeDatafileCopy(datafile: any): ProjectConfig { * @param {string|null} datafileStr JSON string representation of the datafile * @return {ProjectConfig} Object representing project configuration */ -export const createProjectConfig = function ( - datafileObj?: JSON, - datafileStr: string | null = null -): ProjectConfig { +export const createProjectConfig = function(datafileObj?: JSON, datafileStr: string | null = null): ProjectConfig { const projectConfig = createMutationSafeDatafileCopy(datafileObj); projectConfig.__datafileStr = datafileStr === null ? JSON.stringify(datafileObj) : datafileStr; @@ -161,53 +146,54 @@ export const createProjectConfig = function ( * Conditions of audiences in projectConfig.typedAudiences are not * expected to be string-encoded as they are here in projectConfig.audiences. */ - (projectConfig.audiences || []).forEach((audience) => { + (projectConfig.audiences || []).forEach(audience => { audience.conditions = JSON.parse(audience.conditions as string); }); projectConfig.audiencesById = keyBy(projectConfig.audiences, 'id'); assign(projectConfig.audiencesById, keyBy(projectConfig.typedAudiences, 'id')); - projectConfig.allSegments = new Set([]) + projectConfig.allSegments = []; + const allSegmentsSet = new Set(); Object.keys(projectConfig.audiencesById) - .map((audience) => getAudienceSegments(projectConfig.audiencesById[audience])) + .map(audience => getAudienceSegments(projectConfig.audiencesById[audience])) .forEach(audienceSegments => { audienceSegments.forEach(segment => { - projectConfig.allSegments.add(segment) - }) - }) + allSegmentsSet.add(segment); + }); + }); + + projectConfig.allSegments = Array.from(allSegmentsSet); projectConfig.attributeKeyMap = keyBy(projectConfig.attributes, 'key'); projectConfig.eventKeyMap = keyBy(projectConfig.events, 'key'); projectConfig.groupIdMap = keyBy(projectConfig.groups, 'id'); let experiments; - Object.keys(projectConfig.groupIdMap || {}).forEach((Id) => { + Object.keys(projectConfig.groupIdMap || {}).forEach(Id => { experiments = projectConfig.groupIdMap[Id].experiments; - (experiments || []).forEach((experiment) => { + (experiments || []).forEach(experiment => { projectConfig.experiments.push(assign(experiment, { groupId: Id })); }); }); projectConfig.rolloutIdMap = keyBy(projectConfig.rollouts || [], 'id'); - objectValues(projectConfig.rolloutIdMap || {}).forEach( - (rollout) => { - (rollout.experiments || []).forEach((experiment) => { - projectConfig.experiments.push(experiment); - // Creates { : } map inside of the experiment - experiment.variationKeyMap = keyBy(experiment.variations, 'key'); - }); - } - ); + objectValues(projectConfig.rolloutIdMap || {}).forEach(rollout => { + (rollout.experiments || []).forEach(experiment => { + projectConfig.experiments.push(experiment); + // Creates { : } map inside of the experiment + experiment.variationKeyMap = keyBy(experiment.variations, 'key'); + }); + }); if (projectConfig.integrations) { projectConfig.integrationKeyMap = keyBy(projectConfig.integrations, 'key'); projectConfig.integrations - .filter((integration) => integration.key === 'odp') - .forEach((integration) => { - if (integration.publicKey) projectConfig.publicKeyForOdp = integration.publicKey - if (integration.host) projectConfig.hostForOdp = integration.host - }) + .filter(integration => integration.key === 'odp') + .forEach(integration => { + if (integration.publicKey) projectConfig.publicKeyForOdp = integration.publicKey; + if (integration.host) projectConfig.hostForOdp = integration.host; + }); } projectConfig.experimentKeyMap = keyBy(projectConfig.experiments, 'key'); @@ -215,13 +201,13 @@ export const createProjectConfig = function ( projectConfig.variationIdMap = {}; projectConfig.variationVariableUsageMap = {}; - (projectConfig.experiments || []).forEach((experiment) => { + (projectConfig.experiments || []).forEach(experiment => { // Creates { : } map inside of the experiment experiment.variationKeyMap = keyBy(experiment.variations, 'key'); // Creates { : { key: , id: } } mapping for quick lookup assign(projectConfig.variationIdMap, keyBy(experiment.variations, 'id')); - objectValues(experiment.variationKeyMap || {}).forEach((variation) => { + objectValues(experiment.variationKeyMap || {}).forEach(variation => { if (variation.variables) { projectConfig.variationVariableUsageMap[variation.id] = keyBy(variation.variables, 'id'); } @@ -233,28 +219,26 @@ export const createProjectConfig = function ( projectConfig.experimentFeatureMap = {}; projectConfig.featureKeyMap = keyBy(projectConfig.featureFlags || [], 'key'); - objectValues(projectConfig.featureKeyMap || {}).forEach( - (feature) => { - // Json type is represented in datafile as a subtype of string for the sake of backwards compatibility. - // Converting it to a first-class json type while creating Project Config - feature.variables.forEach((variable) => { - if (variable.type === FEATURE_VARIABLE_TYPES.STRING && variable.subType === FEATURE_VARIABLE_TYPES.JSON) { - variable.type = FEATURE_VARIABLE_TYPES.JSON as VariableType; - delete variable.subType; - } - }); + objectValues(projectConfig.featureKeyMap || {}).forEach(feature => { + // Json type is represented in datafile as a subtype of string for the sake of backwards compatibility. + // Converting it to a first-class json type while creating Project Config + feature.variables.forEach(variable => { + if (variable.type === FEATURE_VARIABLE_TYPES.STRING && variable.subType === FEATURE_VARIABLE_TYPES.JSON) { + variable.type = FEATURE_VARIABLE_TYPES.JSON as VariableType; + delete variable.subType; + } + }); - feature.variableKeyMap = keyBy(feature.variables, 'key'); - (feature.experimentIds || []).forEach((experimentId) => { - // Add this experiment in experiment-feature map. - if (projectConfig.experimentFeatureMap[experimentId]) { - projectConfig.experimentFeatureMap[experimentId].push(feature.id); - } else { - projectConfig.experimentFeatureMap[experimentId] = [feature.id]; - } - }); - } - ); + feature.variableKeyMap = keyBy(feature.variables, 'key'); + (feature.experimentIds || []).forEach(experimentId => { + // Add this experiment in experiment-feature map. + if (projectConfig.experimentFeatureMap[experimentId]) { + projectConfig.experimentFeatureMap[experimentId].push(feature.id); + } else { + projectConfig.experimentFeatureMap[experimentId] = [feature.id]; + } + }); + }); // all rules (experiment rules and delivery rules) for each flag projectConfig.flagRulesMap = {}; @@ -281,19 +265,17 @@ export const createProjectConfig = function ( // - we collect variations used in each rule (experiment rules and delivery rules) projectConfig.flagVariationsMap = {}; - objectEntries(projectConfig.flagRulesMap || {}).forEach( - ([flagKey, rules]) => { - const variations: OptimizelyVariation[] = []; - rules.forEach(rule => { - rule.variations.forEach(variation => { - if (!find(variations, item => item.id === variation.id)) { - variations.push(variation); - } - }); + objectEntries(projectConfig.flagRulesMap || {}).forEach(([flagKey, rules]) => { + const variations: OptimizelyVariation[] = []; + rules.forEach(rule => { + rule.variations.forEach(variation => { + if (!find(variations, item => item.id === variation.id)) { + variations.push(variation); + } }); - projectConfig.flagVariationsMap[flagKey] = variations; - } - ); + }); + projectConfig.flagVariationsMap[flagKey] = variations; + }); return projectConfig; }; @@ -303,8 +285,8 @@ export const createProjectConfig = function ( * @param {Audience} audience Object representing the audience being parsed * @return {string[]} List of all audience segments */ -export const getAudienceSegments = function (audience: Audience): string[] { - if (!audience.conditions) return [] +export const getAudienceSegments = function(audience: Audience): string[] { + if (!audience.conditions) return []; return getSegmentsFromConditions(audience.conditions); }; @@ -313,20 +295,18 @@ const getSegmentsFromConditions = (condition: any): string[] => { const segments = []; if (isLogicalOperator(condition)) { - return [] - } - else if (Array.isArray(condition)) { - condition.forEach((nextCondition) => segments.push(...getSegmentsFromConditions(nextCondition))) - } - else if (condition['match'] === 'qualified') { - segments.push(condition['value']) + return []; + } else if (Array.isArray(condition)) { + condition.forEach(nextCondition => segments.push(...getSegmentsFromConditions(nextCondition))); + } else if (condition['match'] === 'qualified') { + segments.push(condition['value']); } return segments; -} +}; function isLogicalOperator(condition: string): boolean { - return ['and', 'or', 'not'].includes(condition) + return ['and', 'or', 'not'].includes(condition); } /** @@ -336,7 +316,7 @@ function isLogicalOperator(condition: string): boolean { * @return {string} Experiment ID corresponding to the provided experiment key * @throws If experiment key is not in datafile */ -export const getExperimentId = function (projectConfig: ProjectConfig, experimentKey: string): string { +export const getExperimentId = function(projectConfig: ProjectConfig, experimentKey: string): string { const experiment = projectConfig.experimentKeyMap[experimentKey]; if (!experiment) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, MODULE_NAME, experimentKey)); @@ -351,7 +331,7 @@ export const getExperimentId = function (projectConfig: ProjectConfig, experimen * @return {string} Layer ID corresponding to the provided experiment key * @throws If experiment key is not in datafile */ -export const getLayerId = function (projectConfig: ProjectConfig, experimentId: string): string { +export const getLayerId = function(projectConfig: ProjectConfig, experimentId: string): string { const experiment = projectConfig.experimentIdMap[experimentId]; if (!experiment) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_ID, MODULE_NAME, experimentId)); @@ -366,7 +346,7 @@ export const getLayerId = function (projectConfig: ProjectConfig, experimentId: * @param {LogHandler} logger * @return {string|null} Attribute ID corresponding to the provided attribute key. Attribute key if it is a reserved attribute. */ -export const getAttributeId = function ( +export const getAttributeId = function( projectConfig: ProjectConfig, attributeKey: string, logger: LogHandler @@ -379,7 +359,7 @@ export const getAttributeId = function ( LOG_LEVEL.WARNING, 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID instead of reserved attribute name.', attributeKey, - RESERVED_ATTRIBUTE_PREFIX, + RESERVED_ATTRIBUTE_PREFIX ); } return attribute.id; @@ -397,7 +377,7 @@ export const getAttributeId = function ( * @param {string} eventKey Event key for which ID is to be determined * @return {string|null} Event ID corresponding to the provided event key */ -export const getEventId = function (projectConfig: ProjectConfig, eventKey: string): string | null { +export const getEventId = function(projectConfig: ProjectConfig, eventKey: string): string | null { const event = projectConfig.eventKeyMap[eventKey]; if (event) { return event.id; @@ -412,7 +392,7 @@ export const getEventId = function (projectConfig: ProjectConfig, eventKey: stri * @return {string} Experiment status corresponding to the provided experiment key * @throws If experiment key is not in datafile */ -export const getExperimentStatus = function (projectConfig: ProjectConfig, experimentKey: string): string { +export const getExperimentStatus = function(projectConfig: ProjectConfig, experimentKey: string): string { const experiment = projectConfig.experimentKeyMap[experimentKey]; if (!experiment) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, MODULE_NAME, experimentKey)); @@ -426,7 +406,7 @@ export const getExperimentStatus = function (projectConfig: ProjectConfig, exper * @param {string} experimentKey Experiment key for which status is to be compared with 'Running' * @return {boolean} True if experiment status is set to 'Running', false otherwise */ -export const isActive = function (projectConfig: ProjectConfig, experimentKey: string): boolean { +export const isActive = function(projectConfig: ProjectConfig, experimentKey: string): boolean { return getExperimentStatus(projectConfig, experimentKey) === EXPERIMENT_RUNNING_STATUS; }; @@ -438,7 +418,7 @@ export const isActive = function (projectConfig: ProjectConfig, experimentKey: s * False if the experiment is not running * */ -export const isRunning = function (projectConfig: ProjectConfig, experimentKey: string): boolean { +export const isRunning = function(projectConfig: ProjectConfig, experimentKey: string): boolean { return getExperimentStatus(projectConfig, experimentKey) === EXPERIMENT_RUNNING_STATUS; }; @@ -451,7 +431,7 @@ export const isRunning = function (projectConfig: ProjectConfig, experimentKey: * Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"] * @throws If experiment key is not in datafile */ -export const getExperimentAudienceConditions = function ( +export const getExperimentAudienceConditions = function( projectConfig: ProjectConfig, experimentId: string ): Array { @@ -469,7 +449,7 @@ export const getExperimentAudienceConditions = function ( * @param {string} variationId ID of the variation * @return {string|null} Variation key or null if the variation ID is not found */ -export const getVariationKeyFromId = function (projectConfig: ProjectConfig, variationId: string): string | null { +export const getVariationKeyFromId = function(projectConfig: ProjectConfig, variationId: string): string | null { if (projectConfig.variationIdMap.hasOwnProperty(variationId)) { return projectConfig.variationIdMap[variationId].key; } @@ -483,7 +463,7 @@ export const getVariationKeyFromId = function (projectConfig: ProjectConfig, var * @param {string} variationId ID of the variation * @return {Variation|null} Variation or null if the variation ID is not found */ -export const getVariationFromId = function (projectConfig: ProjectConfig, variationId: string): Variation | null { +export const getVariationFromId = function(projectConfig: ProjectConfig, variationId: string): Variation | null { if (projectConfig.variationIdMap.hasOwnProperty(variationId)) { return projectConfig.variationIdMap[variationId]; } @@ -498,7 +478,7 @@ export const getVariationFromId = function (projectConfig: ProjectConfig, variat * @param {string} variationKey The variation key * @return {string|null} Variation ID or null */ -export const getVariationIdFromExperimentAndVariationKey = function ( +export const getVariationIdFromExperimentAndVariationKey = function( projectConfig: ProjectConfig, experimentKey: string, variationKey: string @@ -518,7 +498,7 @@ export const getVariationIdFromExperimentAndVariationKey = function ( * @return {Experiment} Experiment * @throws If experiment key is not in datafile */ -export const getExperimentFromKey = function (projectConfig: ProjectConfig, experimentKey: string): Experiment { +export const getExperimentFromKey = function(projectConfig: ProjectConfig, experimentKey: string): Experiment { if (projectConfig.experimentKeyMap.hasOwnProperty(experimentKey)) { const experiment = projectConfig.experimentKeyMap[experimentKey]; if (experiment) { @@ -536,7 +516,7 @@ export const getExperimentFromKey = function (projectConfig: ProjectConfig, expe * @return {TrafficAllocation[]} Traffic allocation for the experiment * @throws If experiment key is not in datafile */ -export const getTrafficAllocation = function (projectConfig: ProjectConfig, experimentId: string): TrafficAllocation[] { +export const getTrafficAllocation = function(projectConfig: ProjectConfig, experimentId: string): TrafficAllocation[] { const experiment = projectConfig.experimentIdMap[experimentId]; if (!experiment) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_ID, MODULE_NAME, experimentId)); @@ -552,7 +532,7 @@ export const getTrafficAllocation = function (projectConfig: ProjectConfig, expe * @param {LogHandler} logger * @return {Experiment|null} Experiment object or null */ -export const getExperimentFromId = function ( +export const getExperimentFromId = function( projectConfig: ProjectConfig, experimentId: string, logger: LogHandler @@ -569,18 +549,22 @@ export const getExperimentFromId = function ( }; /** -* Returns flag variation for specified flagKey and variationKey -* @param {flagKey} string -* @param {variationKey} string -* @return {Variation|null} -*/ -export const getFlagVariationByKey = function (projectConfig: ProjectConfig, flagKey: string, variationKey: string): Variation | null { + * Returns flag variation for specified flagKey and variationKey + * @param {flagKey} string + * @param {variationKey} string + * @return {Variation|null} + */ +export const getFlagVariationByKey = function( + projectConfig: ProjectConfig, + flagKey: string, + variationKey: string +): Variation | null { if (!projectConfig) { return null; } const variations = projectConfig.flagVariationsMap[flagKey]; - const result = find(variations, item => item.key === variationKey) + const result = find(variations, item => item.key === variationKey); if (result) { return result; } @@ -597,7 +581,7 @@ export const getFlagVariationByKey = function (projectConfig: ProjectConfig, fla * @return {FeatureFlag|null} Feature object, or null if no feature with the given * key exists */ -export const getFeatureFromKey = function ( +export const getFeatureFromKey = function( projectConfig: ProjectConfig, featureKey: string, logger: LogHandler @@ -624,7 +608,7 @@ export const getFeatureFromKey = function ( * @return {FeatureVariable|null} Variable object, or null one or both of the given * feature and variable keys are invalid */ -export const getVariableForFeature = function ( +export const getVariableForFeature = function( projectConfig: ProjectConfig, featureKey: string, variableKey: string, @@ -638,13 +622,7 @@ export const getVariableForFeature = function ( const variable = feature.variableKeyMap[variableKey]; if (!variable) { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.VARIABLE_KEY_NOT_IN_DATAFILE, - MODULE_NAME, - variableKey, - featureKey, - ); + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.VARIABLE_KEY_NOT_IN_DATAFILE, MODULE_NAME, variableKey, featureKey); return null; } @@ -663,7 +641,7 @@ export const getVariableForFeature = function ( * variation, or null if the given variable has no value * for the given variation or if the variation or variable are invalid */ -export const getVariableValueForVariation = function ( +export const getVariableValueForVariation = function( projectConfig: ProjectConfig, variable: FeatureVariable, variation: Variation, @@ -674,12 +652,7 @@ export const getVariableValueForVariation = function ( } if (!projectConfig.variationVariableUsageMap.hasOwnProperty(variation.id)) { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.VARIATION_ID_NOT_IN_DATAFILE_NO_EXPERIMENT, - MODULE_NAME, - variation.id, - ); + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.VARIATION_ID_NOT_IN_DATAFILE_NO_EXPERIMENT, MODULE_NAME, variation.id); return null; } @@ -705,7 +678,7 @@ export const getVariableValueForVariation = function ( * @returns {*} Variable value of the appropriate type, or * null if the type cast failed */ -export const getTypeCastValue = function ( +export const getTypeCastValue = function( variableValue: string, variableType: VariableType, logger: LogHandler @@ -715,13 +688,7 @@ export const getTypeCastValue = function ( switch (variableType) { case FEATURE_VARIABLE_TYPES.BOOLEAN: if (variableValue !== 'true' && variableValue !== 'false') { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, - MODULE_NAME, - variableValue, - variableType, - ); + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, MODULE_NAME, variableValue, variableType); castValue = null; } else { castValue = variableValue === 'true'; @@ -731,13 +698,7 @@ export const getTypeCastValue = function ( case FEATURE_VARIABLE_TYPES.INTEGER: castValue = parseInt(variableValue, 10); if (isNaN(castValue)) { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, - MODULE_NAME, - variableValue, - variableType, - ); + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, MODULE_NAME, variableValue, variableType); castValue = null; } break; @@ -745,13 +706,7 @@ export const getTypeCastValue = function ( case FEATURE_VARIABLE_TYPES.DOUBLE: castValue = parseFloat(variableValue); if (isNaN(castValue)) { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, - MODULE_NAME, - variableValue, - variableType, - ); + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, MODULE_NAME, variableValue, variableType); castValue = null; } break; @@ -759,14 +714,8 @@ export const getTypeCastValue = function ( case FEATURE_VARIABLE_TYPES.JSON: try { castValue = JSON.parse(variableValue); - } catch (e: any) { - logger.log( - LOG_LEVEL.ERROR, - ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, - MODULE_NAME, - variableValue, - variableType, - ); + } catch (e) { + logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.UNABLE_TO_CAST_VALUE, MODULE_NAME, variableValue, variableType); castValue = null; } break; @@ -786,7 +735,7 @@ export const getTypeCastValue = function ( * @param {ProjectConfig} projectConfig * @returns {{ [id: string]: Audience }} */ -export const getAudiencesById = function (projectConfig: ProjectConfig): { [id: string]: Audience } { +export const getAudiencesById = function(projectConfig: ProjectConfig): { [id: string]: Audience } { return projectConfig.audiencesById; }; @@ -796,7 +745,7 @@ export const getAudiencesById = function (projectConfig: ProjectConfig): { [id: * @param {string} eventKey * @returns {boolean} */ -export const eventWithKeyExists = function (projectConfig: ProjectConfig, eventKey: string): boolean { +export const eventWithKeyExists = function(projectConfig: ProjectConfig, eventKey: string): boolean { return projectConfig.eventKeyMap.hasOwnProperty(eventKey); }; @@ -804,9 +753,9 @@ export const eventWithKeyExists = function (projectConfig: ProjectConfig, eventK * Returns true if experiment belongs to any feature, false otherwise. * @param {ProjectConfig} projectConfig * @param {string} experimentId - * @returns {boolean} + * @returns {boolean} */ -export const isFeatureExperiment = function (projectConfig: ProjectConfig, experimentId: string): boolean { +export const isFeatureExperiment = function(projectConfig: ProjectConfig, experimentId: string): boolean { return projectConfig.experimentFeatureMap.hasOwnProperty(experimentId); }; @@ -815,9 +764,9 @@ export const isFeatureExperiment = function (projectConfig: ProjectConfig, exper * @param {ProjectConfig} projectConfig * @returns {string} */ -export const toDatafile = function (projectConfig: ProjectConfig): string { +export const toDatafile = function(projectConfig: ProjectConfig): string { return projectConfig.__datafileStr; -} +}; /** * @typedef {Object} @@ -837,13 +786,13 @@ export const toDatafile = function (projectConfig: ProjectConfig): string { * @param {Object} config.logger * @returns {Object} Object containing configObj and error properties */ -export const tryCreatingProjectConfig = function ( +export const tryCreatingProjectConfig = function( config: TryCreatingProjectConfigConfig ): { configObj: ProjectConfig | null; error: Error | null } { let newDatafileObj; try { newDatafileObj = configValidator.validateDatafile(config.datafile); - } catch (error: any) { + } catch (error) { return { configObj: null, error }; } @@ -851,7 +800,7 @@ export const tryCreatingProjectConfig = function ( try { config.jsonSchemaValidator.validate(newDatafileObj); config.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME); - } catch (error : any) { + } catch (error) { return { configObj: null, error }; } } else { @@ -877,9 +826,9 @@ export const tryCreatingProjectConfig = function ( * @param {ProjectConfig} projectConfig * @return {boolean} A boolean value that indicates if we should send flag decisions */ -export const getSendFlagDecisionsValue = function (projectConfig: ProjectConfig): boolean { +export const getSendFlagDecisionsValue = function(projectConfig: ProjectConfig): boolean { return !!projectConfig.sendFlagDecisions; -} +}; export default { createProjectConfig, diff --git a/packages/optimizely-sdk/lib/index.browser.tests.js b/packages/optimizely-sdk/lib/index.browser.tests.js index 69822f46c..c2417b46b 100644 --- a/packages/optimizely-sdk/lib/index.browser.tests.js +++ b/packages/optimizely-sdk/lib/index.browser.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2020, 2022 Optimizely + * Copyright 2016-2020, 2022-2023 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/modules/datafile-manager/httpPollingDatafileManager.ts b/packages/optimizely-sdk/lib/modules/datafile-manager/httpPollingDatafileManager.ts index 7d11a14cd..fcf2c0efd 100644 --- a/packages/optimizely-sdk/lib/modules/datafile-manager/httpPollingDatafileManager.ts +++ b/packages/optimizely-sdk/lib/modules/datafile-manager/httpPollingDatafileManager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,9 @@ import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE } fr import BackoffController from './backoffController'; import PersistentKeyValueCache from './persistentKeyValueCache'; +import { NotificationRegistry } from './../../core/notification_center/notification_registry'; +import { NOTIFICATION_TYPES } from '../../../lib/utils/enums'; + const logger = getLogger('DatafileManager'); const UPDATE_EVT = 'update'; @@ -95,6 +98,8 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana private cache: PersistentKeyValueCache; + private sdkKey: string; + // When true, this means the update interval timeout fired before the current // sync completed. In that case, we should sync again immediately upon // completion of the current request, instead of waiting another update @@ -117,6 +122,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana this.cache = cache; this.cacheKey = 'opt-datafile-' + sdkKey; + this.sdkKey = sdkKey; this.isReadyPromiseSettled = false; this.readyPromiseResolver = (): void => {}; this.readyPromiseRejecter = (): void => {}; @@ -232,6 +238,9 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana const datafileUpdate: DatafileUpdate = { datafile, }; + NotificationRegistry.getNotificationCenter(this.sdkKey, logger)?.sendNotifications( + NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE + ); this.emitter.emit(UPDATE_EVT, datafileUpdate); } } diff --git a/packages/optimizely-sdk/lib/optimizely/index.ts b/packages/optimizely-sdk/lib/optimizely/index.ts index d6fdd8beb..8d6b6ccd4 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.ts +++ b/packages/optimizely-sdk/lib/optimizely/index.ts @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2020-2022, Optimizely, Inc. and contributors * + * Copyright 2020-2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * * * - * http://www.apache.org/licenses/LICENSE-2.0 * + * https://www.apache.org/licenses/LICENSE-2.0 * * * * Unless required by applicable law or agreed to in writing, software * * distributed under the License is distributed on an "AS IS" BASIS, * @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * * limitations under the License. * ***************************************************************************/ + import { LoggerFacade, ErrorHandler } from '../modules/logging'; import { sprintf, objectValues } from '../utils/fns'; import { NotificationCenter } from '../core/notification_center'; import { EventProcessor } from '../../lib/modules/event_processor'; +import { OdpManager } from '../core/odp/odp_manager'; +import { OdpConfig } from '../core/odp/odp_config'; + import { UserAttributes, EventTags, @@ -29,7 +33,7 @@ import { FeatureVariable, OptimizelyOptions, OptimizelyDecideOption, - OptimizelyDecision + OptimizelyDecision, } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; @@ -37,6 +41,7 @@ import { createProjectConfigManager, ProjectConfigManager } from '../core/projec import { createDecisionService, DecisionService, DecisionObj } from '../core/decision_service'; import { getImpressionEvent, getConversionEvent } from '../core/event_builder'; import { buildImpressionEvent, buildConversionEvent } from '../core/event_builder/event_helpers'; +import { NotificationRegistry } from '../core/notification_center/notification_registry'; import fns from '../utils/fns' import { validate } from '../utils/attributes_validator'; import * as enums from '../utils/enums'; @@ -81,6 +86,7 @@ export default class Optimizely { private decisionService: DecisionService; private eventProcessor: EventProcessor; private defaultDecideOptions: { [key: string]: boolean }; + private odpManager?: OdpManager; public notificationCenter: NotificationCenter; constructor(config: OptimizelyOptions) { @@ -168,13 +174,27 @@ export default class Optimizely { const eventProcessorStartedPromise = this.eventProcessor.start(); - this.readyPromise = Promise.all([projectConfigManagerReadyPromise, eventProcessorStartedPromise]).then(function(promiseResults) { + this.readyPromise = Promise.all([projectConfigManagerReadyPromise, eventProcessorStartedPromise]).then((promiseResults) => { + if (config.odpManager != null) { + this.odpManager = config.odpManager; + this.odpManager.eventManager?.start(); + this.updateODPSettings(); + const sdkKey = this.projectConfigManager.getConfig()?.sdkKey; + if (sdkKey != null) { + NotificationRegistry.getNotificationCenter(sdkKey, this.logger) + ?.addNotificationListener(enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, () => this.updateODPSettings()); + } else { + this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.ODP_SDK_KEY_MISSING_NOTIFICATION_CENTER_FAILURE); + } + } + // Only return status from project config promise because event processor promise does not return any status. return promiseResults[0]; }) this.readyTimeouts = {}; this.nextReadyTimeoutId = 0; + } /** @@ -1315,6 +1335,12 @@ export default class Optimizely { */ close(): Promise<{ success: boolean; reason?: string }> { try { + this.notificationCenter.clearAllNotificationListeners(); + const sdkKey = this.projectConfigManager.getConfig()?.sdkKey; + if (sdkKey) { + NotificationRegistry.removeNotificationCenter(sdkKey); + } + const eventProcessorStoppedPromise = this.eventProcessor.stop(); if (this.disposeOnUpdate) { this.disposeOnUpdate(); @@ -1672,4 +1698,15 @@ export default class Optimizely { return this.decideForKeys(user, allFlagKeys, options); } + /** + * Updates ODP Config with most recent ODP key, host, and segments from the project config + */ + updateODPSettings(): void { + const projectConfig = this.projectConfigManager.getConfig(); + if (this.odpManager != null && projectConfig != null) { + this.odpManager.updateSettings( + new OdpConfig(projectConfig.publicKeyForOdp, projectConfig.hostForOdp, projectConfig.allSegments) + ); + } + } } diff --git a/packages/optimizely-sdk/lib/plugins/datafile_manager/no_op_datafile_manager.ts b/packages/optimizely-sdk/lib/plugins/datafile_manager/no_op_datafile_manager.ts index eb602c371..2f1926d4f 100644 --- a/packages/optimizely-sdk/lib/plugins/datafile_manager/no_op_datafile_manager.ts +++ b/packages/optimizely-sdk/lib/plugins/datafile_manager/no_op_datafile_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021, Optimizely + * Copyright 2021, 2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,13 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DatafileManager, DatafileUpdateListener} from '../../shared_types'; +import { DatafileManager, DatafileUpdateListener } from '../../shared_types'; +/** + * No-operation Datafile Manager for Lite Bundle designed for Edge platforms + * https://github.com/optimizely/javascript-sdk/issues/699 + */ class NoOpDatafileManager implements DatafileManager { - /* eslint-disable @typescript-eslint/no-unused-vars */ on(_eventName: string, _listener: DatafileUpdateListener): () => void { - return (): void => {} + return (): void => {}; } get(): string { diff --git a/packages/optimizely-sdk/lib/plugins/odp_manager/index.browser.ts b/packages/optimizely-sdk/lib/plugins/odp_manager/index.browser.ts new file mode 100644 index 000000000..ae2bcfd70 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp_manager/index.browser.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BROWSER_CLIENT_VERSION, ERROR_MESSAGES, JAVASCRIPT_CLIENT_ENGINE, ODP_USER_KEY } from '../../utils/enums'; +import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; +import { BrowserRequestHandler } from './../../utils/http_request_handler/browser_request_handler'; + +import BrowserAsyncStorageCache from '../key_value_cache/browserAsyncStorageCache'; +import PersistentKeyValueCache from '../key_value_cache/persistentKeyValueCache'; +import { BrowserLRUCache, LRUCache } from '../../utils/lru_cache'; + +import { VuidManager } from './../vuid_manager/index'; + +import { OdpManager } from '../../core/odp/odp_manager'; +import { OdpEvent } from '../../core/odp/odp_event'; +import { OdpEventManager } from '../../core/odp/odp_event_manager'; +import { OdpSegmentManager } from '../../core/odp/odp_segment_manager'; + +interface BrowserOdpManagerConfig { + disable: boolean; + logger?: LogHandler; + segmentsCache?: LRUCache; + eventManager?: OdpEventManager; + segmentManager?: OdpSegmentManager; +} + +// Client-side Browser Plugin for ODP Manager +export class BrowserOdpManager extends OdpManager { + static cache = new BrowserAsyncStorageCache(); + vuid?: string; + + constructor({ disable, logger, segmentsCache, eventManager, segmentManager }: BrowserOdpManagerConfig) { + const browserLogger = logger || getLogger(); + + const browserRequestHandler = new BrowserRequestHandler(browserLogger); + const browserClientEngine = JAVASCRIPT_CLIENT_ENGINE; + const browserClientVersion = BROWSER_CLIENT_VERSION; + + super({ + disable, + requestHandler: browserRequestHandler, + logger: browserLogger, + clientEngine: browserClientEngine, + clientVersion: browserClientVersion, + segmentsCache: segmentsCache || new BrowserLRUCache(), + eventManager, + segmentManager, + }); + + this.logger = browserLogger; + this.initializeVuid(BrowserOdpManager.cache); + } + + /** + * Upon initializing BrowserOdpManager, accesses or creates new VUID from Browser cache and registers it via the Event Manager + */ + private async initializeVuid(cache: PersistentKeyValueCache): Promise { + const vuidManager = await VuidManager.instance(cache); + this.vuid = vuidManager.vuid; + this.registerVuid(this.vuid); + } + + private registerVuid(vuid: string) { + if (!this.eventManager) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED_EVENT_MANAGER_MISSING); + return; + } + + try { + this.eventManager.registerVuid(vuid); + } catch (e) { + this.logger.log(this.enabled ? LogLevel.ERROR : LogLevel.DEBUG, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED); + } + } + + /** + * @override + * - Still identifies a user via the ODP Event Manager + * - Additionally, also passes VUID to help identify client-side users + * @param fsUserId Unique identifier of a target user. + */ + public identifyUser(fsUserId?: string, vuid?: string): void { + if (fsUserId && VuidManager.isVuid(fsUserId)) { + super.identifyUser(undefined, fsUserId); + return; + } + + super.identifyUser(fsUserId, vuid); + } + + /** + * @override + * - Sends an event to the ODP Server via the ODP Events API + * - Intercepts identifiers and injects VUID before sending event + * @param {OdpEvent} odpEvent > ODP Event to send to event manager + */ + public sendEvent({ type, action, identifiers, data }: OdpEvent): void { + const identifiersWithVuid = new Map(identifiers); + + if (!identifiers.has(ODP_USER_KEY.VUID)) { + if (this.vuid) { + identifiersWithVuid.set(ODP_USER_KEY.VUID, this.vuid); + } else { + throw new Error(ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_VUID_MISSING); + } + } + + super.sendEvent({ type, action, identifiers: identifiersWithVuid, data }); + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp_manager/index.node.ts b/packages/optimizely-sdk/lib/plugins/odp_manager/index.node.ts new file mode 100644 index 000000000..186f6925d --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp_manager/index.node.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NodeRequestHandler } from '../../utils/http_request_handler/node_request_handler'; + +import { LRUCache } from '../../utils/lru_cache'; +import { ServerLRUCache } from './../../utils/lru_cache/server_lru_cache'; + +import { OdpManager } from '../../core/odp/odp_manager'; +import { OdpSegmentManager } from '../../core/odp/odp_segment_manager'; +import { OdpEventManager } from '../../core/odp/odp_event_manager'; +import { getLogger, LogHandler } from '../../modules/logging'; +import { ERROR_MESSAGES, LOG_LEVEL, NODE_CLIENT_ENGINE, NODE_CLIENT_VERSION } from '../../utils/enums'; + +interface NodeOdpManagerConfig { + disable: boolean; + logger?: LogHandler; + segmentsCache?: LRUCache; + segmentManager?: OdpSegmentManager; + eventManager?: OdpEventManager; +} + +/** + * Server-side Node Plugin for ODP Manager. + * Note: As this is still a work-in-progress. Please avoid using the Node ODP Manager. + */ +export class NodeOdpManager extends OdpManager { + constructor({ disable, logger, segmentsCache, segmentManager, eventManager }: NodeOdpManagerConfig) { + const nodeLogger = logger || getLogger(); + + const nodeRequestHandler = new NodeRequestHandler(nodeLogger); + const nodeClientEngine = NODE_CLIENT_ENGINE; + const nodeClientVersion = NODE_CLIENT_VERSION; + + super({ + disable, + requestHandler: nodeRequestHandler, + logger: nodeLogger, + clientEngine: nodeClientEngine, + clientVersion: nodeClientVersion, + segmentsCache: segmentsCache || new ServerLRUCache(), + segmentManager, + eventManager, + }); + } +} diff --git a/packages/optimizely-sdk/lib/plugins/vuid_manager/index.ts b/packages/optimizely-sdk/lib/plugins/vuid_manager/index.ts index 81b5cf4bd..4518006c2 100644 --- a/packages/optimizely-sdk/lib/plugins/vuid_manager/index.ts +++ b/packages/optimizely-sdk/lib/plugins/vuid_manager/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,17 +26,18 @@ export interface IVuidManager { */ export class VuidManager implements IVuidManager { /** - * Unique key used within the persistent value cache against which to - * store the VUID - * @private + * Prefix used as part of the VUID format + * @public + * @readonly */ - private _keyForVuid = 'optimizely-vuid'; + static readonly vuid_prefix: string = `vuid_`; /** - * Prefix used as part of the VUID format + * Unique key used within the persistent value cache against which to + * store the VUID * @private */ - private readonly _prefix: string = `vuid_`; + private _keyForVuid = 'optimizely-vuid'; /** * Current VUID value being used @@ -85,7 +86,7 @@ export class VuidManager implements IVuidManager { */ private async load(cache: PersistentKeyValueCache): Promise { const cachedValue = await cache.get(this._keyForVuid); - if (cachedValue && this.isVuid(cachedValue)) { + if (cachedValue && VuidManager.isVuid(cachedValue)) { this._vuid = cachedValue; } else { this._vuid = this.makeVuid(); @@ -100,14 +101,14 @@ export class VuidManager implements IVuidManager { * @returns A new visitor unique identifier */ private makeVuid(): string { - const maxLength = 32; // required by ODP server + const maxLength = 32; // required by ODP server // make sure UUIDv4 is used (not UUIDv1 or UUIDv6) since the trailing 5 chars will be truncated. See TDD for details. const uuidV4 = uuid(); const formatted = uuidV4.replace(/-/g, '').toLowerCase(); - const vuidFull = `${(this._prefix)}${formatted}`; + const vuidFull = `${VuidManager.vuid_prefix}${formatted}`; - return (vuidFull.length <= maxLength) ? vuidFull : vuidFull.substring(0, maxLength); + return vuidFull.length <= maxLength ? vuidFull : vuidFull.substring(0, maxLength); } /** @@ -124,7 +125,7 @@ export class VuidManager implements IVuidManager { * @param vuid VistorId to check * @returns *true* if the VisitorId is valid otherwise *false* for invalid */ - private isVuid = (vuid: string): boolean => vuid.startsWith(this._prefix); + static isVuid = (vuid: string): boolean => vuid.startsWith(VuidManager.vuid_prefix); /** * Function used in unit testing to reset the VuidManager diff --git a/packages/optimizely-sdk/lib/shared_types.ts b/packages/optimizely-sdk/lib/shared_types.ts index f3ff5251b..29122436d 100644 --- a/packages/optimizely-sdk/lib/shared_types.ts +++ b/packages/optimizely-sdk/lib/shared_types.ts @@ -1,5 +1,5 @@ /** - * Copyright 2020-2022, Optimizely + * Copyright 2020-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,8 +15,9 @@ */ import { ErrorHandler, LogHandler, LogLevel, LoggerFacade } from '../lib/modules/logging'; import { EventProcessor } from '../lib/modules/event_processor'; +import { OdpManager } from './core/odp/odp_manager'; -import { NotificationCenter as NotificationCenterImpl } from './core/notification_center' +import { NotificationCenter as NotificationCenterImpl } from './core/notification_center'; import { NOTIFICATION_TYPES } from './utils/enums'; export interface BucketerParams { @@ -41,11 +42,10 @@ export type UserAttributes = { // TODO[OASIS-6649]: Don't use any type // eslint-disable-next-line @typescript-eslint/no-explicit-any [name: string]: any; -} +}; export interface ExperimentBucketMap { - [experiment_id: string]: - { variation_id: string } + [experiment_id: string]: { variation_id: string }; } // Information about past bucketing decisions for a user. @@ -64,7 +64,7 @@ export interface UserProfileService { } export interface DatafileManagerConfig { - sdkKey: string, + sdkKey: string; datafile?: string; } @@ -114,7 +114,7 @@ export interface EventDispatcher { * After the event has at least been queued for dispatch, call this function to return * control back to the Client. */ - dispatchEvent: (event: Event, callback: (response: { statusCode: number; }) => void) => void; + dispatchEvent: (event: Event, callback: (response: { statusCode: number }) => void) => void; } export interface VariationVariable { @@ -164,9 +164,9 @@ export interface FeatureFlag { rolloutId: string; key: string; id: string; - experimentIds: string[], - variables: FeatureVariable[], - variableKeyMap: { [key: string]: FeatureVariable } + experimentIds: string[]; + variables: FeatureVariable[]; + variableKeyMap: { [key: string]: FeatureVariable }; groupId?: string; } @@ -175,7 +175,7 @@ export type Condition = { type: string; match?: string; value: string | number | boolean | null; -} +}; export interface Audience { id: string; @@ -214,7 +214,7 @@ export interface Group { } export interface FeatureKeyMap { - [key: string]: FeatureFlag + [key: string]: FeatureFlag; } export interface OnReadyResult { @@ -224,7 +224,7 @@ export interface OnReadyResult { export type ObjectWithUnknownProperties = { [key: string]: unknown; -} +}; export interface Rollout { id: string; @@ -237,7 +237,7 @@ export enum OptimizelyDecideOption { ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY', IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE', INCLUDE_REASONS = 'INCLUDE_REASONS', - EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' + EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES', } /** @@ -255,12 +255,13 @@ export interface OptimizelyOptions { eventProcessor: EventProcessor; isValidInstance: boolean; jsonSchemaValidator?: { - validate(jsonObject: unknown): boolean, + validate(jsonObject: unknown): boolean; }; logger: LoggerFacade; sdkKey?: string; userProfileService?: UserProfileService | null; defaultDecideOptions?: OptimizelyDecideOption[]; + odpManager?: OdpManager; notificationCenter: NotificationCenterImpl; } @@ -285,43 +286,15 @@ export interface OptimizelyVariable { export interface Client { notificationCenter: NotificationCenter; - createUserContext( - userId: string, - attributes?: UserAttributes - ): OptimizelyUserContext | null; - activate( - experimentKey: string, - userId: string, - attributes?: UserAttributes - ): string | null; - track( - eventKey: string, - userId: string, - attributes?: UserAttributes, - eventTags?: EventTags - ): void; - getVariation( - experimentKey: string, - userId: string, - attributes?: UserAttributes - ): string | null; + createUserContext(userId: string, attributes?: UserAttributes): OptimizelyUserContext | null; + activate(experimentKey: string, userId: string, attributes?: UserAttributes): string | null; + track(eventKey: string, userId: string, attributes?: UserAttributes, eventTags?: EventTags): void; + getVariation(experimentKey: string, userId: string, attributes?: UserAttributes): string | null; setForcedVariation(experimentKey: string, userId: string, variationKey: string | null): boolean; getForcedVariation(experimentKey: string, userId: string): string | null; - isFeatureEnabled( - featureKey: string, - userId: string, - attributes?: UserAttributes - ): boolean; - getEnabledFeatures( - userId: string, - attributes?: UserAttributes - ): string[]; - getFeatureVariable( - featureKey: string, - variableKey: string, - userId: string, - attributes?: UserAttributes - ): unknown; + isFeatureEnabled(featureKey: string, userId: string, attributes?: UserAttributes): boolean; + getEnabledFeatures(userId: string, attributes?: UserAttributes): string[]; + getFeatureVariable(featureKey: string, variableKey: string, userId: string, attributes?: UserAttributes): unknown; getFeatureVariableBoolean( featureKey: string, variableKey: string, @@ -346,12 +319,7 @@ export interface Client { userId: string, attributes?: UserAttributes ): string | null; - getFeatureVariableJSON( - featureKey: string, - variableKey: string, - userId: string, - attributes?: UserAttributes - ): unknown; + getFeatureVariableJSON(featureKey: string, variableKey: string, userId: string, attributes?: UserAttributes): unknown; getAllFeatureVariables( featureKey: string, userId: string, @@ -379,16 +347,12 @@ export interface TrackListenerPayload extends ListenerPayload { * For compatibility with the previous declaration file */ export interface Config extends ConfigLite { - // options for Datafile Manager - datafileOptions?: DatafileOptions; - // limit of events to dispatch in a batch - eventBatchSize?: number; - // maximum time for an event to stay in the queue - eventFlushInterval?: number; - // maximum size for the event queue - eventMaxQueueSize?: number; - // sdk key + datafileOptions?: DatafileOptions; // Options for Datafile Manager + eventBatchSize?: number; // Maximum size of events to be dispatched in a batch + eventFlushInterval?: number; // Maximum time for an event to be enqueued + eventMaxQueueSize?: number; // Maximum size for the event queue sdkKey?: string; + odpManager?: OdpManager; } /** @@ -406,7 +370,7 @@ export interface ConfigLite { eventDispatcher?: EventDispatcher; // The object to validate against the schema jsonSchemaValidator?: { - validate(jsonObject: unknown): boolean, + validate(jsonObject: unknown): boolean; }; // level of logging i.e debug, info, error, warning etc logLevel?: LogLevel | string; @@ -422,15 +386,15 @@ export interface ConfigLite { export type OptimizelyExperimentsMap = { [experimentKey: string]: OptimizelyExperiment; -} +}; export type OptimizelyVariablesMap = { [variableKey: string]: OptimizelyVariable; -} +}; export type OptimizelyFeaturesMap = { [featureKey: string]: OptimizelyFeature; -} +}; export type OptimizelyAttribute = { id: string; @@ -493,17 +457,9 @@ export interface OptimizelyUserContext { getUserId(): string; getAttributes(): UserAttributes; setAttribute(key: string, value: unknown): void; - decide( - key: string, - options?: OptimizelyDecideOption[] - ): OptimizelyDecision; - decideForKeys( - keys: string[], - options?: OptimizelyDecideOption[], - ): { [key: string]: OptimizelyDecision }; - decideAll( - options?: OptimizelyDecideOption[], - ): { [key: string]: OptimizelyDecision }; + decide(key: string, options?: OptimizelyDecideOption[]): OptimizelyDecision; + decideForKeys(keys: string[], options?: OptimizelyDecideOption[]): { [key: string]: OptimizelyDecision }; + decideAll(options?: OptimizelyDecideOption[]): { [key: string]: OptimizelyDecision }; trackEvent(eventName: string, eventTags?: EventTags): void; setForcedDecision(context: OptimizelyDecisionContext, decision: OptimizelyForcedDecision): boolean; getForcedDecision(context: OptimizelyDecisionContext): OptimizelyForcedDecision | null; diff --git a/packages/optimizely-sdk/lib/utils/enums/index.ts b/packages/optimizely-sdk/lib/utils/enums/index.ts index d8931acde..3911aac58 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.ts +++ b/packages/optimizely-sdk/lib/utils/enums/index.ts @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2022, Optimizely, Inc. and contributors * + * Copyright 2016-2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -31,6 +31,8 @@ export const ERROR_MESSAGES = { EXPERIMENT_KEY_NOT_IN_DATAFILE: '%s: Experiment key %s is not in datafile.', FEATURE_NOT_IN_DATAFILE: '%s: Feature key %s is not in datafile.', FETCH_SEGMENTS_FAILED_INVALID_IDENTIFIER: '%s: Audience segments fetch failed. (invalid identifier)', + FETCH_SEGMENTS_FAILED_NETWORK_ERROR: '%s: Audience segments fetch failed. (network error)', + FETCH_SEGMENTS_FAILED_DECODE_ERROR: '%s: Audience segments fetch failed. (decode error)', IMPROPERLY_FORMATTED_EXPERIMENT: '%s: Experiment key %s is improperly formatted.', INVALID_ATTRIBUTES: '%s: Provided attributes are in an invalid format.', INVALID_BUCKETING_ID: '%s: Unable to generate hash for bucketing ID %s: %s', @@ -51,6 +53,28 @@ export const ERROR_MESSAGES = { NO_DATAFILE_SPECIFIED: '%s: No datafile specified. Cannot start optimizely.', NO_JSON_PROVIDED: '%s: No JSON object to validate against schema.', NO_VARIATION_FOR_EXPERIMENT_KEY: '%s: No variation key %s defined in datafile for experiment %s.', + ODP_EVENT_FAILED: '%s: ODP event send failed (invalid url)', + ODP_FETCH_QUALIFIED_SEGMENTS_SEGMENTS_MANAGER_MISSING: + '%s: ODP unable to fetch qualified segments (Segments Manager not initialized).', + ODP_IDENTIFY_FAILED_EVENT_MANAGER_MISSING: + '%s: ODP identify event %s is not dispatched (Event Manager not instantiated).', + ODP_INITIALIZATION_FAILED: '%s: ODP failed to initialize.', + ODP_INVALID_DATA: '%s: ODP data is not valid', + ODP_MANAGER_UPDATE_SETTINGS_FAILED_EVENT_MANAGER_MISSING: + '%s: ODP Manager failed to update OdpConfig settings for internal event manager. (Event Manager not initialized).', + ODP_MANAGER_UPDATE_SETTINGS_FAILED_SEGMENTS_MANAGER_MISSING: + '%s: ODP Manager failed to update OdpConfig settings for internal segments manager. (Segments Manager not initialized).', + ODP_NOT_ENABLED: '%s: ODP is not enabled', + ODP_NOT_INTEGRATED: '%s: ODP is not integrated', + ODP_SEND_EVENT_FAILED_EVENT_MANAGER_MISSING: + '%s: ODP send event %s was not dispatched (Event Manager not instantiated).', + ODP_SEND_EVENT_FAILED_UID_MISSING: '%s: ODP send event %s was not dispatched (No valid user identifier provided).', + ODP_SEND_EVENT_FAILED_VUID_MISSING: '%s: ODP send event %s was not dispatched (Unable to fetch VUID).', + ODP_SDK_KEY_MISSING_NOTIFICATION_CENTER_FAILURE: + '%s: You must provide an sdkKey. Cannot start Notification Center for ODP Integration.', + ODP_VUID_INITIALIZATION_FAILED: '%s: ODP VUID initialization failed.', + ODP_VUID_REGISTRATION_FAILED: '%s: ODP VUID failed to be registered.', + ODP_VUID_REGISTRATION_FAILED_EVENT_MANAGER_MISSING: '%s: ODP register vuid failed. (Event Manager not instantiated).', UNDEFINED_ATTRIBUTE: '%s: Provided attribute: %s has an undefined value.', UNRECOGNIZED_ATTRIBUTE: '%s: Unrecognized attribute %s provided. Pruning before sending event to Optimizely.', UNABLE_TO_CAST_VALUE: '%s: Unable to cast value %s to type %s, returning null.', @@ -87,6 +111,8 @@ export const LOG_MESSAGES = { NO_ROLLOUT_EXISTS: '%s: There is no rollout of feature %s.', NOT_ACTIVATING_USER: '%s: Not activating user %s for experiment %s.', NOT_TRACKING_USER: '%s: Not tracking user %s.', + ODP_IDENTIFY_FAILED_ODP_DISABLED: '%s: ODP identify event for user %s is not dispatched (ODP disabled).', + ODP_IDENTIFY_FAILED_ODP_NOT_INTEGRATED: '%s: ODP identify event %s is not dispatched (ODP not integrated).', PARSED_REVENUE_VALUE: '%s: Parsed revenue value "%s" from event tags.', PARSED_NUMERIC_VALUE: '%s: Parsed event value "%s" from event tags.', RETURNING_STORED_VARIATION: @@ -185,6 +211,7 @@ export const NODE_CLIENT_ENGINE = 'node-sdk'; export const REACT_CLIENT_ENGINE = 'react-sdk'; export const REACT_NATIVE_CLIENT_ENGINE = 'react-native-sdk'; export const REACT_NATIVE_JS_CLIENT_ENGINE = 'react-native-js-sdk'; +export const BROWSER_CLIENT_VERSION = '4.9.2'; export const NODE_CLIENT_VERSION = '4.9.2'; export const DECISION_NOTIFICATION_TYPES = { @@ -303,9 +330,19 @@ export enum NOTIFICATION_TYPES { export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute /** - * ODP User Key + * ODP User Key Options */ export enum ODP_USER_KEY { VUID = 'vuid', FS_USER_ID = 'fs_user_id', } + +export const ODP_EVENT_TYPE = 'fullstack'; + +/** + * ODP Event Action Options + */ +export enum ODP_EVENT_ACTION { + IDENTIFIED = 'identified', + INITIALIZED = 'client_initialized', +} diff --git a/packages/optimizely-sdk/lib/utils/fns/index.ts b/packages/optimizely-sdk/lib/utils/fns/index.ts index c769f147b..6852e030d 100644 --- a/packages/optimizely-sdk/lib/utils/fns/index.ts +++ b/packages/optimizely-sdk/lib/utils/fns/index.ts @@ -153,8 +153,21 @@ export function sprintf(format: string, ...args: any[]): string { }) } + + +/** + * Checks two string arrays for equality. + * @param arrayA First Array to be compared against. + * @param arrayB Second Array to be compared against. + * @returns {boolean} True if both arrays are equal, otherwise returns false. + */ +export function checkArrayEquality(arrayA: string[], arrayB: string[]): boolean { + return arrayA.length === arrayB.length && arrayA.every((item, index) => item === arrayB[index]); +} + export default { assign, + checkArrayEquality, currentTimestamp, isSafeInteger, keyBy, diff --git a/packages/optimizely-sdk/lib/utils/lru_cache/browser_lru_cache.ts b/packages/optimizely-sdk/lib/utils/lru_cache/browser_lru_cache.ts index 23daedf01..f66ad86ba 100644 --- a/packages/optimizely-sdk/lib/utils/lru_cache/browser_lru_cache.ts +++ b/packages/optimizely-sdk/lib/utils/lru_cache/browser_lru_cache.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,18 @@ * limitations under the License. */ -import LRUCache from './lru_cache'; +import LRUCache, { ISegmentsCacheConfig } from './lru_cache'; + +export const BrowserLRUCacheConfig: ISegmentsCacheConfig = { + DEFAULT_CAPACITY: 100, + DEFAULT_TIMEOUT_SECS: 600, +}; export class BrowserLRUCache extends LRUCache { constructor() { super({ - maxSize: 100, - timeout: 600 * 1000, // 600 secs + maxSize: BrowserLRUCacheConfig.DEFAULT_CAPACITY, + timeout: BrowserLRUCacheConfig.DEFAULT_TIMEOUT_SECS * 1000, }); } } diff --git a/packages/optimizely-sdk/lib/utils/lru_cache/lru_cache.ts b/packages/optimizely-sdk/lib/utils/lru_cache/lru_cache.ts index b0b2a60f5..dc4d4c94f 100644 --- a/packages/optimizely-sdk/lib/utils/lru_cache/lru_cache.ts +++ b/packages/optimizely-sdk/lib/utils/lru_cache/lru_cache.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -105,4 +105,9 @@ export class LRUCache { } } +export interface ISegmentsCacheConfig { + DEFAULT_CAPACITY: number; + DEFAULT_TIMEOUT_SECS: number; +} + export default LRUCache; diff --git a/packages/optimizely-sdk/lib/utils/lru_cache/server_lru_cache.ts b/packages/optimizely-sdk/lib/utils/lru_cache/server_lru_cache.ts index f39e15894..a933832f5 100644 --- a/packages/optimizely-sdk/lib/utils/lru_cache/server_lru_cache.ts +++ b/packages/optimizely-sdk/lib/utils/lru_cache/server_lru_cache.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,18 @@ * limitations under the License. */ -import LRUCache from './lru_cache'; +import LRUCache, { ISegmentsCacheConfig } from './lru_cache'; + +export const ServerLRUCacheConfig: ISegmentsCacheConfig = { + DEFAULT_CAPACITY: 10000, + DEFAULT_TIMEOUT_SECS: 600, +}; export class ServerLRUCache extends LRUCache { constructor() { super({ - maxSize: 10000, - timeout: 600 * 1000, // 600 secs + maxSize: ServerLRUCacheConfig.DEFAULT_CAPACITY, + timeout: ServerLRUCacheConfig.DEFAULT_TIMEOUT_SECS * 1000, }); } } diff --git a/packages/optimizely-sdk/tests/odpEventManager.spec.ts b/packages/optimizely-sdk/tests/odpEventManager.spec.ts index 13f70d1e9..ffa223b15 100644 --- a/packages/optimizely-sdk/tests/odpEventManager.spec.ts +++ b/packages/optimizely-sdk/tests/odpEventManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ * limitations under the License. */ +import { ODP_EVENT_ACTION, ODP_EVENT_TYPE } from './../lib/utils/enums/index'; + import { OdpConfig } from '../lib/core/odp/odp_config'; import { OdpEventManager, STATE } from '../lib/core/odp/odp_event_manager'; import { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; @@ -422,8 +424,8 @@ describe('OdpEventManager', () => { expect(method).toEqual('POST'); const events = JSON.parse(data as string); const event = events[0]; - expect(event.type).toEqual('fullstack'); - expect(event.action).toEqual('identified'); + expect(event.type).toEqual(ODP_EVENT_TYPE); + expect(event.action).toEqual(ODP_EVENT_ACTION.IDENTIFIED); expect(event.identifiers).toEqual({ vuid: vuid, fs_user_id: fsUserId }); expect(event.data.idempotence_id.length).toBe(36); // uuid length expect(event.data.data_source_type).toEqual('sdk'); @@ -448,7 +450,7 @@ describe('OdpEventManager', () => { expect(eventManager['odpConfig'].apiKey).toEqual(apiKey); expect(eventManager['odpConfig'].apiHost).toEqual(apiHost); - expect(eventManager['odpConfig'].segmentsToCheck).toContain(segmentsToCheck[0]); - expect(eventManager['odpConfig'].segmentsToCheck).toContain(segmentsToCheck[1]); + expect(eventManager['odpConfig'].segmentsToCheck).toContain(Array.from(segmentsToCheck)[0]); + expect(eventManager['odpConfig'].segmentsToCheck).toContain(Array.from(segmentsToCheck)[1]); }); }); diff --git a/packages/optimizely-sdk/tests/odpManager.browser.spec.ts b/packages/optimizely-sdk/tests/odpManager.browser.spec.ts new file mode 100644 index 000000000..536d91085 --- /dev/null +++ b/packages/optimizely-sdk/tests/odpManager.browser.spec.ts @@ -0,0 +1,219 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anything, capture, instance, mock, resetCalls, verify } from 'ts-mockito'; + +import { LOG_MESSAGES } from './../lib/utils/enums/index'; +import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; + +import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { RequestHandler } from '../lib/utils/http_request_handler/http'; +import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; + +import { BrowserOdpManager } from './../lib/plugins/odp_manager/index.browser'; +import { OdpConfig } from '../lib/core/odp/odp_config'; +import { OdpEventApiManager } from '../lib/core/odp/odp_event_api_manager'; +import { OdpEventManager, STATE } from '../lib/core/odp/odp_event_manager'; +import { OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; +import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; +import { VuidManager } from '../lib/plugins/vuid_manager'; + +const keyA = 'key-a'; +const hostA = 'host-a'; +const segmentsA = ['a']; +const userA = 'fs-user-a'; +const vuidA = 'vuid_a'; +const odpConfigA = new OdpConfig(keyA, hostA, segmentsA); + +const keyB = 'key-b'; +const hostB = 'host-b'; +const segmentsB = ['b']; +const userB = 'fs-user-b'; +const vuidB = 'vuid_b'; +const odpConfigB = new OdpConfig(keyB, hostB, segmentsB); + +describe('OdpManager', () => { + let mockLogger: LogHandler; + let mockRequestHandler: RequestHandler; + + let odpConfig: OdpConfig; + let logger: LogHandler; + let requestHandler: RequestHandler; + + let mockEventApiManager: OdpEventApiManager; + let mockEventManager: OdpEventManager; + let mockSegmentApiManager: OdpSegmentApiManager; + let mockSegmentManager: OdpSegmentManager; + + let eventApiManager: OdpEventApiManager; + let eventManager: OdpEventManager; + let segmentApiManager: OdpSegmentApiManager; + let segmentManager: OdpSegmentManager; + + beforeAll(() => { + mockLogger = mock(); + mockRequestHandler = mock(); + + odpConfig = new OdpConfig(); + logger = instance(mockLogger); + requestHandler = instance(mockRequestHandler); + + mockEventApiManager = mock(); + mockEventManager = mock(); + mockSegmentApiManager = mock(); + mockSegmentManager = mock(); + + eventApiManager = instance(mockEventApiManager); + eventManager = instance(mockEventManager); + segmentApiManager = instance(mockSegmentApiManager); + segmentManager = instance(mockSegmentManager); + }); + + beforeEach(() => { + resetCalls(mockLogger); + resetCalls(mockRequestHandler); + resetCalls(mockEventApiManager); + resetCalls(mockEventManager); + resetCalls(mockSegmentManager); + }); + + const browserOdpManagerInstance = () => + new BrowserOdpManager({ + disable: false, + eventManager, + segmentManager, + }); + + it('should register VUID automatically on BrowserOdpManager initialization', async () => { + const browserOdpManager = browserOdpManagerInstance(); + const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); + expect(browserOdpManager.vuid).toBe(vuidManager.vuid); + }); + + it('should drop relevant calls when OdpManager is initialized with the disabled flag, except for VUID', async () => { + const browserOdpManager = new BrowserOdpManager({ disable: true, logger }); + + verify(mockLogger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); + + browserOdpManager.updateSettings(new OdpConfig('valid', 'host', [])); + expect(browserOdpManager.odpConfig).toBeUndefined; + + await browserOdpManager.fetchQualifiedSegments('vuid_user1', []); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); + + browserOdpManager.identifyUser('vuid_user1'); + verify(mockLogger.log(LogLevel.DEBUG, LOG_MESSAGES.ODP_IDENTIFY_FAILED_ODP_DISABLED)).once(); + + expect(browserOdpManager.eventManager).toBeUndefined; + expect(browserOdpManager.segmentManager).toBeUndefined; + + const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); + expect(vuidManager.vuid.slice(0, 5)).toBe('vuid_'); + }); + + it('should start ODP Event Manager when ODP Manager is initialized', () => { + const browserOdpManager = browserOdpManagerInstance(); + verify(mockEventManager.start()).once(); + expect(browserOdpManager.eventManager).not.toBeUndefined(); + }); + + it('should stop ODP Event Manager when close is called', () => { + const browserOdpManager = browserOdpManagerInstance(); + verify(mockEventManager.stop()).never(); + + browserOdpManager.close(); + verify(mockEventManager.stop()).once(); + }); + + it('should use new settings in event manager when ODP Config is updated', async () => { + const browserOdpManager = new BrowserOdpManager({ + disable: false, + eventManager, + }); + + expect(browserOdpManager.eventManager).toBeDefined(); + verify(mockEventManager.updateSettings(anything())).once(); + verify(mockEventManager.start()).once(); + + await new Promise(resolve => setTimeout(resolve, 200)); // Wait for VuidManager to fetch from cache. + + verify(mockEventManager.registerVuid(anything())).once(); + + const didUpdateA = browserOdpManager.updateSettings(odpConfigA); + expect(didUpdateA).toBe(true); + expect(browserOdpManager.odpConfig.equals(odpConfigA)).toBe(true); + + const updateSettingsArgsA = capture(mockEventManager.updateSettings).last(); + expect(updateSettingsArgsA[0]).toStrictEqual(odpConfigA); + + browserOdpManager.identifyUser(userA); + const identifyUserArgsA = capture(mockEventManager.identifyUser).last(); + expect(identifyUserArgsA[0]).toStrictEqual(userA); + + const didUpdateB = browserOdpManager.updateSettings(odpConfigB); + expect(didUpdateB).toBe(true); + expect(browserOdpManager.odpConfig.equals(odpConfigB)).toBe(true); + + const updateSettingsArgsB = capture(mockEventManager.updateSettings).last(); + expect(updateSettingsArgsB[0]).toStrictEqual(odpConfigB); + + browserOdpManager.eventManager!.identifyUser(userB); + const identifyUserArgsB = capture(mockEventManager.identifyUser).last(); + expect(identifyUserArgsB[0]).toStrictEqual(userB); + }); + + it('should use new settings in segment manager when ODP Config is updated', () => { + const browserOdpManager = new BrowserOdpManager({ + disable: false, + segmentManager: new OdpSegmentManager(odpConfig, new BrowserLRUCache(), segmentApiManager), + }); + + const didUpdateA = browserOdpManager.updateSettings(new OdpConfig(keyA, hostA, segmentsA)); + expect(didUpdateA).toBe(true); + + browserOdpManager.fetchQualifiedSegments(vuidA); + const fetchQualifiedSegmentsArgsA = capture(mockSegmentApiManager.fetchSegments).last(); + expect(fetchQualifiedSegmentsArgsA).toStrictEqual([keyA, hostA, ODP_USER_KEY.VUID, vuidA, segmentsA]); + + const didUpdateB = browserOdpManager.updateSettings(new OdpConfig(keyB, hostB, segmentsB)); + expect(didUpdateB).toBe(true); + + browserOdpManager.fetchQualifiedSegments(vuidB); + + const fetchQualifiedSegmentsArgsB = capture(mockSegmentApiManager.fetchSegments).last(); + expect(fetchQualifiedSegmentsArgsB).toStrictEqual([keyB, hostB, ODP_USER_KEY.VUID, vuidB, segmentsB]); + }); + + it('should get event manager', () => { + const browserOdpManagerA = browserOdpManagerInstance(); + expect(browserOdpManagerA.eventManager).not.toBe(null); + + const browserOdpManagerB = new BrowserOdpManager({ + disable: false, + }); + expect(browserOdpManagerB.eventManager).not.toBe(null); + }); + + it('should get segment manager', () => { + const browserOdpManagerA = browserOdpManagerInstance(); + expect(browserOdpManagerA.segmentManager).not.toBe(null); + + const browserOdpManagerB = new BrowserOdpManager({ + disable: false, + }); + expect(browserOdpManagerB.eventManager).not.toBe(null); + }); +}); diff --git a/packages/optimizely-sdk/tests/odpManager.spec.ts b/packages/optimizely-sdk/tests/odpManager.spec.ts new file mode 100644 index 000000000..29b7e01e7 --- /dev/null +++ b/packages/optimizely-sdk/tests/odpManager.spec.ts @@ -0,0 +1,207 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// + +import { anything, instance, mock, resetCalls, verify } from 'ts-mockito'; + +import { LOG_MESSAGES } from './../lib/utils/enums/index'; +import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; + +import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { RequestHandler } from '../lib/utils/http_request_handler/http'; +import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; + +import { OdpManager } from './../lib/core/odp/odp_manager'; +import { OdpConfig } from '../lib/core/odp/odp_config'; +import { OdpEventApiManager } from '../lib/core/odp/odp_event_api_manager'; +import { OdpEventManager } from '../lib/core/odp/odp_event_manager'; +import { OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; +import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; + +const keyA = 'key-a'; +const hostA = 'host-a'; +const segmentsA = ['a']; +const userA = 'fs-user-a'; + +const keyB = 'key-b'; +const hostB = 'host-b'; +const segmentsB = ['b']; +const userB = 'fs-user-b'; + +describe('OdpManager', () => { + let mockLogger: LogHandler; + let mockRequestHandler: RequestHandler; + + let odpConfig: OdpConfig; + let logger: LogHandler; + let requestHandler: RequestHandler; + + let mockEventApiManager: OdpEventApiManager; + let mockEventManager: OdpEventManager; + let mockSegmentApiManager: OdpSegmentApiManager; + let mockSegmentManager: OdpSegmentManager; + + let eventApiManager: OdpEventApiManager; + let eventManager: OdpEventManager; + let segmentApiManager: OdpSegmentApiManager; + let segmentManager: OdpSegmentManager; + + beforeAll(() => { + mockLogger = mock(); + mockRequestHandler = mock(); + + odpConfig = new OdpConfig(); + logger = instance(mockLogger); + requestHandler = instance(mockRequestHandler); + + mockEventApiManager = mock(); + mockEventManager = mock(); + mockSegmentApiManager = mock(); + mockSegmentManager = mock(); + + eventApiManager = instance(mockEventApiManager); + eventManager = instance(mockEventManager); + segmentApiManager = instance(mockSegmentApiManager); + segmentManager = instance(mockSegmentManager); + }); + + beforeEach(() => { + resetCalls(mockLogger); + resetCalls(mockRequestHandler); + resetCalls(mockEventApiManager); + resetCalls(mockEventManager); + resetCalls(mockSegmentManager); + }); + + const odpManagerInstance = (config?: OdpConfig) => + new OdpManager({ + disable: false, + requestHandler, + eventManager, + segmentManager, + }); + + it('should drop relevant calls when OdpManager is initialized with the disabled flag', async () => { + const odpManager = new OdpManager({ disable: true, requestHandler, logger }); + verify(mockLogger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); + + odpManager.updateSettings(new OdpConfig('valid', 'host', [])); + expect(odpManager.odpConfig).toBeUndefined; + + await odpManager.fetchQualifiedSegments('user1', []); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); + + odpManager.identifyUser('user1'); + verify(mockLogger.log(LogLevel.DEBUG, LOG_MESSAGES.ODP_IDENTIFY_FAILED_ODP_DISABLED)).once(); + + expect(odpManager.eventManager).toBeUndefined; + expect(odpManager.segmentManager).toBeUndefined; + }); + + it('should start ODP Event Manager when ODP Manager is initialized', () => { + const odpManager = odpManagerInstance(); + verify(mockEventManager.start()).once(); + expect(odpManager.eventManager).not.toBeUndefined(); + }); + + it('should stop ODP Event Manager when close is called', () => { + const odpManager = odpManagerInstance(); + verify(mockEventManager.stop()).never(); + + odpManager.close(); + verify(mockEventManager.stop()).once(); + }); + + it('should use new settings in event manager when ODP Config is updated', async () => { + const odpManager = new OdpManager({ + disable: false, + requestHandler, + eventManager: new OdpEventManager({ + odpConfig, + apiManager: eventApiManager, + logger, + clientEngine: '', + clientVersion: '', + batchSize: 1, + flushInterval: 250, + }), + }); + + odpManager.updateSettings(new OdpConfig(keyA, hostA, segmentsA)); + + expect(odpManager.odpConfig.apiKey).toBe(keyA); + expect(odpManager.odpConfig.apiHost).toBe(hostA); + + // odpManager.identifyUser(userA); + + // verify(mockEventApiManager.sendEvents(keyA, hostA, anything())).once(); + + odpManager.updateSettings(new OdpConfig(keyB, hostB, segmentsB)); + expect(odpManager.odpConfig.apiKey).toBe(keyB); + expect(odpManager.odpConfig.apiHost).toBe(hostB); + + // odpManager.identifyUser(userB); + + // verify(mockEventApiManager.sendEvents(keyB, hostB, anything())).once(); + }); + + it('should use new settings in segment manager when ODP Config is updated', async () => { + const odpManager = new OdpManager({ + disable: false, + requestHandler, + segmentManager: new OdpSegmentManager(odpConfig, new BrowserLRUCache(), segmentApiManager), + }); + + odpManager.updateSettings(new OdpConfig(keyA, hostA, segmentsA)); + + expect(odpManager.odpConfig.apiKey).toBe(keyA); + expect(odpManager.odpConfig.apiHost).toBe(hostA); + odpManager.fetchQualifiedSegments(userA); + + await new Promise(resolve => setTimeout(resolve, 400)); + verify(mockSegmentApiManager.fetchSegments(keyA, hostA, ODP_USER_KEY.FS_USER_ID, userA, anything())).once(); + + odpManager.updateSettings(new OdpConfig(keyB, hostB, segmentsB)); + expect(odpManager.odpConfig.apiKey).toBe(keyB); + expect(odpManager.odpConfig.apiHost).toBe(hostB); + odpManager.fetchQualifiedSegments(userB); + + await new Promise(resolve => setTimeout(resolve, 400)); + verify(mockSegmentApiManager.fetchSegments(keyB, hostB, ODP_USER_KEY.FS_USER_ID, userB, anything())).once(); + }); + + it('should get event manager', () => { + const odpManagerA = odpManagerInstance(); + expect(odpManagerA.eventManager).not.toBe(null); + + const odpManagerB = new OdpManager({ + disable: false, + requestHandler, + }); + expect(odpManagerB.eventManager).not.toBe(null); + }); + + it('should get segment manager', () => { + const odpManagerA = odpManagerInstance(); + expect(odpManagerA.segmentManager).not.toBe(null); + + const odpManagerB = new OdpManager({ + disable: false, + requestHandler, + }); + expect(odpManagerB.eventManager).not.toBe(null); + }); +}); diff --git a/packages/optimizely-sdk/tests/odpSegmentApiManager.ts b/packages/optimizely-sdk/tests/odpSegmentApiManager.spec.ts similarity index 98% rename from packages/optimizely-sdk/tests/odpSegmentApiManager.ts rename to packages/optimizely-sdk/tests/odpSegmentApiManager.spec.ts index 5056145a2..d59f6fb69 100644 --- a/packages/optimizely-sdk/tests/odpSegmentApiManager.ts +++ b/packages/optimizely-sdk/tests/odpSegmentApiManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -150,7 +150,7 @@ describe('OdpSegmentApiManager', () => { const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - expect(segments).toHaveLength(2); + expect(segments?.length).toEqual(2); expect(segments).toContain('has_email'); expect(segments).toContain('has_email_opted_in'); verify(mockLogger.log(anything(), anyString())).never(); @@ -161,7 +161,7 @@ describe('OdpSegmentApiManager', () => { const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, ODP_USER_KEY.FS_USER_ID, USER_VALUE, []); - expect(segments).toHaveLength(0); + expect(segments?.length).toEqual(0); verify(mockLogger.log(anything(), anyString())).never(); }); @@ -174,7 +174,7 @@ describe('OdpSegmentApiManager', () => { const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - expect(segments).toHaveLength(0); + expect(segments?.length).toEqual(0); verify(mockLogger.log(anything(), anyString())).never(); }); diff --git a/packages/optimizely-sdk/tests/odpSegmentManager.spec.ts b/packages/optimizely-sdk/tests/odpSegmentManager.spec.ts index 91277c5ee..9fb465dca 100644 --- a/packages/optimizely-sdk/tests/odpSegmentManager.spec.ts +++ b/packages/optimizely-sdk/tests/odpSegmentManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022-2023, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,6 +54,9 @@ describe('OdpSegmentManager', () => { const userKey: ODP_USER_KEY = ODP_USER_KEY.VUID; const userValue = 'test-user'; + const validTestOdpConfig = new OdpConfig('valid-key', 'host', ['new-customer']); + const invalidTestOdpConfig = new OdpConfig('invalid-key', 'host', ['new-customer']); + beforeEach(() => { resetCalls(mockLogHandler); resetCalls(mockRequestHandler); @@ -61,7 +64,7 @@ describe('OdpSegmentManager', () => { const API_KEY = 'test-api-key'; const API_HOST = 'https://odp.example.com/'; odpConfig = new OdpConfig(API_KEY, API_HOST, []); - const segmentsCache = new LRUCache>({ + const segmentsCache = new LRUCache({ maxSize: 1000, timeout: 1000, }); @@ -70,7 +73,7 @@ describe('OdpSegmentManager', () => { }); it('should fetch segments successfully on cache miss.', async () => { - odpConfig.update('host', 'valid', ['new-customer']); + odpConfig.update(validTestOdpConfig); setCache(userKey, '123', ['a']); const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); @@ -78,7 +81,7 @@ describe('OdpSegmentManager', () => { }); it('should fetch segments successfully on cache hit.', async () => { - odpConfig.update('host', 'valid', ['new-customer']); + odpConfig.update(validTestOdpConfig); setCache(userKey, userValue, ['a']); const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); @@ -86,14 +89,14 @@ describe('OdpSegmentManager', () => { }); it('should throw an error when fetching segments returns an error.', async () => { - odpConfig.update('host', 'invalid-key', ['new-customer']); + odpConfig.update(invalidTestOdpConfig); const segments = await manager.fetchQualifiedSegments(userKey, userValue, []); expect(segments).toBeNull; }); it('should ignore the cache if the option is included in the options array.', async () => { - odpConfig.update('host', 'valid', ['new-customer']); + odpConfig.update(validTestOdpConfig); setCache(userKey, userValue, ['a']); options = [OptimizelySegmentOption.IGNORE_CACHE]; @@ -103,7 +106,7 @@ describe('OdpSegmentManager', () => { }); it('should reset the cache if the option is included in the options array.', async () => { - odpConfig.update('host', 'valid', ['new-customer']); + odpConfig.update(validTestOdpConfig); setCache(userKey, userValue, ['a']); setCache(userKey, '123', ['a']); setCache(userKey, '456', ['a']); @@ -121,7 +124,7 @@ describe('OdpSegmentManager', () => { // Utility Functions - function setCache(userKey: string, userValue: string, value: Array) { + function setCache(userKey: string, userValue: string, value: string[]) { const cacheKey = manager.makeCacheKey(userKey, userValue); manager.segmentsCache.save({ key: cacheKey, @@ -129,7 +132,7 @@ describe('OdpSegmentManager', () => { }); } - function peekCache(userKey: string, userValue: string): Array | null { + function peekCache(userKey: string, userValue: string): string[] | null { const cacheKey = manager.makeCacheKey(userKey, userValue); return manager.segmentsCache.peek(cacheKey); } diff --git a/packages/optimizely-sdk/tests/vuidManager.spec.ts b/packages/optimizely-sdk/tests/vuidManager.spec.ts index 9fe746f0a..12ee6cff9 100644 --- a/packages/optimizely-sdk/tests/vuidManager.spec.ts +++ b/packages/optimizely-sdk/tests/vuidManager.spec.ts @@ -31,11 +31,11 @@ describe('VuidManager', () => { when(mockCache.set(anyString(), anything())).thenResolve(); VuidManager.instance(instance(mockCache)); }); - - beforeEach(()=>{ + + beforeEach(() => { resetCalls(mockCache); VuidManager['_reset'](); - }) + }); it('should make a VUID', async () => { const manager = await VuidManager.instance(instance(mockCache)); @@ -50,9 +50,9 @@ describe('VuidManager', () => { it('should test if a VUID is valid', async () => { const manager = await VuidManager.instance(instance(mockCache)); - expect(manager['isVuid']('vuid_123')).toBe(true); - expect(manager['isVuid']('vuid-123')).toBe(false); - expect(manager['isVuid']('123')).toBe(false); + expect(VuidManager.isVuid('vuid_123')).toBe(true); + expect(VuidManager.isVuid('vuid-123')).toBe(false); + expect(VuidManager.isVuid('123')).toBe(false); }); it('should auto-save and auto-load', async () => { @@ -67,8 +67,8 @@ describe('VuidManager', () => { const vuid2 = manager2.vuid; expect(vuid1).toStrictEqual(vuid2); - expect(manager2['isVuid'](vuid1)).toBe(true); - expect(manager1['isVuid'](vuid2)).toBe(true); + expect(VuidManager.isVuid(vuid1)).toBe(true); + expect(VuidManager.isVuid(vuid2)).toBe(true); await cache.remove('optimizely-odp'); @@ -77,7 +77,7 @@ describe('VuidManager', () => { const vuid3 = manager2.vuid; expect(vuid3).not.toStrictEqual(vuid1); - expect(manager2['isVuid'](vuid3)).toBe(true); + expect(VuidManager.isVuid(vuid3)).toBe(true); }); it('should handle no valid optimizely-vuid in the cache', async () => { @@ -87,7 +87,7 @@ describe('VuidManager', () => { verify(mockCache.get(anyString())).once(); verify(mockCache.set(anyString(), anything())).once(); - expect(manager['isVuid'](manager.vuid)).toBe(true); + expect(VuidManager.isVuid(manager.vuid)).toBe(true); }); it('should create a new vuid if old VUID from cache is not valid', async () => { @@ -97,7 +97,6 @@ describe('VuidManager', () => { verify(mockCache.get(anyString())).once(); verify(mockCache.set(anyString(), anything())).once(); - expect(manager['isVuid'](manager.vuid)).toBe(true); + expect(VuidManager.isVuid(manager.vuid)).toBe(true); }); }); - diff --git a/packages/optimizely-sdk/tsconfig.json b/packages/optimizely-sdk/tsconfig.json index 51dfbda08..5e19876a5 100644 --- a/packages/optimizely-sdk/tsconfig.json +++ b/packages/optimizely-sdk/tsconfig.json @@ -14,6 +14,7 @@ "outDir": "./dist", "sourceMap": true, "skipLibCheck": true, + "useUnknownInCatchVariables": false }, "exclude": [ "./dist",