From 75a01486272acfdc396cc0f159258a8066b6af99 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Sat, 6 Apr 2024 03:44:07 +0600 Subject: [PATCH] [FSSDK-10090] Refactor ODP integration (#920) * refactor * refactor: build working * fix segment manager tests * fix identify user * update event api manager * update event man * add sement manager test * fix odp event manager tests * odp manager tests * fix odp event manager tests * odpManager tests * odp manager tests * browserOdpManager tests fixed * fix project config tests * fix tests * fix * config manager tests fix * odpManager test * event man update * copyright * remove console log * fix review * undo clear timeout * remove unnecessary line --- lib/core/odp/odp_config.ts | 112 +-- lib/core/odp/odp_event_api_manager.ts | 30 +- lib/core/odp/odp_event_manager.ts | 129 ++-- lib/core/odp/odp_manager.ts | 216 ++++-- lib/core/odp/odp_segment_manager.ts | 22 +- lib/core/project_config/index.tests.js | 51 +- lib/core/project_config/index.ts | 62 +- .../project_config_manager.tests.js | 175 +++-- .../project_config/project_config_manager.ts | 8 +- lib/index.browser.tests.js | 26 +- lib/index.browser.ts | 5 +- lib/index.node.ts | 7 +- lib/index.react_native.ts | 5 +- lib/optimizely/index.tests.js | 25 +- lib/optimizely/index.ts | 83 +-- lib/optimizely_user_context/index.ts | 8 +- .../odp/event_api_manager/index.browser.ts | 40 +- .../odp/event_api_manager/index.node.ts | 29 +- lib/plugins/odp_manager/index.browser.ts | 106 +-- lib/plugins/odp_manager/index.node.ts | 85 ++- lib/shared_types.ts | 2 + lib/utils/enums/index.ts | 4 +- lib/utils/promise/resolvablePromise.ts | 34 + tests/odpEventApiManager.spec.ts | 33 +- tests/odpEventManager.spec.ts | 401 +++++++---- tests/odpManager.browser.spec.ts | 237 ++---- tests/odpManager.spec.ts | 675 +++++++++++++++--- tests/odpSegmentManager.spec.ts | 88 ++- tests/testUtils.ts | 7 +- 29 files changed, 1624 insertions(+), 1081 deletions(-) create mode 100644 lib/utils/promise/resolvablePromise.ts diff --git a/lib/core/odp/odp_config.ts b/lib/core/odp/odp_config.ts index 8593dbd2d..4e4f41855 100644 --- a/lib/core/odp/odp_config.ts +++ b/lib/core/odp/odp_config.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,90 +19,29 @@ import { checkArrayEquality } from '../../utils/fns'; export class OdpConfig { /** * Host of ODP audience segments API. - * @private */ - private _apiHost: string; - - /** - * Getter to retrieve the ODP server host - * @public - */ - get apiHost(): string { - return this._apiHost; - } + readonly apiHost: string; /** * Public API key for the ODP account from which the audience segments will be fetched (optional). - * @private */ - private _apiKey: string; - - /** - * Getter to retrieve the ODP API key - * @public - */ - get apiKey(): string { - return this._apiKey; - } + readonly apiKey: string; /** * Url for sending events via pixel. - * @private - */ - private _pixelUrl: string; - - /** - * Getter to retrieve the ODP pixel URL - * @public */ - get pixelUrl(): string { - return this._pixelUrl; - } + readonly pixelUrl: string; /** * All ODP segments used in the current datafile (associated with apiHost/apiKey). - * @private - */ - private _segmentsToCheck: string[]; - - /** - * Getter for ODP segments to check - * @public - */ - get segmentsToCheck(): string[] { - return this._segmentsToCheck; - } - - constructor(apiKey?: string, apiHost?: string, pixelUrl?: string, segmentsToCheck?: string[]) { - this._apiKey = apiKey ?? ''; - this._apiHost = apiHost ?? ''; - this._pixelUrl = pixelUrl ?? ''; - this._segmentsToCheck = segmentsToCheck ?? []; - } - - /** - * Update the ODP configuration details - * @param {OdpConfig} config New ODP Config to potentially update self with - * @returns true if configuration was updated successfully */ - update(config: OdpConfig): boolean { - if (this.equals(config)) { - return false; - } else { - if (config.apiKey) this._apiKey = config.apiKey; - if (config.apiHost) this._apiHost = config.apiHost; - if (config.pixelUrl) this._pixelUrl = config.pixelUrl; - if (config.segmentsToCheck) this._segmentsToCheck = config.segmentsToCheck; + readonly segmentsToCheck: string[]; - return true; - } - } - - /** - * Determines if ODP configuration has the minimum amount of information - */ - isReady(): boolean { - return !!this._apiKey && !!this._apiHost; + constructor(apiKey: string, apiHost: string, pixelUrl: string, segmentsToCheck: string[]) { + this.apiKey = apiKey; + this.apiHost = apiHost; + this.pixelUrl = pixelUrl; + this.segmentsToCheck = segmentsToCheck; } /** @@ -112,10 +51,33 @@ export class OdpConfig { */ equals(configToCompare: OdpConfig): boolean { return ( - this._apiHost === configToCompare._apiHost && - this._apiKey === configToCompare._apiKey && - this._pixelUrl === configToCompare._pixelUrl && - checkArrayEquality(this.segmentsToCheck, configToCompare._segmentsToCheck) + this.apiHost === configToCompare.apiHost && + this.apiKey === configToCompare.apiKey && + this.pixelUrl === configToCompare.pixelUrl && + checkArrayEquality(this.segmentsToCheck, configToCompare.segmentsToCheck) ); } } + +export type OdpNotIntegratedConfig = { + readonly integrated: false; +} + +export type OdpIntegratedConfig = { + readonly integrated: true; + readonly odpConfig: OdpConfig; +} + +export const odpIntegrationsAreEqual = (config1: OdpIntegrationConfig, config2: OdpIntegrationConfig): boolean => { + if (config1.integrated !== config2.integrated) { + return false; + } + + if (config1.integrated && config2.integrated) { + return config1.odpConfig.equals(config2.odpConfig); + } + + return true; +} + +export type OdpIntegrationConfig = OdpNotIntegratedConfig | OdpIntegratedConfig; diff --git a/lib/core/odp/odp_event_api_manager.ts b/lib/core/odp/odp_event_api_manager.ts index 30d5eb7a1..35ffcc4e8 100644 --- a/lib/core/odp/odp_event_api_manager.ts +++ b/lib/core/odp/odp_event_api_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,16 +18,15 @@ import { LogHandler, LogLevel } from '../../modules/logging'; import { OdpEvent } from './odp_event'; import { RequestHandler } from '../../utils/http_request_handler/http'; import { OdpConfig } from './odp_config'; +import { ERROR_MESSAGES } from '../../utils/enums'; const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; -export const ODP_CONFIG_NOT_READY_MESSAGE = 'ODP config not ready'; /** * Manager for communicating with the Optimizely Data Platform REST API */ export interface IOdpEventApiManager { - sendEvents(events: OdpEvent[]): Promise; - updateSettings(odpConfig: OdpConfig): void; + sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise; } /** @@ -46,11 +45,6 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { */ private readonly requestHandler: RequestHandler; - /** - * ODP configuration settings for identifying the target API and segments - */ - protected odpConfig?: OdpConfig; - /** * Creates instance to access Optimizely Data Platform (ODP) REST API * @param requestHandler Desired request handler for testing @@ -61,14 +55,6 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { this.logger = logger; } - /** - * Updates odpConfig of the api manager instance - * @param odpConfig - */ - updateSettings(odpConfig: OdpConfig): void { - this.odpConfig = odpConfig; - } - getLogger(): LogHandler { return this.logger; } @@ -78,14 +64,9 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { * @param events ODP events to send * @returns Retry is true - if network or server error (5xx), otherwise false */ - async sendEvents(events: OdpEvent[]): Promise { + async sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise { let shouldRetry = false; - if (!this.odpConfig?.isReady()) { - this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (${ODP_CONFIG_NOT_READY_MESSAGE})`); - return shouldRetry; - } - if (events.length === 0) { this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (no events)`); return shouldRetry; @@ -95,7 +76,7 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { return shouldRetry; } - const { method, endpoint, headers, data } = this.generateRequestData(events); + const { method, endpoint, headers, data } = this.generateRequestData(odpConfig, events); let statusCode = 0; try { @@ -125,6 +106,7 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { protected abstract shouldSendEvents(events: OdpEvent[]): boolean; protected abstract generateRequestData( + odpConfig: OdpConfig, events: OdpEvent[] ): { method: string; diff --git a/lib/core/odp/odp_event_manager.ts b/lib/core/odp/odp_event_manager.ts index 934c2d2fb..80baa4822 100644 --- a/lib/core/odp/odp_event_manager.ts +++ b/lib/core/odp/odp_event_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,10 +30,9 @@ const MAX_RETRIES = 3; /** * Event dispatcher's execution states */ -export enum STATE { - STOPPED, - RUNNING, - PROCESSING, +export enum Status { + Stopped, + Running, } /** @@ -52,7 +51,7 @@ export interface IOdpEventManager { sendEvent(event: OdpEvent): void; - flush(): void; + flush(retry?: boolean): void; } /** @@ -62,7 +61,7 @@ export abstract class OdpEventManager implements IOdpEventManager { /** * Current state of the event processor */ - state: STATE = STATE.STOPPED; + status: Status = Status.Stopped; /** * Queue for holding all events to be eventually dispatched @@ -80,7 +79,7 @@ export abstract class OdpEventManager implements IOdpEventManager { * ODP configuration settings for identifying the target API and segments * @private */ - private odpConfig: OdpConfig; + private odpConfig?: OdpConfig; /** * REST API Manager used to send the events @@ -130,6 +129,8 @@ export abstract class OdpEventManager implements IOdpEventManager { */ private readonly userAgentParser?: IUserAgentParser; + private retries: number; + /** * Information about the user agent @@ -147,8 +148,9 @@ export abstract class OdpEventManager implements IOdpEventManager { batchSize, flushInterval, userAgentParser, + retries, }: { - odpConfig: OdpConfig; + odpConfig?: OdpConfig; apiManager: IOdpEventApiManager; logger: LogHandler; clientEngine: string; @@ -157,15 +159,16 @@ export abstract class OdpEventManager implements IOdpEventManager { batchSize?: number; flushInterval?: number; userAgentParser?: IUserAgentParser; + retries?: number; }) { - this.odpConfig = odpConfig; this.apiManager = apiManager; this.logger = logger; this.clientEngine = clientEngine; this.clientVersion = clientVersion; this.initParams(batchSize, queueSize, flushInterval); - this.state = STATE.STOPPED; + this.status = Status.Stopped; this.userAgentParser = userAgentParser; + this.retries = retries || MAX_RETRIES; if (userAgentParser) { const { os, device } = userAgentParser.parseUserAgentInfo(); @@ -182,7 +185,9 @@ export abstract class OdpEventManager implements IOdpEventManager { ); } - this.apiManager.updateSettings(odpConfig); + if (odpConfig) { + this.updateSettings(odpConfig); + } } protected abstract initParams( @@ -195,25 +200,38 @@ export abstract class OdpEventManager implements IOdpEventManager { * Update ODP configuration settings. * @param newConfig New configuration to apply */ - updateSettings(newConfig: OdpConfig): void { - this.odpConfig = newConfig; - this.apiManager.updateSettings(newConfig); + updateSettings(odpConfig: OdpConfig): void { + // do nothing if config did not change + if (this.odpConfig && this.odpConfig.equals(odpConfig)) { + return; + } + + this.flush(); + this.odpConfig = odpConfig; } /** - * Cleans up all pending events; occurs every time the ODP Config is updated. + * Cleans up all pending events; */ flush(): void { this.processQueue(true); } /** - * Start processing events in the queue + * Start the event manager */ start(): void { - this.state = STATE.RUNNING; + if (!this.odpConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + return; + } - this.setNewTimeout(); + this.status = Status.Running; + + // no need of periodic flush if batchSize is 1 + if (this.batchSize > 1) { + this.setNewTimeout(); + } } /** @@ -222,9 +240,9 @@ export abstract class OdpEventManager implements IOdpEventManager { async stop(): Promise { this.logger.log(LogLevel.DEBUG, 'Stop requested.'); - await this.processQueue(true); - - this.state = STATE.STOPPED; + this.flush(); + this.clearCurrentTimeout(); + this.status = Status.Stopped; this.logger.log(LogLevel.DEBUG, 'Stopped. Queue Count: %s', this.queue.length); } @@ -283,7 +301,7 @@ export abstract class OdpEventManager implements IOdpEventManager { * @private */ private enqueue(event: OdpEvent): void { - if (this.state === STATE.STOPPED) { + if (this.status === Status.Stopped) { this.logger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.'); return; } @@ -303,7 +321,6 @@ export abstract class OdpEventManager implements IOdpEventManager { } this.queue.push(event); - this.processQueue(); } @@ -315,38 +332,30 @@ export abstract class OdpEventManager implements IOdpEventManager { * @private */ private processQueue(shouldFlush = false): void { - if (this.state !== STATE.RUNNING) { + if (this.status !== Status.Running) { return; } - - if (!this.isOdpConfigurationReady()) { - return; - } - - // Flush interval occurred & queue has items + if (shouldFlush) { // clear the queue completely this.clearCurrentTimeout(); - this.state = STATE.PROCESSING; - while (this.queueContainsItems()) { this.makeAndSend1Batch(); } - } - // Check if queue has a full batch available - else if (this.queueHasBatches()) { + } else if (this.queueHasBatches()) { + // Check if queue has a full batch available this.clearCurrentTimeout(); - this.state = STATE.PROCESSING; - while (this.queueHasBatches()) { this.makeAndSend1Batch(); } } - this.state = STATE.RUNNING; - this.setNewTimeout(); + // no need for periodic flush if batchSize is 1 + if (this.batchSize > 1) { + this.setNewTimeout(); + } } /** @@ -374,27 +383,23 @@ export abstract class OdpEventManager implements IOdpEventManager { * @private */ private makeAndSend1Batch(): void { - const batch = new Array(); - - // remove a batch from the queue - for (let count = 0; count < this.batchSize; count += 1) { - const event = this.queue.shift(); - if (event) { - batch.push(event); - } else { - break; - } + if (!this.odpConfig) { + return; } + const batch = this.queue.splice(0, this.batchSize); + + const odpConfig = this.odpConfig; + if (batch.length > 0) { // put sending the event on another event loop - setTimeout(async () => { + queueMicrotask(async () => { let shouldRetry: boolean; let attemptNumber = 0; do { - shouldRetry = await this.apiManager.sendEvents(batch); + shouldRetry = await this.apiManager.sendEvents(odpConfig, batch); attemptNumber += 1; - } while (shouldRetry && attemptNumber < MAX_RETRIES); + } while (shouldRetry && attemptNumber < this.retries); }); } } @@ -417,20 +422,6 @@ export abstract class OdpEventManager implements IOdpEventManager { return this.queue.length > 0; } - /** - * Check if the ODP Configuration is ready and log if not. - * Potentially clear queue if server-side - * @returns True if the ODP configuration is ready otherwise False - * @private - */ - private isOdpConfigurationReady(): boolean { - if (this.odpConfig.isReady()) { - return true; - } - this.discardEventsIfNeeded(); - return false; - } - protected abstract discardEventsIfNeeded(): void; /** @@ -454,4 +445,8 @@ export abstract class OdpEventManager implements IOdpEventManager { protected getLogger(): LogHandler { return this.logger; } + + getQueue(): OdpEvent[] { + return this.queue; + } } diff --git a/lib/core/odp/odp_manager.ts b/lib/core/odp/odp_manager.ts index ca3fc7f77..4da2f6191 100644 --- a/lib/core/odp/odp_manager.ts +++ b/lib/core/odp/odp_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,29 +20,26 @@ import { ERROR_MESSAGES, ODP_USER_KEY } from '../../utils/enums'; import { VuidManager } from '../../plugins/vuid_manager'; -import { OdpConfig } from './odp_config'; +import { OdpConfig, OdpIntegrationConfig, odpIntegrationsAreEqual } from './odp_config'; import { IOdpEventManager } from './odp_event_manager'; import { IOdpSegmentManager } from './odp_segment_manager'; import { OptimizelySegmentOption } from './optimizely_segment_option'; import { invalidOdpDataFound } from './odp_utils'; import { OdpEvent } from './odp_event'; +import { resolvablePromise, ResolvablePromise } from '../../utils/promise/resolvablePromise'; /** * Manager for handling internal all business logic related to * Optimizely Data Platform (ODP) / Advanced Audience Targeting (AAT) */ export interface IOdpManager { - initPromise?: Promise; + onReady(): Promise; - enabled: boolean; + isReady(): boolean; - segmentManager: IOdpSegmentManager | undefined; + updateSettings(odpIntegrationConfig: OdpIntegrationConfig): boolean; - eventManager: IOdpEventManager | undefined; - - updateSettings({ apiKey, apiHost, pixelUrl, segmentsToCheck }: OdpConfig): boolean; - - close(): void; + stop(): void; fetchQualifiedSegments(userId: string, options?: Array): Promise; @@ -55,6 +52,11 @@ export interface IOdpManager { getVuid(): string | undefined; } +export enum Status { + Running, + Stopped, +} + /** * Orchestrates segments manager, event manager, and ODP configuration */ @@ -62,79 +64,143 @@ export abstract class OdpManager implements IOdpManager { /** * Promise that returns when the OdpManager is finished initializing */ - initPromise?: Promise; + private initPromise: Promise; + + private ready = false; /** - * Switch to enable/disable ODP Manager functionality + * Promise that resolves when odpConfig becomes available */ - enabled = true; + private configPromise: ResolvablePromise; + + status: Status = Status.Stopped; /** * 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. */ - segmentManager: IOdpSegmentManager | undefined; + private segmentManager: IOdpSegmentManager; /** * 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. */ - eventManager: IOdpEventManager | undefined; + private eventManager: IOdpEventManager; /** * Handler for recording execution logs * @protected */ - protected logger: LogHandler = getLogger(); // TODO: Consider making private and moving instantiation to constructor + protected logger: LogHandler; /** * ODP configuration settings for identifying the target API and segments */ - odpConfig: OdpConfig = new OdpConfig(); // TODO: Consider making private and adding public accessors + odpIntegrationConfig?: OdpIntegrationConfig; + + // TODO: Consider accepting logger as a parameter and initializing it in constructor instead + constructor({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + }: { + odpIntegrationConfig?: OdpIntegrationConfig; + segmentManager: IOdpSegmentManager; + eventManager: IOdpEventManager; + logger: LogHandler; + }) { + this.segmentManager = segmentManager; + this.eventManager = eventManager; + this.logger = logger; + + this.configPromise = resolvablePromise(); + + const readinessDependencies: PromiseLike[] = [this.configPromise]; + + if (this.isVuidEnabled()) { + readinessDependencies.push(this.initializeVuid()); + } - constructor() {} // TODO: Consider accepting logger as a parameter and initializing it in constructor instead + this.initPromise = Promise.all(readinessDependencies); - /** - * Provides a method to update ODP Manager's ODP Config API Key, API Host, and Audience Segments - */ - updateSettings({ apiKey, apiHost, pixelUrl, segmentsToCheck }: OdpConfig): boolean { - if (!this.enabled) { - return false; + this.onReady().then(() => { + this.ready = true; + if(this.isVuidEnabled() && this.status === Status.Running) { + this.registerVuid(); + } + }); + + if (odpIntegrationConfig) { + this.updateSettings(odpIntegrationConfig); } + } - if (!this.eventManager) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_MANAGER_UPDATE_SETTINGS_FAILED_EVENT_MANAGER_MISSING); - return false; + public getStatus(): Status { + return this.status; + } + + async start(): Promise { + if (this.status === Status.Running) { + return; } - if (!this.segmentManager) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_MANAGER_UPDATE_SETTINGS_FAILED_SEGMENTS_MANAGER_MISSING); - return false; + if (!this.odpIntegrationConfig) { + return Promise.reject(new Error('cannot start without ODP config')); } - this.eventManager.flush(); + if (!this.odpIntegrationConfig.integrated) { + return Promise.reject(new Error('start() called when ODP is not integrated')); + } - const newConfig = new OdpConfig(apiKey, apiHost, pixelUrl, segmentsToCheck); - const configDidUpdate = this.odpConfig.update(newConfig); + this.status = Status.Running; + this.segmentManager.updateSettings(this.odpIntegrationConfig.odpConfig); + this.eventManager.updateSettings(this.odpIntegrationConfig.odpConfig); + this.eventManager.start(); + return Promise.resolve(); + } - if (configDidUpdate) { - this.odpConfig.update(newConfig); - this.segmentManager?.reset(); - return true; + async stop(): Promise { + if (this.status === Status.Stopped) { + return; } + this.status = Status.Stopped; + await this.eventManager.stop(); + } + + onReady(): Promise { + return this.initPromise; + } - return false; + isReady(): boolean { + return this.ready; } /** - * Attempts to stop the current instance of ODP Manager's event manager, if it exists and is running. + * Provides a method to update ODP Manager's ODP Config */ - close(): void { - if (!this.enabled) { - return; + updateSettings(odpIntegrationConfig: OdpIntegrationConfig): boolean { + this.configPromise.resolve(); + + // do nothing if config did not change + if (this.odpIntegrationConfig && odpIntegrationsAreEqual(this.odpIntegrationConfig, odpIntegrationConfig)) { + return false; } - this.eventManager?.stop(); + this.odpIntegrationConfig = odpIntegrationConfig; + + if (odpIntegrationConfig.integrated) { + // already running, just propagate updated config to children; + if (this.status === Status.Running) { + this.segmentManager.updateSettings(odpIntegrationConfig.odpConfig); + this.eventManager.updateSettings(odpIntegrationConfig.odpConfig); + } else { + this.start(); + } + } else { + this.stop(); + } + return true; } /** @@ -145,13 +211,13 @@ export abstract class OdpManager implements IOdpManager { * @returns {Promise} A promise holding either a list of qualified segments or null. */ async fetchQualifiedSegments(userId: string, options: Array = []): Promise { - if (!this.enabled) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED); + if (!this.odpIntegrationConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); return null; } - if (!this.segmentManager) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_FETCH_QUALIFIED_SEGMENTS_SEGMENTS_MANAGER_MISSING); + if (!this.odpIntegrationConfig.integrated) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); return null; } @@ -159,7 +225,7 @@ export abstract class OdpManager implements IOdpManager { return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.VUID, userId, options); } - return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, options); + return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, options); } /** @@ -169,18 +235,13 @@ export abstract class OdpManager implements IOdpManager { * @returns */ 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); + if (!this.odpIntegrationConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); return; } - if (!this.eventManager) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_IDENTIFY_FAILED_EVENT_MANAGER_MISSING); + if (!this.odpIntegrationConfig.integrated) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); return; } @@ -203,22 +264,20 @@ export abstract class OdpManager implements IOdpManager { mType = 'fullstack'; } - if (!this.enabled) { - throw new Error(ERROR_MESSAGES.ODP_NOT_ENABLED); + if (!this.odpIntegrationConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + return; } - if (!this.odpConfig.isReady()) { - throw new Error(ERROR_MESSAGES.ODP_NOT_INTEGRATED); + if (!this.odpIntegrationConfig.integrated) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); + return; } 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); - } - if (typeof action !== 'string' || action === '') { throw new Error('ODP action is not valid (cannot be empty).'); } @@ -235,4 +294,31 @@ export abstract class OdpManager implements IOdpManager { * Returns VUID value if it exists */ abstract getVuid(): string | undefined; + + protected initializeVuid(): Promise { + return Promise.resolve(); + } + + private registerVuid() { + if (!this.odpIntegrationConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + return; + } + + if (!this.odpIntegrationConfig.integrated) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); + return; + } + + const vuid = this.getVuid(); + if (!vuid) { + return; + } + + try { + this.eventManager.registerVuid(vuid); + } catch (e) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED); + } + } } diff --git a/lib/core/odp/odp_segment_manager.ts b/lib/core/odp/odp_segment_manager.ts index 1d89fd467..ac92f5e33 100644 --- a/lib/core/odp/odp_segment_manager.ts +++ b/lib/core/odp/odp_segment_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,7 +40,7 @@ export class OdpSegmentManager implements IOdpSegmentManager { * ODP configuration settings in used * @private */ - private odpConfig: OdpConfig; + private odpConfig?: OdpConfig; /** * Holds cached audience segments @@ -69,10 +69,10 @@ export class OdpSegmentManager implements IOdpSegmentManager { private readonly logger: LogHandler; constructor( - odpConfig: OdpConfig, segmentsCache: ICache, odpSegmentApiManager: IOdpSegmentApiManager, - logger?: LogHandler + logger?: LogHandler, + odpConfig?: OdpConfig, ) { this.odpConfig = odpConfig; this._segmentsCache = segmentsCache; @@ -93,11 +93,9 @@ export class OdpSegmentManager implements IOdpSegmentManager { userValue: string, options: Array ): Promise { - const { apiHost: odpApiHost, apiKey: odpApiKey } = this.odpConfig; - - if (!odpApiKey || !odpApiHost) { - this.logger.log(LogLevel.WARNING, ERROR_MESSAGES.FETCH_SEGMENTS_FAILED_INVALID_IDENTIFIER); - return null; + if (!this.odpConfig) { + this.logger.log(LogLevel.WARNING, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + return null; } const segmentsToCheck = this.odpConfig.segmentsToCheck; @@ -127,8 +125,8 @@ export class OdpSegmentManager implements IOdpSegmentManager { this.logger.log(LogLevel.DEBUG, `Making a call to ODP server.`); const segments = await this.odpSegmentApiManager.fetchSegments( - odpApiKey, - odpApiHost, + this.odpConfig.apiKey, + this.odpConfig.apiHost, userKey, userValue, segmentsToCheck @@ -164,6 +162,6 @@ export class OdpSegmentManager implements IOdpSegmentManager { */ updateSettings(config: OdpConfig): void { this.odpConfig = config; - this._segmentsCache.reset(); + this.reset(); } } diff --git a/lib/core/project_config/index.tests.js b/lib/core/project_config/index.tests.js index 0500e8884..24134e3cd 100644 --- a/lib/core/project_config/index.tests.js +++ b/lib/core/project_config/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2023, Optimizely + * Copyright 2016-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -813,21 +813,12 @@ describe('lib/core/project_config', function() { assert.equal(config.integrations.length, 4); }); - it('should populate the public key value from the odp integration', () => { - assert.exists(config.publicKeyForOdp); - }); - - it('should populate the host value from the odp integration', () => { - assert.exists(config.hostForOdp); - }); - - it('should populate the pixelUrl value from the odp integration', () => { - assert.exists(config.pixelUrlForOdp); - }); - - it('should contain all expected unique odp segments in allSegments', () => { - assert.equal(config.allSegments.length, 3); - assert.deepEqual(config.allSegments, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3']); + it('should populate odpIntegrationConfig', () => { + assert.isTrue(config.odpIntegrationConfig.integrated); + assert.equal(config.odpIntegrationConfig.odpConfig.apiKey, 'W4WzcEs-ABgXorzY7h1LCQ'); + assert.equal(config.odpIntegrationConfig.odpConfig.apiHost, 'https://api.zaius.com'); + assert.equal(config.odpIntegrationConfig.odpConfig.pixelUrl, 'https://jumbe.zaius.com'); + assert.deepEqual(config.odpIntegrationConfig.odpConfig.segmentsToCheck, ['odp-segment-1', 'odp-segment-2', 'odp-segment-3']); }); }); @@ -842,23 +833,12 @@ describe('lib/core/project_config', function() { assert.equal(config.integrations.length, 3); }); - it('should populate the public key value from the odp integration', () => { - assert.exists(config.publicKeyForOdp); - assert.equal(config.publicKeyForOdp, 'W4WzcEs-ABgXorzY7h1LCQ'); - }); - - it('should populate the host value from the odp integration', () => { - assert.exists(config.hostForOdp); - assert.equal(config.hostForOdp, 'https://api.zaius.com'); - }); - - it('should populate the pixelUrl value from the odp integration', () => { - assert.exists(config.pixelUrlForOdp); - assert.equal(config.pixelUrlForOdp, 'https://jumbe.zaius.com'); - }); - - it('should contain all expected unique odp segments in all segments', () => { - assert.equal(config.allSegments.length, 0); + it('should populate odpIntegrationConfig', () => { + assert.isTrue(config.odpIntegrationConfig.integrated); + assert.equal(config.odpIntegrationConfig.odpConfig.apiKey, 'W4WzcEs-ABgXorzY7h1LCQ'); + assert.equal(config.odpIntegrationConfig.odpConfig.apiHost, 'https://api.zaius.com'); + assert.equal(config.odpIntegrationConfig.odpConfig.pixelUrl, 'https://jumbe.zaius.com'); + assert.deepEqual(config.odpIntegrationConfig.odpConfig.segmentsToCheck, []); }); }); @@ -882,6 +862,11 @@ describe('lib/core/project_config', function() { it('should convert integrations from the datafile into the project config', () => { assert.equal(config.integrations.length, 0); }); + + it('should populate odpIntegrationConfig', () => { + assert.isFalse(config.odpIntegrationConfig.integrated); + assert.isUndefined(config.odpIntegrationConfig.odpConfig); + }); }); }); }); diff --git a/lib/core/project_config/index.ts b/lib/core/project_config/index.ts index b94e3373c..68ffbeacd 100644 --- a/lib/core/project_config/index.ts +++ b/lib/core/project_config/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2016-2023, Optimizely + * Copyright 2016-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import { Integration, FeatureVariableValue, } from '../../shared_types'; +import { OdpConfig, OdpIntegrationConfig } from '../odp/odp_config'; interface TryCreatingProjectConfigConfig { // TODO[OASIS-6649]: Don't use object type @@ -90,10 +91,7 @@ export interface ProjectConfig { flagVariationsMap: { [key: string]: Variation[] }; integrations: Integration[]; integrationKeyMap?: { [key: string]: Integration }; - publicKeyForOdp?: string; - hostForOdp?: string; - pixelUrlForOdp?: string; - allSegments: string[]; + odpIntegrationConfig: OdpIntegrationConfig; } const EXPERIMENT_RUNNING_STATUS = 'Running'; @@ -154,19 +152,6 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.audiencesById = keyBy(projectConfig.audiences, 'id'); assign(projectConfig.audiencesById, keyBy(projectConfig.typedAudiences, 'id')); - projectConfig.allSegments = []; - const allSegmentsSet = new Set(); - - Object.keys(projectConfig.audiencesById) - .map(audience => getAudienceSegments(projectConfig.audiencesById[audience])) - .forEach(audienceSegments => { - audienceSegments.forEach(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'); @@ -188,6 +173,23 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str }); }); + const allSegmentsSet = new Set(); + + Object.keys(projectConfig.audiencesById) + .map(audience => getAudienceSegments(projectConfig.audiencesById[audience])) + .forEach(audienceSegments => { + audienceSegments.forEach(segment => { + allSegmentsSet.add(segment); + }); + }); + + const allSegments = Array.from(allSegmentsSet); + + let odpIntegrated = false; + let odpApiHost = ''; + let odpApiKey = ''; + let odpPixelUrl = ''; + if (projectConfig.integrations) { projectConfig.integrationKeyMap = keyBy(projectConfig.integrations, 'key'); @@ -197,21 +199,23 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str } if (integration.key === 'odp') { - if (integration.publicKey && !projectConfig.publicKeyForOdp) { - projectConfig.publicKeyForOdp = integration.publicKey; - } - - if (integration.host && !projectConfig.hostForOdp) { - projectConfig.hostForOdp = integration.host; - } - - if (integration.pixelUrl && !projectConfig.pixelUrlForOdp) { - projectConfig.pixelUrlForOdp = integration.pixelUrl; - } + odpIntegrated = true; + odpApiKey = odpApiKey || integration.publicKey || ''; + odpApiHost = odpApiHost || integration.host || ''; + odpPixelUrl = odpPixelUrl || integration.pixelUrl || ''; } }); } + if (odpIntegrated) { + projectConfig.odpIntegrationConfig = { + integrated: true, + odpConfig: new OdpConfig(odpApiKey, odpApiHost, odpPixelUrl, allSegments), + } + } else { + projectConfig.odpIntegrationConfig = { integrated: false }; + } + projectConfig.experimentKeyMap = keyBy(projectConfig.experiments, 'key'); projectConfig.experimentIdMap = keyBy(projectConfig.experiments, 'id'); diff --git a/lib/core/project_config/project_config_manager.tests.js b/lib/core/project_config/project_config_manager.tests.js index bc44e62eb..b8fe8f8d3 100644 --- a/lib/core/project_config/project_config_manager.tests.js +++ b/lib/core/project_config/project_config_manager.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2019-2020, 2022, Optimizely + * Copyright 2019-2020, 2022, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -165,7 +165,7 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); - it('does not call onUpdate listeners after becoming ready when constructed with a valid datafile and without sdkKey', function() { + it('calls onUpdate listeners once when constructed with a valid datafile and without sdkKey', function() { var configWithFeatures = testData.getTestProjectConfigWithFeatures(); var manager = projectConfigManager.createProjectConfigManager({ datafile: configWithFeatures, @@ -173,7 +173,7 @@ describe('lib/core/project_config/project_config_manager', function() { var onUpdateSpy = sinon.spy(); manager.onUpdate(onUpdateSpy); return manager.onReady().then(function() { - sinon.assert.notCalled(onUpdateSpy); + sinon.assert.calledOnce(onUpdateSpy); }); }); @@ -242,7 +242,7 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); - it('calls onUpdate listeners after becoming ready, and after the datafile manager emits updates', function() { + it('calls onUpdate listeners after becoming ready, and after the datafile manager emits updates', async function() { datafileManager.HttpPollingDatafileManager.returns({ start: sinon.stub(), stop: sinon.stub(), @@ -256,21 +256,20 @@ describe('lib/core/project_config/project_config_manager', function() { }); var onUpdateSpy = sinon.spy(); manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function() { - sinon.assert.calledOnce(onUpdateSpy); - - var fakeDatafileManager = datafileManager.HttpPollingDatafileManager.getCall(0).returnValue; - var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - var newDatafile = testData.getTestProjectConfigWithFeatures(); - newDatafile.revision = '36'; - fakeDatafileManager.get.returns(newDatafile); - - updateListener({ datafile: newDatafile }); - sinon.assert.calledTwice(onUpdateSpy); - }); + await manager.onReady(); + sinon.assert.calledOnce(onUpdateSpy); + var fakeDatafileManager = datafileManager.HttpPollingDatafileManager.getCall(0).returnValue; + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + var newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '36'; + fakeDatafileManager.get.returns(newDatafile); + updateListener({ datafile: newDatafile }); + + await Promise.resolve(); + sinon.assert.calledTwice(onUpdateSpy); }); - it('can remove onUpdate listeners using the function returned from onUpdate', function() { + it('can remove onUpdate listeners using the function returned from onUpdate', async function() { datafileManager.HttpPollingDatafileManager.returns({ start: sinon.stub(), stop: sinon.stub(), @@ -282,29 +281,29 @@ describe('lib/core/project_config/project_config_manager', function() { sdkKey: '12345', datafileManager: createHttpPollingDatafileManager('12345', logger), }); - return manager.onReady().then(function() { - var onUpdateSpy = sinon.spy(); - var unsubscribe = manager.onUpdate(onUpdateSpy); + await manager.onReady(); + var onUpdateSpy = sinon.spy(); + var unsubscribe = manager.onUpdate(onUpdateSpy); + var fakeDatafileManager = datafileManager.HttpPollingDatafileManager.getCall(0).returnValue; + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - var fakeDatafileManager = datafileManager.HttpPollingDatafileManager.getCall(0).returnValue; - var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - var newDatafile = testData.getTestProjectConfigWithFeatures(); - newDatafile.revision = '36'; - fakeDatafileManager.get.returns(newDatafile); - updateListener({ datafile: newDatafile }); - - sinon.assert.calledOnce(onUpdateSpy); - - unsubscribe(); - - newDatafile = testData.getTestProjectConfigWithFeatures(); - newDatafile.revision = '37'; - fakeDatafileManager.get.returns(newDatafile); - updateListener({ datafile: newDatafile }); - // // Should not call onUpdateSpy again since we unsubscribed - updateListener({ datafile: testData.getTestProjectConfigWithFeatures() }); - sinon.assert.calledOnce(onUpdateSpy); - }); + var newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '36'; + fakeDatafileManager.get.returns(newDatafile); + + updateListener({ datafile: newDatafile }); + // allow queued micortasks to run + await Promise.resolve(); + + sinon.assert.calledOnce(onUpdateSpy); + unsubscribe(); + newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '37'; + fakeDatafileManager.get.returns(newDatafile); + updateListener({ datafile: newDatafile }); + // // Should not call onUpdateSpy again since we unsubscribed + updateListener({ datafile: testData.getTestProjectConfigWithFeatures() }); + sinon.assert.calledOnce(onUpdateSpy); }); it('fulfills its ready promise with an unsuccessful result when the datafile manager emits an invalid datafile', function() { @@ -368,58 +367,92 @@ describe('lib/core/project_config/project_config_manager', function() { }); describe('when constructed with sdkKey and with a valid datafile object', function() { - it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() { - datafileManager.HttpPollingDatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(JSON.stringify(testData.getTestProjectConfigWithFeatures())), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()), - }); + it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners if datafile does not change', async function() { var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + + const handlers = []; + const mockDatafileManager = { + start: () => {}, + get: () => JSON.stringify(configWithFeatures), + on: (event, fn) => handlers.push(fn), + onReady: () => Promise.resolve(), + pushUpdate: (datafile) => handlers.forEach(handler => handler({ datafile })), + }; + var manager = projectConfigManager.createProjectConfigManager({ datafile: configWithFeatures, sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger, configWithFeatures), + datafileManager: mockDatafileManager, }); var onUpdateSpy = sinon.spy(); manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function(result) { - assert.include(result, { - success: true, - }); - // Datafile is the same as what it was constructed with, so should - // not have called update listener - sinon.assert.notCalled(onUpdateSpy); + + const result = await manager.onReady(); + assert.include(result, { + success: true, }); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + // allow queued microtasks to run + await Promise.resolve(); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + + configWithFeatures.revision = '99'; + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + sinon.assert.callCount(onUpdateSpy, 2); }); }); describe('when constructed with sdkKey and with a valid datafile string', function() { - it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() { - datafileManager.HttpPollingDatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(JSON.stringify(testData.getTestProjectConfigWithFeatures())), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()), - }); + it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners if datafile does not change', async function() { var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + + const handlers = []; + const mockDatafileManager = { + start: () => {}, + get: () => JSON.stringify(configWithFeatures), + on: (event, fn) => handlers.push(fn), + onReady: () => Promise.resolve(), + pushUpdate: (datafile) => handlers.forEach(handler => handler({ datafile })), + }; + var manager = projectConfigManager.createProjectConfigManager({ datafile: JSON.stringify(configWithFeatures), sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger, JSON.stringify(configWithFeatures)), + datafileManager: mockDatafileManager, }); var onUpdateSpy = sinon.spy(); manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function(result) { - assert.include(result, { - success: true, - }); - // Datafile is the same as what it was constructed with, so should - // not have called update listener - sinon.assert.notCalled(onUpdateSpy); + + const result = await manager.onReady(); + assert.include(result, { + success: true, }); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + // allow queued microtasks to run + await Promise.resolve(); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + + configWithFeatures.revision = '99'; + mockDatafileManager.pushUpdate(JSON.stringify(configWithFeatures)); + await Promise.resolve(); + + sinon.assert.callCount(onUpdateSpy, 2); }); }); diff --git a/lib/core/project_config/project_config_manager.ts b/lib/core/project_config/project_config_manager.ts index 0432b5fc1..3f3aea4df 100644 --- a/lib/core/project_config/project_config_manager.ts +++ b/lib/core/project_config/project_config_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019-2022, Optimizely + * Copyright 2019-2022, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,6 +89,7 @@ export class ProjectConfigManager { if (config.sdkKey && config.datafileManager) { this.datafileManager = config.datafileManager; this.datafileManager.start(); + this.readyPromise = this.datafileManager .onReady() .then(this.onDatafileManagerReadyFulfill.bind(this), this.onDatafileManagerReadyReject.bind(this)); @@ -188,7 +189,10 @@ export class ProjectConfigManager { if (configObj && oldRevision !== configObj.revision) { this.configObj = configObj; this.optimizelyConfigObj = null; - this.updateListeners.forEach(listener => listener(configObj)); + + queueMicrotask(() => { + this.updateListeners.forEach(listener => listener(configObj)); + }); } } diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index 04e4a1559..e9b5283e5 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -582,6 +582,7 @@ describe('javascript-sdk (Browser)', function() { var sandbox = sinon.sandbox.create(); const fakeOptimizely = { + onReady: () => Promise.resolve({ success: true }), identifyUser: sinon.stub().returns(), }; @@ -621,12 +622,14 @@ describe('javascript-sdk (Browser)', function() { requestParams.clear(); }); - it('should send identify event by default when initialized', () => { + it('should send identify event by default when initialized', async () => { new OptimizelyUserContext({ optimizely: fakeOptimizely, userId: testFsUserId, }); + await fakeOptimizely.onReady(); + sinon.assert.calledOnce(fakeOptimizely.identifyUser); sinon.assert.calledWith(fakeOptimizely.identifyUser, testFsUserId); @@ -639,7 +642,8 @@ describe('javascript-sdk (Browser)', function() { eventDispatcher: fakeEventDispatcher, eventBatchSize: null, logger, - odpManager: new BrowserOdpManager({ + odpOptions: { disabled: true }, + odpManager: BrowserOdpManager.createInstance({ logger, odpOptions: { disabled: true, @@ -657,7 +661,7 @@ describe('javascript-sdk (Browser)', function() { eventDispatcher: fakeEventDispatcher, eventBatchSize: null, logger, - odpManager: new BrowserOdpManager({ + odpManager: BrowserOdpManager.createInstance({ logger, }), }); @@ -689,7 +693,7 @@ describe('javascript-sdk (Browser)', function() { eventDispatcher: fakeEventDispatcher, eventBatchSize: null, logger, - odpManager: new BrowserOdpManager({ + odpManager: BrowserOdpManager.createInstance({ logger, odpOptions: { segmentsCacheSize: 10, @@ -711,7 +715,7 @@ describe('javascript-sdk (Browser)', function() { eventDispatcher: fakeEventDispatcher, eventBatchSize: null, logger, - odpManager: new BrowserOdpManager({ + odpManager: BrowserOdpManager.createInstance({ logger, odpOptions: { segmentsCacheTimeout: 10, @@ -759,7 +763,7 @@ describe('javascript-sdk (Browser)', function() { }; const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + datafile: testData.getOdpIntegratedConfigWithSegments(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -769,9 +773,10 @@ describe('javascript-sdk (Browser)', function() { }, }); + const readyData = await client.onReady(); + sinon.assert.called(fakeSegmentManager.updateSettings); - const readyData = await client.onReady(); assert.equal(readyData.success, true); assert.isUndefined(readyData.reason); @@ -794,7 +799,7 @@ describe('javascript-sdk (Browser)', function() { }; const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + datafile: testData.getOdpIntegratedConfigWithSegments(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1143,7 +1148,7 @@ describe('javascript-sdk (Browser)', function() { }); it('should send odp client_initialized on client instantiation', async () => { - const odpConfig = new OdpConfig(); + const odpConfig = new OdpConfig('key', 'host', 'pixel', []); const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); sinon.spy(apiManager, 'sendEvents'); const eventManager = new BrowserOdpEventManager({ @@ -1170,7 +1175,8 @@ describe('javascript-sdk (Browser)', function() { clock.tick(100); - const [events] = apiManager.sendEvents.getCall(0).args; + const [_, events] = apiManager.sendEvents.getCall(0).args; + const [firstEvent] = events; assert.equal(firstEvent.action, 'client_initialized'); assert.equal(firstEvent.type, 'fullstack'); diff --git a/lib/index.browser.ts b/lib/index.browser.ts index 46ce0dbe6..c0d62897c 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -131,6 +131,8 @@ const createInstance = function(config: Config): Client | null { logger.info(enums.LOG_MESSAGES.ODP_DISABLED); } + const { clientEngine, clientVersion } = config; + const optimizelyOptions: OptimizelyOptions = { clientEngine: enums.JAVASCRIPT_CLIENT_ENGINE, ...config, @@ -142,7 +144,8 @@ const createInstance = function(config: Config): Client | null { : undefined, notificationCenter, isValidInstance, - odpManager: odpExplicitlyOff ? undefined : new BrowserOdpManager({ logger, odpOptions: config.odpOptions }), + odpManager: odpExplicitlyOff ? undefined + : BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; const optimizely = new Optimizely(optimizelyOptions); diff --git a/lib/index.node.ts b/lib/index.node.ts index 57d72e174..50a7829b8 100644 --- a/lib/index.node.ts +++ b/lib/index.node.ts @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2017, 2019-2024 Optimizely, Inc. and contributors * + * Copyright 2016-2017, 2019-2024 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. * @@ -107,6 +107,8 @@ const createInstance = function(config: Config): Client | null { logger.info(enums.LOG_MESSAGES.ODP_DISABLED); } + const { clientEngine, clientVersion } = config; + const optimizelyOptions = { clientEngine: enums.NODE_CLIENT_ENGINE, ...config, @@ -118,7 +120,8 @@ const createInstance = function(config: Config): Client | null { : undefined, notificationCenter, isValidInstance, - odpManager: odpExplicitlyOff ? undefined : new NodeOdpManager({ logger, odpOptions: config.odpOptions }), + odpManager: odpExplicitlyOff ? undefined + : NodeOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; return new Optimizely(optimizelyOptions); diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index 6584b46fb..9457052f6 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -103,6 +103,8 @@ const createInstance = function(config: Config): Client | null { logger.info(enums.LOG_MESSAGES.ODP_DISABLED); } + const { clientEngine, clientVersion } = config; + const optimizelyOptions = { clientEngine: enums.REACT_NATIVE_JS_CLIENT_ENGINE, ...config, @@ -120,7 +122,8 @@ const createInstance = function(config: Config): Client | null { : undefined, notificationCenter, isValidInstance: isValidInstance, - odpManager: odpExplicitlyOff ? undefined : new BrowserOdpManager({ logger, odpOptions: config.odpOptions }), + odpManager: odpExplicitlyOff ? undefined + :BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; // If client engine is react, convert it to react native. diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 39e6c3c6e..3f5b3e232 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2023, Optimizely, Inc. and contributors * + * Copyright 2016-2024, 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. * @@ -10144,29 +10144,6 @@ describe('lib/optimizely', function() { fns.uuid.restore(); }); - it('should call logger with log level of "info" when odp disabled', () => { - new Optimizely({ - clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), - errorHandler: errorHandler, - eventDispatcher: eventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - isValidInstance: true, - eventBatchSize: 1, - eventProcessor, - notificationCenter, - odpManager: new NodeOdpManager({ - logger: createdLogger, - odpOptions: { - disabled: true, - }, - }), - }); - - sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, LOG_MESSAGES.ODP_DISABLED); - }); - it('should send an identify event when called with odp enabled', () => { // TODO }); diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index ef57822c2..e29d04daa 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -110,6 +110,7 @@ export default class Optimizely implements Client { this.errorHandler = config.errorHandler; this.isOptimizelyConfigValid = config.isValidInstance; this.logger = config.logger; + this.odpManager = config.odpManager; let decideOptionsArray = config.defaultDecideOptions ?? []; if (!Array.isArray(decideOptionsArray)) { @@ -145,10 +146,6 @@ export default class Optimizely implements Client { this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE); - NotificationRegistry.getNotificationCenter(config.sdkKey)?.sendNotifications( - NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE - ); - this.updateOdpSettings(); }); @@ -178,29 +175,11 @@ export default class Optimizely implements Client { const eventProcessorStartedPromise = this.eventProcessor.start(); - const dependentPromises: Array> = [projectConfigManagerReadyPromise, eventProcessorStartedPromise]; - - if (config.odpManager?.initPromise) { - dependentPromises.push(config.odpManager.initPromise); - } - - this.readyPromise = Promise.all(dependentPromises).then(promiseResults => { - // If no odpManager exists yet, creates a new one - 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(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, () => this.updateOdpSettings()); - } else { - this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.ODP_SDK_KEY_MISSING_NOTIFICATION_CENTER_FAILURE); - } - } - + this.readyPromise = Promise.all([ + projectConfigManagerReadyPromise, + eventProcessorStartedPromise, + config.odpManager ? config.odpManager.onReady() : Promise.resolve(), + ]).then(promiseResults => { // Only return status from project config promise because event processor promise does not return any status. return promiseResults[0]; }); @@ -209,6 +188,15 @@ export default class Optimizely implements Client { this.nextReadyTimeoutId = 0; } + + /** + * Returns the project configuration retrieved from projectConfigManager + * @return {projectConfig.ProjectConfig} + */ + getProjectConfig(): projectConfig.ProjectConfig | null { + return this.projectConfigManager.getConfig(); + } + /** * Returns a truthy value if this instance currently has a valid project config * object, and the initial configuration object that was passed into the @@ -1317,7 +1305,7 @@ export default class Optimizely implements Client { close(): Promise<{ success: boolean; reason?: string }> { try { if (this.odpManager) { - this.odpManager.close(); + this.odpManager.stop(); } this.notificationCenter.clearAllNotificationListeners(); @@ -1454,16 +1442,9 @@ export default class Optimizely implements Client { * null if provided inputs are invalid */ createUserContext(userId?: string, attributes?: UserAttributes): OptimizelyUserContext | null { - let userIdentifier; - - if (this.odpManager?.isVuidEnabled() && !userId) { - userIdentifier = userId || this.getVuid(); - } else { - userIdentifier = userId; - } + const userIdentifier = userId ?? this.odpManager?.getVuid(); if ( - userIdentifier === null || userIdentifier === undefined || !this.validateInputs({ user_id: userIdentifier }, attributes) ) { @@ -1684,15 +1665,13 @@ export default class Optimizely implements Client { */ private updateOdpSettings(): void { const projectConfig = this.projectConfigManager.getConfig(); - if (this.odpManager != null && projectConfig != null) { - this.odpManager.updateSettings( - new OdpConfig( - projectConfig.publicKeyForOdp, - projectConfig.hostForOdp, - projectConfig.pixelUrlForOdp, - projectConfig.allSegments - ) - ); + + if (!projectConfig) { + return; + } + + if (this.odpManager) { + this.odpManager.updateSettings(projectConfig.odpIntegrationConfig) } } @@ -1744,12 +1723,17 @@ export default class Optimizely implements Client { } } + private isOdpIntegrated(): boolean { + return this.projectConfigManager.getConfig()?.odpIntegrationConfig?.integrated ?? false; + } + /** * Identifies user with ODP server in a fire-and-forget manner. + * Should be called only after the instance is ready * @param {string} userId */ public identifyUser(userId: string): void { - if (this.odpManager && this.odpManager.enabled) { + if (this.odpManager && this.isOdpIntegrated()) { this.odpManager.identifyUser(userId); } } @@ -1767,12 +1751,7 @@ export default class Optimizely implements Client { if (!this.odpManager) { return null; } - - if (!this.odpManager.enabled) { - this.logger.error(ERROR_MESSAGES.ODP_FETCH_QUALIFIED_SEGMENTS_FAILED_ODP_MANAGER_MISSING); - return null; - } - + return await this.odpManager.fetchQualifiedSegments(userId, options); } diff --git a/lib/optimizely_user_context/index.ts b/lib/optimizely_user_context/index.ts index de3930290..0b689237a 100644 --- a/lib/optimizely_user_context/index.ts +++ b/lib/optimizely_user_context/index.ts @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2023, Optimizely, Inc. and contributors * + * Copyright 2020-2024, 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. * @@ -64,7 +64,11 @@ export default class OptimizelyUserContext implements IOptimizelyUserContext { this.forcedDecisionsMap = {}; if (shouldIdentifyUser) { - this.identifyUser(); + this.optimizely.onReady().then(({ success }) => { + if (success) { + this.identifyUser(); + } + }); } } diff --git a/lib/plugins/odp/event_api_manager/index.browser.ts b/lib/plugins/odp/event_api_manager/index.browser.ts index 592978f63..8a21a462c 100644 --- a/lib/plugins/odp/event_api_manager/index.browser.ts +++ b/lib/plugins/odp/event_api_manager/index.browser.ts @@ -1,7 +1,23 @@ +/**************************************************************************** + * Copyright 2024, 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 * + * * + * 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 { OdpEvent } from '../../../core/odp/odp_event'; import { OdpEventApiManager } from '../../../core/odp/odp_event_api_manager'; import { LogLevel } from '../../../modules/logging'; -import { ODP_CONFIG_NOT_READY_MESSAGE } from '../../../core/odp/odp_event_api_manager'; +import { OdpConfig, OdpIntegrationConfig } from '../../../core/odp/odp_config'; const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; @@ -16,31 +32,19 @@ export class BrowserOdpEventApiManager extends OdpEventApiManager { return false; } - private getPixelApiEndpoint(): string { - if (!this.odpConfig?.isReady()) { - throw new Error(ODP_CONFIG_NOT_READY_MESSAGE); - } - const pixelUrl = this.odpConfig.pixelUrl; + private getPixelApiEndpoint(odpConfig: OdpConfig): string { + const pixelUrl = odpConfig.pixelUrl; const pixelApiEndpoint = new URL(pixelApiPath, pixelUrl).href; return pixelApiEndpoint; } protected generateRequestData( + odpConfig: OdpConfig, events: OdpEvent[] ): { method: string; endpoint: string; headers: { [key: string]: string }; data: string } { - // the caller should ensure odpConfig is ready before calling - if (!this.odpConfig?.isReady()) { - this.getLogger().log(LogLevel.ERROR, ODP_CONFIG_NOT_READY_MESSAGE); - throw new Error(ODP_CONFIG_NOT_READY_MESSAGE); - } - - // this cannot be cached cause OdpConfig is mutable - // and can be updated in place and it is done so in odp - // manager. We should make OdpConfig immutable and - // refacator later - const pixelApiEndpoint = this.getPixelApiEndpoint(); + const pixelApiEndpoint = this.getPixelApiEndpoint(odpConfig); - const apiKey = this.odpConfig.apiKey; + const apiKey = odpConfig.apiKey; const method = 'GET'; const event = events[0]; const url = new URL(pixelApiEndpoint); diff --git a/lib/plugins/odp/event_api_manager/index.node.ts b/lib/plugins/odp/event_api_manager/index.node.ts index 1d04bc9d3..0b8b4e3ba 100644 --- a/lib/plugins/odp/event_api_manager/index.node.ts +++ b/lib/plugins/odp/event_api_manager/index.node.ts @@ -1,23 +1,34 @@ +/**************************************************************************** + * Copyright 2024, 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 * + * * + * 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 { OdpConfig, OdpIntegrationConfig } from '../../../core/odp/odp_config'; import { OdpEvent } from '../../../core/odp/odp_event'; import { OdpEventApiManager } from '../../../core/odp/odp_event_api_manager'; import { LogLevel } from '../../../modules/logging'; -import { ODP_CONFIG_NOT_READY_MESSAGE } from '../../../core/odp/odp_event_api_manager'; export class NodeOdpEventApiManager extends OdpEventApiManager { protected shouldSendEvents(events: OdpEvent[]): boolean { return true; } protected generateRequestData( + odpConfig: OdpConfig, events: OdpEvent[] ): { method: string; endpoint: string; headers: { [key: string]: string }; data: string } { - // the caller should ensure odpConfig is ready before calling - if (!this.odpConfig?.isReady()) { - this.getLogger().log(LogLevel.ERROR, ODP_CONFIG_NOT_READY_MESSAGE); - throw new Error(ODP_CONFIG_NOT_READY_MESSAGE); - } - - const apiHost = this.odpConfig.apiHost; - const apiKey = this.odpConfig.apiKey; + + const { apiHost, apiKey } = odpConfig; return { method: 'POST', diff --git a/lib/plugins/odp_manager/index.browser.ts b/lib/plugins/odp_manager/index.browser.ts index 171b93566..e7095364a 100644 --- a/lib/plugins/odp_manager/index.browser.ts +++ b/lib/plugins/odp_manager/index.browser.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,36 +35,48 @@ import { VuidManager } from './../vuid_manager/index'; import { OdpManager } from '../../core/odp/odp_manager'; import { OdpEvent } from '../../core/odp/odp_event'; -import { OdpOptions } from '../../shared_types'; +import { IOdpEventManager, OdpOptions } from '../../shared_types'; import { BrowserOdpEventApiManager } from '../odp/event_api_manager/index.browser'; import { BrowserOdpEventManager } from '../odp/event_manager/index.browser'; -import { OdpSegmentManager } from '../../core/odp/odp_segment_manager'; +import { IOdpSegmentManager, OdpSegmentManager } from '../../core/odp/odp_segment_manager'; import { OdpSegmentApiManager } from '../../core/odp/odp_segment_api_manager'; +import { OdpConfig, OdpIntegrationConfig } from '../../core/odp/odp_config'; interface BrowserOdpManagerConfig { + clientEngine?: string, + clientVersion?: string, logger?: LogHandler; odpOptions?: OdpOptions; + odpIntegrationConfig?: OdpIntegrationConfig; } // Client-side Browser Plugin for ODP Manager export class BrowserOdpManager extends OdpManager { static cache = new BrowserAsyncStorageCache(); + vuidManager?: VuidManager; vuid?: string; - constructor({ logger, odpOptions }: BrowserOdpManagerConfig) { - super(); + constructor(options: { + odpIntegrationConfig?: OdpIntegrationConfig; + segmentManager: IOdpSegmentManager; + eventManager: IOdpEventManager; + logger: LogHandler; + }) { + super(options); + } - this.logger = logger || getLogger(); + static createInstance({ + logger, odpOptions, odpIntegrationConfig, clientEngine, clientVersion + }: BrowserOdpManagerConfig): BrowserOdpManager { + logger = logger || getLogger(); - if (odpOptions?.disabled) { - this.initPromise = Promise.resolve(); - this.enabled = false; - this.logger.log(LogLevel.INFO, LOG_MESSAGES.ODP_DISABLED); - return; - } + clientEngine = clientEngine || JAVASCRIPT_CLIENT_ENGINE; + clientVersion = clientVersion || CLIENT_VERSION; - const browserClientEngine = JAVASCRIPT_CLIENT_ENGINE; - const browserClientVersion = CLIENT_VERSION; + let odpConfig : OdpConfig | undefined = undefined; + if (odpIntegrationConfig?.integrated) { + odpConfig = odpIntegrationConfig.odpConfig; + } let customSegmentRequestHandler; @@ -72,24 +84,25 @@ export class BrowserOdpManager extends OdpManager { customSegmentRequestHandler = odpOptions.segmentsRequestHandler; } else { customSegmentRequestHandler = new BrowserRequestHandler( - this.logger, + logger, odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS ); } - // Set up Segment Manager (Audience Segments GraphQL API Interface) + let segmentManager: IOdpSegmentManager; + if (odpOptions?.segmentManager) { - this.segmentManager = odpOptions.segmentManager; - this.segmentManager.updateSettings(this.odpConfig); + segmentManager = odpOptions.segmentManager; } else { - this.segmentManager = new OdpSegmentManager( - this.odpConfig, + segmentManager = new OdpSegmentManager( odpOptions?.segmentsCache || new BrowserLRUCache({ maxSize: odpOptions?.segmentsCacheSize, timeout: odpOptions?.segmentsCacheTimeout, }), - new OdpSegmentApiManager(customSegmentRequestHandler, this.logger) + new OdpSegmentApiManager(customSegmentRequestHandler, logger), + logger, + odpConfig ); } @@ -99,22 +112,22 @@ export class BrowserOdpManager extends OdpManager { customEventRequestHandler = odpOptions.eventRequestHandler; } else { customEventRequestHandler = new BrowserRequestHandler( - this.logger, + logger, odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS ); } - // Set up Events Manager (Events REST API Interface) + let eventManager: IOdpEventManager; + if (odpOptions?.eventManager) { - this.eventManager = odpOptions.eventManager; - this.eventManager.updateSettings(this.odpConfig); + eventManager = odpOptions.eventManager; } else { - this.eventManager = new BrowserOdpEventManager({ - odpConfig: this.odpConfig, - apiManager: new BrowserOdpEventApiManager(customEventRequestHandler, this.logger), - logger: this.logger, - clientEngine: browserClientEngine, - clientVersion: browserClientVersion, + eventManager = new BrowserOdpEventManager({ + odpConfig, + apiManager: new BrowserOdpEventApiManager(customEventRequestHandler, logger), + logger: logger, + clientEngine, + clientVersion, flushInterval: odpOptions?.eventFlushInterval, batchSize: odpOptions?.eventBatchSize, queueSize: odpOptions?.eventQueueSize, @@ -122,34 +135,21 @@ export class BrowserOdpManager extends OdpManager { }); } - this.eventManager!.start(); - - this.initPromise = this.initializeVuid(BrowserOdpManager.cache).catch(e => { - this.logger.log(this.enabled ? LogLevel.ERROR : LogLevel.DEBUG, e); + return new BrowserOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, }); } /** - * Upon initializing BrowserOdpManager, accesses or creates new VUID from Browser cache and registers it via the Event Manager - * @private + * @override + * accesses or creates new VUID from Browser cache */ - private async initializeVuid(cache: PersistentKeyValueCache): Promise { - const vuidManager = await VuidManager.instance(cache); + protected async initializeVuid(): Promise { + const vuidManager = await VuidManager.instance(BrowserOdpManager.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); - } } /** diff --git a/lib/plugins/odp_manager/index.node.ts b/lib/plugins/odp_manager/index.node.ts index 4f01ededc..bdd57f1ad 100644 --- a/lib/plugins/odp_manager/index.node.ts +++ b/lib/plugins/odp_manager/index.node.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import { ServerLRUCache } from './../../utils/lru_cache/server_lru_cache'; import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; import { - LOG_MESSAGES, NODE_CLIENT_ENGINE, CLIENT_VERSION, REQUEST_TIMEOUT_ODP_EVENTS_MS, @@ -28,15 +27,19 @@ import { } from '../../utils/enums'; import { OdpManager } from '../../core/odp/odp_manager'; -import { OdpOptions } from '../../shared_types'; +import { IOdpEventManager, OdpOptions } from '../../shared_types'; import { NodeOdpEventApiManager } from '../odp/event_api_manager/index.node'; import { NodeOdpEventManager } from '../odp/event_manager/index.node'; -import { OdpSegmentManager } from '../../core/odp/odp_segment_manager'; +import { IOdpSegmentManager, OdpSegmentManager } from '../../core/odp/odp_segment_manager'; import { OdpSegmentApiManager } from '../../core/odp/odp_segment_api_manager'; +import { OdpConfig, OdpIntegrationConfig } from '../../core/odp/odp_config'; interface NodeOdpManagerConfig { + clientEngine?: string, + clientVersion?: string, logger?: LogHandler; odpOptions?: OdpOptions; + odpIntegrationConfig?: OdpIntegrationConfig; } /** @@ -44,20 +47,27 @@ interface NodeOdpManagerConfig { * Note: As this is still a work-in-progress. Please avoid using the Node ODP Manager. */ export class NodeOdpManager extends OdpManager { - constructor({ logger, odpOptions }: NodeOdpManagerConfig) { - super(); + constructor(options: { + odpIntegrationConfig?: OdpIntegrationConfig; + segmentManager: IOdpSegmentManager; + eventManager: IOdpEventManager; + logger: LogHandler; + }) { + super(options); + } - this.logger = logger || getLogger(); + static createInstance({ + logger, odpOptions, odpIntegrationConfig, clientEngine, clientVersion + }: NodeOdpManagerConfig): NodeOdpManager { + logger = logger || getLogger(); - if (odpOptions?.disabled) { - this.initPromise = Promise.resolve(); - this.enabled = false; - this.logger.log(LogLevel.INFO, LOG_MESSAGES.ODP_DISABLED); - return; - } + clientEngine = clientEngine || NODE_CLIENT_ENGINE; + clientVersion = clientVersion || CLIENT_VERSION; - const nodeClientEngine = NODE_CLIENT_ENGINE; - const nodeClientVersion = CLIENT_VERSION; + let odpConfig : OdpConfig | undefined = undefined; + if (odpIntegrationConfig?.integrated) { + odpConfig = odpIntegrationConfig.odpConfig; + } let customSegmentRequestHandler; @@ -65,24 +75,25 @@ export class NodeOdpManager extends OdpManager { customSegmentRequestHandler = odpOptions.segmentsRequestHandler; } else { customSegmentRequestHandler = new NodeRequestHandler( - this.logger, + logger, odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS ); } - // Set up Segment Manager (Audience Segments GraphQL API Interface) + let segmentManager: IOdpSegmentManager; + if (odpOptions?.segmentManager) { - this.segmentManager = odpOptions.segmentManager; - this.segmentManager.updateSettings(this.odpConfig); + segmentManager = odpOptions.segmentManager; } else { - this.segmentManager = new OdpSegmentManager( - this.odpConfig, + segmentManager = new OdpSegmentManager( odpOptions?.segmentsCache || new ServerLRUCache({ maxSize: odpOptions?.segmentsCacheSize, timeout: odpOptions?.segmentsCacheTimeout, }), - new OdpSegmentApiManager(customSegmentRequestHandler, this.logger) + new OdpSegmentApiManager(customSegmentRequestHandler, logger), + logger, + odpConfig ); } @@ -92,31 +103,35 @@ export class NodeOdpManager extends OdpManager { customEventRequestHandler = odpOptions.eventRequestHandler; } else { customEventRequestHandler = new NodeRequestHandler( - this.logger, + logger, odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS ); } - // Set up Events Manager (Events REST API Interface) + let eventManager: IOdpEventManager; + if (odpOptions?.eventManager) { - this.eventManager = odpOptions.eventManager; - this.eventManager.updateSettings(this.odpConfig); + eventManager = odpOptions.eventManager; } else { - this.eventManager = new NodeOdpEventManager({ - odpConfig: this.odpConfig, - apiManager: new NodeOdpEventApiManager(customEventRequestHandler, this.logger), - logger: this.logger, - clientEngine: nodeClientEngine, - clientVersion: nodeClientVersion, + eventManager = new NodeOdpEventManager({ + odpConfig, + apiManager: new NodeOdpEventApiManager(customEventRequestHandler, logger), + logger: logger, + clientEngine, + clientVersion, flushInterval: odpOptions?.eventFlushInterval, batchSize: odpOptions?.eventBatchSize, queueSize: odpOptions?.eventQueueSize, + userAgentParser: odpOptions?.userAgentParser, }); } - this.eventManager.start(); - - this.initPromise = Promise.resolve(); + return new NodeOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + }); } public isVuidEnabled(): boolean { diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 4af80fa13..361f293d5 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -37,6 +37,7 @@ import { IOdpEventManager } from './core/odp/odp_event_manager'; import { IOdpManager } from './core/odp/odp_manager'; import { IUserAgentParser } from './core/odp/user_agent_parser'; import PersistentCache from './plugins/key_value_cache/persistentKeyValueCache'; +import { ProjectConfig } from './core/project_config'; export interface BucketerParams { experimentId: string; @@ -369,6 +370,7 @@ export interface Client { onReady(options?: { timeout?: number }): Promise<{ success: boolean; reason?: string }>; close(): Promise<{ success: boolean; reason?: string }>; sendOdpEvent(action: string, type?: string, identifiers?: Map, data?: Map): void; + getProjectConfig(): ProjectConfig | null; } export interface ActivateListenerPayload extends ListenerPayload { diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index 95fe4f4ed..3f7bee937 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -31,7 +31,6 @@ export const ERROR_MESSAGES = { DATAFILE_AND_SDK_KEY_MISSING: '%s: You must provide at least one of sdkKey or datafile. Cannot start Optimizely', 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.', @@ -56,6 +55,7 @@ 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_CONFIG_NOT_AVAILABLE: '%s: ODP is not integrated to the project.', ODP_EVENT_FAILED: 'ODP event send failed.', ODP_FETCH_QUALIFIED_SEGMENTS_SEGMENTS_MANAGER_MISSING: '%s: ODP unable to fetch qualified segments (Segments Manager not initialized).', @@ -79,8 +79,6 @@ export const ERROR_MESSAGES = { '%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).', diff --git a/lib/utils/promise/resolvablePromise.ts b/lib/utils/promise/resolvablePromise.ts new file mode 100644 index 000000000..354df2b7d --- /dev/null +++ b/lib/utils/promise/resolvablePromise.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2024, 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. + */ + +const noop = () => {}; + +export type ResolvablePromise = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; + then: Promise['then']; +}; + +export function resolvablePromise(): ResolvablePromise { + let resolve: (value: T | PromiseLike) => void = noop; + let reject: (reason?: any) => void = noop; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject, then: promise.then.bind(promise) }; +} diff --git a/tests/odpEventApiManager.spec.ts b/tests/odpEventApiManager.spec.ts index bfe813af6..c989b76a6 100644 --- a/tests/odpEventApiManager.spec.ts +++ b/tests/odpEventApiManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,7 +57,6 @@ describe('NodeOdpEventApiManager', () => { const managerInstance = () => { const manager = new NodeOdpEventApiManager(instance(mockRequestHandler), instance(mockLogger)); - manager.updateSettings(odpConfig); return manager; } @@ -78,7 +77,7 @@ describe('NodeOdpEventApiManager', () => { ); const manager = managerInstance(); - const shouldRetry = await manager.sendEvents(ODP_EVENTS); + const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); expect(shouldRetry).toBe(false); verify(mockLogger.log(anything(), anyString())).never(); @@ -90,7 +89,7 @@ describe('NodeOdpEventApiManager', () => { ); const manager = managerInstance(); - const shouldRetry = await manager.sendEvents(ODP_EVENTS); + const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); expect(shouldRetry).toBe(false); verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (400)')).once(); @@ -102,7 +101,7 @@ describe('NodeOdpEventApiManager', () => { ); const manager = managerInstance(); - const shouldRetry = await manager.sendEvents(ODP_EVENTS); + const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); expect(shouldRetry).toBe(true); verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (500)')).once(); @@ -115,13 +114,13 @@ describe('NodeOdpEventApiManager', () => { }); const manager = managerInstance(); - const shouldRetry = await manager.sendEvents(ODP_EVENTS); + const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); expect(shouldRetry).toBe(true); verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (Request timed out)')).once(); }); - it('should send events to updated host on settings update', async () => { + it('should send events to the correct host using correct api key', async () => { when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ abort: () => {}, responsePromise: Promise.reject(new Error('Request timed out')), @@ -129,24 +128,12 @@ describe('NodeOdpEventApiManager', () => { const manager = managerInstance(); - await manager.sendEvents(ODP_EVENTS); + await manager.sendEvents(odpConfig, ODP_EVENTS); - const updatedOdpConfig = new OdpConfig( - 'updated-key', - 'https://updatedhost.test', - 'https://updatedpixel.test', - ['updated-seg'], - ) + verify(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).once(); - manager.updateSettings(updatedOdpConfig); - await manager.sendEvents(ODP_EVENTS); - - verify(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).twice(); - - const [initUrl] = capture(mockRequestHandler.makeRequest).first(); + const [initUrl, headers] = capture(mockRequestHandler.makeRequest).first(); expect(initUrl).toEqual(`${API_HOST}/v3/events`); - - const [finalUrl] = capture(mockRequestHandler.makeRequest).last(); - expect(finalUrl).toEqual(`${updatedOdpConfig.apiHost}/v3/events`); + expect(headers['x-api-key']).toEqual(odpConfig.apiKey); }); }); diff --git a/tests/odpEventManager.spec.ts b/tests/odpEventManager.spec.ts index 59a3d7669..31bc7a753 100644 --- a/tests/odpEventManager.spec.ts +++ b/tests/odpEventManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,19 @@ * limitations under the License. */ -import { ODP_EVENT_ACTION, ODP_DEFAULT_EVENT_TYPE } from '../lib/utils/enums'; +import { ODP_EVENT_ACTION, ODP_DEFAULT_EVENT_TYPE, ERROR_MESSAGES } from '../lib/utils/enums'; import { OdpConfig } from '../lib/core/odp/odp_config'; -import { STATE } from '../lib/core/odp/odp_event_manager'; +import { Status } from '../lib/core/odp/odp_event_manager'; import { BrowserOdpEventManager } from "../lib/plugins/odp/event_manager/index.browser"; -import { NodeOdpEventManager, NodeOdpEventManager as OdpEventManager } from '../lib/plugins/odp/event_manager/index.node'; +import { NodeOdpEventManager } from '../lib/plugins/odp/event_manager/index.node'; +import { OdpEventManager } from '../lib/core/odp/odp_event_manager'; import { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; import { IOdpEventApiManager } from '../lib/core/odp/odp_event_api_manager'; import { LogHandler, LogLevel } from '../lib/modules/logging'; import { OdpEvent } from '../lib/core/odp/odp_event'; import { IUserAgentParser } from '../lib/core/odp/user_agent_parser'; import { UserAgentInfo } from '../lib/core/odp/user_agent_info'; +import exp from 'constants'; const API_KEY = 'test-api-key'; const API_HOST = 'https://odp.example.com'; @@ -135,6 +137,20 @@ const abortableRequest = (statusCode: number, body: string) => { }; }; +class TestOdpEventManager extends OdpEventManager { + constructor(options: any) { + super(options); + } + protected initParams(batchSize: number, queueSize: number, flushInterval: number): void { + this.queueSize = queueSize; + this.batchSize = batchSize; + this.flushInterval = flushInterval; + } + protected discardEventsIfNeeded(): void { + } + protected hasNecessaryIdentifiers = (event: OdpEvent): boolean => event.identifiers.size >= 0; +} + describe('OdpEventManager', () => { let mockLogger: LogHandler; let mockApiManager: IOdpEventApiManager; @@ -152,35 +168,27 @@ describe('OdpEventManager', () => { }); beforeEach(() => { + jest.useFakeTimers(); resetCalls(mockLogger); resetCalls(mockApiManager); }); - it('should update api manager setting with odp config on instantiation', () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); - when(mockApiManager.updateSettings(anything())).thenReturn(undefined); - - const apiManager = instance(mockApiManager); - - const eventManager = new OdpEventManager({ - odpConfig, + it('should log an error and not start if start() is called without a config', () => { + const eventManager = new TestOdpEventManager({ + odpConfig: undefined, apiManager, logger, clientEngine, clientVersion, }); - const [passedConfig] = capture(mockApiManager.updateSettings).last(); - expect(passedConfig).toEqual(odpConfig); + eventManager.start(); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).once(); + expect(eventManager.status).toEqual(Status.Stopped); }); - it('should update api manager setting with updatetd odp config on updateSettings', () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); - when(mockApiManager.updateSettings(anything())).thenReturn(undefined); - - const apiManager = instance(mockApiManager); - - const eventManager = new OdpEventManager({ + it('should start() correctly after odpConfig is provided', () => { + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -188,67 +196,39 @@ describe('OdpEventManager', () => { clientVersion, }); - const updatedOdpConfig = new OdpConfig( - 'updated-key', - 'https://updatedhost.test', - 'https://pixel.test', - ['updated-seg'], - ) - - eventManager.updateSettings(updatedOdpConfig); - - verify(mockApiManager.updateSettings(anything())).twice(); - - const [initConfig] = capture(mockApiManager.updateSettings).first(); - expect(initConfig).toEqual(odpConfig); - - const [finalConfig] = capture(mockApiManager.updateSettings).last(); - expect(finalConfig).toEqual(updatedOdpConfig); + expect(eventManager.status).toEqual(Status.Stopped); + eventManager.updateSettings(odpConfig); + eventManager.start(); + expect(eventManager.status).toEqual(Status.Running); }); - it('should log and discard events when event manager not running', () => { - const eventManager = new OdpEventManager({ + it('should log and discard events when event manager is not running', () => { + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, }); - // since we've not called start() then... + expect(eventManager.status).toEqual(Status.Stopped); eventManager.sendEvent(EVENTS[0]); - - // ...we should get a notice after trying to send an event verify(mockLogger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.')).once(); + expect(eventManager.getQueue().length).toEqual(0); }); - it('should log and discard events when event manager config is not ready', () => { - const mockOdpConfig = mock(); - when(mockOdpConfig.isReady()).thenReturn(false); - const odpConfig = instance(mockOdpConfig); - const eventManager = new OdpEventManager({ + it('should discard events with invalid data', () => { + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, }); - eventManager['state'] = STATE.RUNNING; // simulate running without calling start() - - eventManager.sendEvent(EVENTS[0]); + eventManager.start(); - // In a Node context, the events should be discarded - verify(mockLogger.log(LogLevel.WARNING, 'ODPConfig not ready. Discarding events in queue.')).once(); - }); + expect(eventManager.status).toEqual(Status.Running); - it('should discard events with invalid data', () => { - const eventManager = new OdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); // make an event with invalid data key-value entry const badEvent = new OdpEvent( 't3', @@ -262,11 +242,12 @@ describe('OdpEventManager', () => { eventManager.sendEvent(badEvent); verify(mockLogger.log(LogLevel.ERROR, 'Event data found to be invalid.')).once(); + expect(eventManager.getQueue().length).toEqual(0); }); it('should log a max queue hit and discard ', () => { // set queue to maximum of 1 - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -274,9 +255,10 @@ describe('OdpEventManager', () => { clientVersion, queueSize: 1, // With max queue size set to 1... }); - eventManager['state'] = STATE.RUNNING; - eventManager['queue'].push(EVENTS[0]); // simulate 1 event already in the queue then... + eventManager.start(); + + eventManager['queue'].push(EVENTS[0]); // simulate 1 event already in the queue then... // ...try adding the second event eventManager.sendEvent(EVENTS[1]); @@ -286,13 +268,15 @@ describe('OdpEventManager', () => { }); it('should add additional information to each event', () => { - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, }); + eventManager.start(); + const processedEventData = PROCESSED_EVENTS[0].data; const eventData = eventManager['augmentCommonData'](EVENTS[0].data); @@ -309,28 +293,56 @@ describe('OdpEventManager', () => { expect(eventData.get('key-4')).toEqual(processedEventData.get('key-4')); }); - it('should attempt to flush an empty queue at flush intervals', async () => { - const eventManager = new OdpEventManager({ + it('should attempt to flush an empty queue at flush intervals if batchSize is greater than 1', async () => { + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, + batchSize: 10, flushInterval: 100, }); - const spiedEventManager = spy(eventManager); + + //@ts-ignore + const processQueueSpy = jest.spyOn(eventManager, 'processQueue'); eventManager.start(); // do not add events to the queue, but allow for... - await pause(400); // at least 3 flush intervals executions (giving a little longer) + jest.advanceTimersByTime(350); // 3 flush intervals executions (giving a little longer) + + expect(processQueueSpy).toHaveBeenCalledTimes(3); + }); - verify(spiedEventManager['processQueue'](anything())).atLeast(3); + + it('should not flush periodically if batch size is 1', async () => { + const eventManager = new TestOdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 1, + flushInterval: 100, + }); + + //@ts-ignore + const processQueueSpy = jest.spyOn(eventManager, 'processQueue'); + + eventManager.start(); + eventManager.sendEvent(EVENTS[0]); + eventManager.sendEvent(EVENTS[1]); + + jest.advanceTimersByTime(350); // 3 flush intervals executions (giving a little longer) + + expect(processQueueSpy).toHaveBeenCalledTimes(2); }); - it('should dispatch events in correct number of batches', async () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); + it('should dispatch events in correct batch sizes', async () => { + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); + const apiManager = instance(mockApiManager); - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -341,18 +353,24 @@ describe('OdpEventManager', () => { }); eventManager.start(); + for (let i = 0; i < 25; i += 1) { eventManager.sendEvent(makeEvent(i)); } - await pause(1500); + jest.runAllTicks(); + // as we are not advancing the jest fake timers, no flush should occur // ...there should be 3 batches: // batch #1 with 10, batch #2 with 10, and batch #3 (after flushInterval lapsed) with 5 = 25 events - verify(mockApiManager.sendEvents(anything())).thrice(); + verify(mockApiManager.sendEvents(anything(), anything())).twice(); + + // rest of the events should now be flushed + jest.advanceTimersByTime(250); + verify(mockApiManager.sendEvents(anything(), anything())).thrice(); }); it('should dispatch events with correct payload', async () => { - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -364,11 +382,11 @@ describe('OdpEventManager', () => { eventManager.start(); EVENTS.forEach(event => eventManager.sendEvent(event)); - await pause(1000); + jest.advanceTimersByTime(100); // sending 1 batch of 2 events after flushInterval since batchSize is 10 - verify(mockApiManager.sendEvents(anything())).once(); - const [events] = capture(mockApiManager.sendEvents).last(); + verify(mockApiManager.sendEvents(anything(), anything())).once(); + const [_, events] = capture(mockApiManager.sendEvents).last(); expect(events.length).toEqual(2); expect(events[0].identifiers.size).toEqual(PROCESSED_EVENTS[0].identifiers.size); expect(events[0].data.size).toEqual(PROCESSED_EVENTS[0].data.size); @@ -376,6 +394,28 @@ describe('OdpEventManager', () => { expect(events[1].data.size).toEqual(PROCESSED_EVENTS[1].data.size); }); + it('should dispatch events with correct odpConfig', async () => { + const eventManager = new TestOdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 10, + flushInterval: 100, + }); + + eventManager.start(); + EVENTS.forEach(event => eventManager.sendEvent(event)); + + jest.advanceTimersByTime(100); + + // sending 1 batch of 2 events after flushInterval since batchSize is 10 + verify(mockApiManager.sendEvents(anything(), anything())).once(); + const [usedOdpConfig] = capture(mockApiManager.sendEvents).last(); + expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); + }); + it('should augment events with data from user agent parser', async () => { const userAgentParser : IUserAgentParser = { parseUserAgentInfo: function (): UserAgentInfo { @@ -386,7 +426,7 @@ describe('OdpEventManager', () => { } } - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -399,10 +439,10 @@ describe('OdpEventManager', () => { eventManager.start(); EVENTS.forEach(event => eventManager.sendEvent(event)); - await pause(1000); + jest.advanceTimersByTime(100); - verify(mockApiManager.sendEvents(anything())).called(); - const [events] = capture(mockApiManager.sendEvents).last(); + verify(mockApiManager.sendEvents(anything(), anything())).called(); + const [_, events] = capture(mockApiManager.sendEvents).last(); const event = events[0]; expect(event.data.get('os')).toEqual('windows'); @@ -412,62 +452,175 @@ describe('OdpEventManager', () => { }); it('should retry failed events', async () => { - // all events should fail ie shouldRetry = true - when(mockApiManager.sendEvents(anything())).thenResolve(true); + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(true) + + const retries = 3; const apiManager = instance(mockApiManager); - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, - batchSize: 2, // batch size of 2 + batchSize: 2, flushInterval: 100, + retries, }); eventManager.start(); - // send 4 events for (let i = 0; i < 4; i += 1) { eventManager.sendEvent(makeEvent(i)); } - await pause(1500); - // retry 3x (default) for 2 batches or 6 calls to attempt to process - verify(mockApiManager.sendEvents(anything())).times(6); + jest.runAllTicks(); + jest.useRealTimers(); + await pause(100); + + // retry 3x for 2 batches or 6 calls to attempt to process + verify(mockApiManager.sendEvents(anything(), anything())).times(6); + }); + + it('should flush all queued events when flush() is called', async () => { + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); + + const apiManager = instance(mockApiManager); + const eventManager = new TestOdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 200, + flushInterval: 100, + }); + + eventManager.start(); + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + + expect(eventManager.getQueue().length).toEqual(25); + + eventManager.flush(); + + jest.runAllTicks(); + + verify(mockApiManager.sendEvents(anything(), anything())).once(); + expect(eventManager.getQueue().length).toEqual(0); + }); + + it('should flush all queued events before stopping', async () => { + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); + const apiManager = instance(mockApiManager); + const eventManager = new TestOdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 200, + flushInterval: 100, + }); + + eventManager.start(); + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + + expect(eventManager.getQueue().length).toEqual(25); + + eventManager.flush(); + + jest.runAllTicks(); + + verify(mockApiManager.sendEvents(anything(), anything())).once(); + expect(eventManager.getQueue().length).toEqual(0); + }); + + it('should flush all queued events using the old odpConfig when updateSettings is called()', async () => { + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); + + const odpConfig = new OdpConfig('old-key', 'old-host', 'https://new-odp.pixel.com', []); + const updatedConfig = new OdpConfig('new-key', 'new-host', 'https://new-odp.pixel.com', []); + + const apiManager = instance(mockApiManager); + const eventManager = new TestOdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 200, + flushInterval: 100, + }); + + eventManager.start(); + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + + expect(eventManager.getQueue().length).toEqual(25); + + eventManager.updateSettings(updatedConfig); + + jest.runAllTicks(); + + verify(mockApiManager.sendEvents(anything(), anything())).once(); + expect(eventManager.getQueue().length).toEqual(0); + const [usedOdpConfig] = capture(mockApiManager.sendEvents).last(); + expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); }); - it('should flush all scheduled events before stopping', async () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); + it('should use updated odpConfig to send events', async () => { + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); + + const odpConfig = new OdpConfig('old-key', 'old-host', 'https://new-odp.pixel.com', []); + const updatedConfig = new OdpConfig('new-key', 'new-host', 'https://new-odp.pixel.com', []); + const apiManager = instance(mockApiManager); - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, clientEngine, clientVersion, - batchSize: 2, // batches of 2 with... + batchSize: 200, flushInterval: 100, }); eventManager.start(); - // ...25 events should... for (let i = 0; i < 25; i += 1) { eventManager.sendEvent(makeEvent(i)); } - await pause(300); - await eventManager.stop(); - verify(mockLogger.log(LogLevel.DEBUG, 'Stop requested.')).once(); - verify(mockLogger.log(LogLevel.DEBUG, 'Stopped. Queue Count: %s', 0)).once(); + expect(eventManager.getQueue().length).toEqual(25); + + jest.advanceTimersByTime(100); + + expect(eventManager.getQueue().length).toEqual(0); + let [usedOdpConfig] = capture(mockApiManager.sendEvents).first(); + expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); + + eventManager.updateSettings(updatedConfig); + jest.runAllTicks(); + + + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + jest.advanceTimersByTime(100); + + expect(eventManager.getQueue().length).toEqual(0); + ([usedOdpConfig] = capture(mockApiManager.sendEvents).last()); + expect(usedOdpConfig.equals(updatedConfig)).toBeTruthy(); }); it('should prepare correct payload for register VUID', async () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); - when(mockApiManager.updateSettings(anything())).thenReturn(undefined); + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); const apiManager = instance(mockApiManager); - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -482,9 +635,10 @@ describe('OdpEventManager', () => { eventManager.start(); eventManager.registerVuid(vuid); - await pause(1500); - const [events] = capture(mockApiManager.sendEvents).last(); + jest.advanceTimersByTime(250); + + const [_, events] = capture(mockApiManager.sendEvents).last(); expect(events.length).toBe(1); const [event] = events; @@ -498,12 +652,11 @@ describe('OdpEventManager', () => { }); it('should send correct event payload for identify user', async () => { - when(mockApiManager.sendEvents(anything())).thenResolve(false); - when(mockApiManager.updateSettings(anything())).thenReturn(undefined); + when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); const apiManager = instance(mockApiManager); - const eventManager = new OdpEventManager({ + const eventManager = new TestOdpEventManager({ odpConfig, apiManager, logger, @@ -518,9 +671,10 @@ describe('OdpEventManager', () => { eventManager.start(); eventManager.identifyUser(fsUserId, vuid); - await pause(1500); - const [events] = capture(mockApiManager.sendEvents).last(); + jest.advanceTimersByTime(250); + + const [_, events] = capture(mockApiManager.sendEvents).last(); expect(events.length).toBe(1); const [event] = events; @@ -533,29 +687,6 @@ describe('OdpEventManager', () => { expect(event.data.get("data_source_version") as string).not.toBeNull(); }); - it('should apply updated ODP configuration when available', () => { - const eventManager = new OdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - const apiKey = 'testing-api-key'; - const apiHost = 'https://some.other.example.com'; - const pixelUrl = 'https://some.other.pixel.com'; - const segmentsToCheck = ['empty-cart', '1-item-cart']; - const differentOdpConfig = new OdpConfig(apiKey, apiHost, pixelUrl, segmentsToCheck); - - eventManager.updateSettings(differentOdpConfig); - - expect(eventManager['odpConfig'].apiKey).toEqual(apiKey); - expect(eventManager['odpConfig'].apiHost).toEqual(apiHost); - expect(eventManager['odpConfig'].pixelUrl).toEqual(pixelUrl); - expect(eventManager['odpConfig'].segmentsToCheck).toContain(Array.from(segmentsToCheck)[0]); - expect(eventManager['odpConfig'].segmentsToCheck).toContain(Array.from(segmentsToCheck)[1]); - }); - it('should error when no identifiers are provided in Node', () => { const eventManager = new NodeOdpEventManager({ odpConfig, @@ -570,6 +701,8 @@ describe('OdpEventManager', () => { eventManager.sendEvent(EVENT_WITH_UNDEFINED_IDENTIFIER); eventManager.stop(); + jest.runAllTicks(); + verify(mockLogger.log(LogLevel.ERROR, 'ODP events should have at least one key-value pair in identifiers.')).twice(); }); @@ -587,6 +720,8 @@ describe('OdpEventManager', () => { eventManager.sendEvent(EVENT_WITH_UNDEFINED_IDENTIFIER); eventManager.stop(); + jest.runAllTicks(); + verify(mockLogger.log(LogLevel.ERROR, 'ODP events should have at least one key-value pair in identifiers.')).never(); }); }); diff --git a/tests/odpManager.browser.spec.ts b/tests/odpManager.browser.spec.ts index ade9d48ce..b9ecb76f0 100644 --- a/tests/odpManager.browser.spec.ts +++ b/tests/odpManager.browser.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,6 @@ import { BrowserOdpManager } from './../lib/plugins/odp_manager/index.browser'; import { IOdpEventManager, OdpOptions } from './../lib/shared_types'; import { OdpConfig } from '../lib/core/odp/odp_config'; import { BrowserOdpEventApiManager } from '../lib/plugins/odp/event_api_manager/index.browser'; -import { BrowserOdpEventManager } from '../lib/plugins/odp/event_manager/index.browser'; 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'; @@ -35,6 +34,9 @@ import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser import { IUserAgentParser } from '../lib/core/odp/user_agent_parser'; import { UserAgentInfo } from '../lib/core/odp/user_agent_info'; import { OdpEvent } from '../lib/core/odp/odp_event'; +import { LRUCache } from '../lib/utils/lru_cache'; +import { BrowserOdpEventManager } from '../lib/plugins/odp/event_manager/index.browser'; +import { OdpManager } from '../lib/core/odp/odp_manager'; const keyA = 'key-a'; const hostA = 'host-a'; @@ -80,7 +82,7 @@ describe('OdpManager', () => { mockLogger = mock(); mockRequestHandler = mock(); - odpConfig = new OdpConfig(); + odpConfig = new OdpConfig(keyA, hostA, pixelA, segmentsA); fakeLogger = instance(mockLogger); fakeRequestHandler = instance(mockRequestHandler); @@ -106,181 +108,22 @@ describe('OdpManager', () => { }); const browserOdpManagerInstance = () => - new BrowserOdpManager({ + BrowserOdpManager.createInstance({ odpOptions: { eventManager: fakeEventManager, segmentManager: fakeSegmentManager, }, }); - it('should register VUID automatically on BrowserOdpManager initialization', async () => { + it('should create 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({ logger: fakeLogger, odpOptions: { disabled: true } }); - - verify(mockLogger.log(LogLevel.INFO, LOG_MESSAGES.ODP_DISABLED)).once(); - - browserOdpManager.updateSettings(new OdpConfig('valid', 'host', 'pixel-url', [])); - expect(browserOdpManager.odpConfig).toBeUndefined; - - await browserOdpManager.fetchQualifiedSegments('vuid_user1', []); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); - - await 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({ - odpOptions: { - eventManager: fakeEventManager, - }, - }); - - 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); - - await 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); - - await 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({ - odpOptions: { - segmentManager: new OdpSegmentManager( - odpConfig, - new BrowserLRUCache(), - fakeSegmentApiManager - ), - }, - }); - - const didUpdateA = browserOdpManager.updateSettings(new OdpConfig(keyA, hostA, pixelA, segmentsA)); - expect(didUpdateA).toBe(true); - - browserOdpManager.fetchQualifiedSegments(vuidA); - - verify( - mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_FETCH_QUALIFIED_SEGMENTS_SEGMENTS_MANAGER_MISSING) - ).never(); - - 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, pixelB, 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({}); - expect(browserOdpManagerB.eventManager).not.toBe(null); - }); - - it('should get segment manager', () => { - const browserOdpManagerA = browserOdpManagerInstance(); - expect(browserOdpManagerA.segmentManager).not.toBe(null); - - const browserOdpManagerB = new BrowserOdpManager({}); - expect(browserOdpManagerB.eventManager).not.toBe(null); - }); - - // it("should call event manager's sendEvent if ODP Event is valid", async () => { - // const browserOdpManager = new BrowserOdpManager({ - // odpOptions: { - // eventManager: fakeEventManager, - // }, - // }); - - // const odpConfig = new OdpConfig('key', 'host', []); - - // browserOdpManager.updateSettings(odpConfig); - - // // Test Valid OdpEvent - calls event manager with valid OdpEvent object - // const validIdentifiers = new Map(); - // validIdentifiers.set('vuid', vuidA); - - // const validOdpEvent = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.INITIALIZED, validIdentifiers); - - // await browserOdpManager.sendEvent(validOdpEvent); - // verify(mockEventManager.sendEvent(anything())).once(); - - // // Test Invalid OdpEvents - logs error and short circuits - // // Does not include `vuid` in identifiers does not have a local this.vuid populated in BrowserOdpManager - // browserOdpManager.vuid = undefined; - // const invalidOdpEvent = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.INITIALIZED, undefined); - - // await expect(browserOdpManager.sendEvent(invalidOdpEvent)).rejects.toThrow( - // ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_VUID_MISSING - // ); - // }); - describe('Populates BrowserOdpManager correctly with all odpOptions', () => { - it('odpOptions.disabled = true disables BrowserOdpManager', () => { - const odpOptions: OdpOptions = { - disabled: true, - }; + beforeAll(() => { - const browserOdpManager = new BrowserOdpManager({ - odpOptions, - }); - - expect(browserOdpManager.enabled).toBe(false); }); it('Custom odpOptions.segmentsCache overrides default LRUCache', () => { @@ -289,17 +132,19 @@ describe('OdpManager', () => { maxSize: 2, timeout: 4000, }), - }; + }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); + const segmentManager = browserOdpManager['segmentManager'] as OdpSegmentManager; + // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.maxSize).toBe(2); + expect(browserOdpManager.segmentManager._segmentsCache.maxSize).toBe(2); // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.timeout).toBe(4000); + expect(browserOdpManager.segmentManager._segmentsCache.timeout).toBe(4000); }); it('Custom odpOptions.segmentsCacheSize overrides default LRUCache size', () => { @@ -307,12 +152,12 @@ describe('OdpManager', () => { segmentsCacheSize: 2, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.maxSize).toBe(2); + expect(browserOdpManager.segmentManager._segmentsCache.maxSize).toBe(2); }); it('Custom odpOptions.segmentsCacheTimeout overrides default LRUCache timeout', () => { @@ -320,12 +165,12 @@ describe('OdpManager', () => { segmentsCacheTimeout: 4000, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.timeout).toBe(4000); + expect(browserOdpManager.segmentManager._segmentsCache.timeout).toBe(4000); }); it('Custom odpOptions.segmentsApiTimeout overrides default Segment API Request Handler timeout', () => { @@ -333,7 +178,7 @@ describe('OdpManager', () => { segmentsApiTimeout: 4000, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -342,7 +187,7 @@ describe('OdpManager', () => { }); it('Browser default Segments API Request Handler timeout should be used when odpOptions does not include segmentsApiTimeout', () => { - const browserOdpManager = new BrowserOdpManager({}); + const browserOdpManager = BrowserOdpManager.createInstance({}); // @ts-ignore expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(10000); @@ -353,7 +198,7 @@ describe('OdpManager', () => { segmentsRequestHandler: new BrowserRequestHandler(fakeLogger, 4000), }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -367,7 +212,7 @@ describe('OdpManager', () => { segmentsRequestHandler: new BrowserRequestHandler(fakeLogger, 1), }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -377,16 +222,17 @@ describe('OdpManager', () => { it('Custom odpOptions.segmentManager overrides default Segment Manager', () => { const customSegmentManager = new OdpSegmentManager( - odpConfig, new BrowserLRUCache(), - fakeSegmentApiManager + fakeSegmentApiManager, + fakeLogger, + odpConfig, ); const odpOptions: OdpOptions = { segmentManager: customSegmentManager, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -396,12 +242,13 @@ describe('OdpManager', () => { it('Custom odpOptions.segmentManager override takes precedence over all other segments-related odpOptions', () => { const customSegmentManager = new OdpSegmentManager( - odpConfig, new BrowserLRUCache({ maxSize: 1, timeout: 1, }), - new OdpSegmentApiManager(new BrowserRequestHandler(fakeLogger, 1), fakeLogger) + new OdpSegmentApiManager(new BrowserRequestHandler(fakeLogger, 1), fakeLogger), + fakeLogger, + odpConfig, ); const odpOptions: OdpOptions = { @@ -413,7 +260,7 @@ describe('OdpManager', () => { segmentManager: customSegmentManager, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -435,7 +282,7 @@ describe('OdpManager', () => { eventApiTimeout: 4000, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -446,7 +293,7 @@ describe('OdpManager', () => { it('Browser default Events API Request Handler timeout should be used when odpOptions does not include eventsApiTimeout', () => { const odpOptions: OdpOptions = {}; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -459,7 +306,7 @@ describe('OdpManager', () => { eventFlushInterval: 4000, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -470,7 +317,7 @@ describe('OdpManager', () => { it('Default ODP event flush interval is used when odpOptions does not include eventFlushInterval', () => { const odpOptions: OdpOptions = {}; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -483,7 +330,7 @@ describe('OdpManager', () => { eventFlushInterval: 0, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -499,7 +346,7 @@ describe('OdpManager', () => { eventBatchSize: 2, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -512,7 +359,7 @@ describe('OdpManager', () => { eventQueueSize: 2, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -525,7 +372,7 @@ describe('OdpManager', () => { eventRequestHandler: new BrowserRequestHandler(fakeLogger, 4000), }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -542,7 +389,7 @@ describe('OdpManager', () => { eventRequestHandler: new BrowserRequestHandler(fakeLogger, 1), }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -566,7 +413,7 @@ describe('OdpManager', () => { eventManager: customEventManager, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -604,7 +451,7 @@ describe('OdpManager', () => { eventManager: customEventManager, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); @@ -642,7 +489,7 @@ describe('OdpManager', () => { eventQueueSize: 4, }; - const browserOdpManager = new BrowserOdpManager({ + const browserOdpManager = BrowserOdpManager.createInstance({ odpOptions, }); diff --git a/tests/odpManager.spec.ts b/tests/odpManager.spec.ts index 5d3b0e465..90228cc52 100644 --- a/tests/odpManager.spec.ts +++ b/tests/odpManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ */ /// -import { anything, instance, mock, resetCalls, verify } from 'ts-mockito'; +import { anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { LOG_MESSAGES } from './../lib/utils/enums/index'; import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; @@ -24,13 +24,16 @@ 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 { NodeOdpManager as OdpManager } from './../lib/plugins/odp_manager/index.node'; -import { OdpConfig } from '../lib/core/odp/odp_config'; +import { OdpManager, Status } from '../lib/core/odp/odp_manager'; +import { OdpConfig, OdpIntegratedConfig, OdpIntegrationConfig, OdpNotIntegratedConfig } from '../lib/core/odp/odp_config'; import { NodeOdpEventApiManager as OdpEventApiManager } from '../lib/plugins/odp/event_api_manager/index.node'; import { NodeOdpEventManager as OdpEventManager } from '../lib/plugins/odp/event_manager/index.node'; -import { OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; +import { IOdpSegmentManager, OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; -import { ServerLRUCache } from '../lib/utils/lru_cache'; +import { IOdpEventManager } from '../lib/shared_types'; +import { wait } from './testUtils'; +import { resolvablePromise } from '../lib/utils/promise/resolvablePromise'; +import exp from 'constants'; const keyA = 'key-a'; const hostA = 'host-a'; @@ -44,6 +47,40 @@ const pixelB = 'pixel-b'; const segmentsB = ['b']; const userB = 'fs-user-b'; +const testOdpManager = ({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled, + vuid, + vuidInitializer, +}: { + odpIntegrationConfig?: OdpIntegrationConfig; + segmentManager: IOdpSegmentManager; + eventManager: IOdpEventManager; + logger: LogHandler; + vuidEnabled?: boolean; + vuid?: string; + vuidInitializer?: () => Promise; +}): OdpManager => { + class TestOdpManager extends OdpManager{ + constructor() { + super({ odpIntegrationConfig, segmentManager, eventManager, logger }); + } + isVuidEnabled(): boolean { + return vuidEnabled ?? false; + } + getVuid(): string { + return vuid ?? 'vuid_123'; + } + protected initializeVuid(): Promise { + return vuidInitializer?.() ?? Promise.resolve(); + } + } + return new TestOdpManager(); +} + describe('OdpManager', () => { let mockLogger: LogHandler; let mockRequestHandler: RequestHandler; @@ -66,7 +103,6 @@ describe('OdpManager', () => { mockLogger = mock(); mockRequestHandler = mock(); - odpConfig = new OdpConfig(); logger = instance(mockLogger); defaultRequestHandler = instance(mockRequestHandler); @@ -89,142 +125,577 @@ describe('OdpManager', () => { resetCalls(mockSegmentManager); }); - const odpManagerInstance = (config?: OdpConfig) => - new OdpManager({ - odpOptions: { - eventManager, - segmentManager, - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - }, + + it('should be in stopped status and not ready if constructed without odpIntegrationConfig', () => { + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, }); - it('should drop relevant calls when OdpManager is initialized with the disabled flag', async () => { - const odpManager = new OdpManager({ + expect(odpManager.isReady()).toBe(false); + expect(odpManager.getStatus()).toEqual(Status.Stopped); + }); + + it('should call initialzeVuid on construction if vuid is enabled', () => { + const vuidInitializer = jest.fn(); + + const odpManager = testOdpManager({ + segmentManager, + eventManager, logger, - odpOptions: { - disabled: true, - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - }, + vuidEnabled: true, + vuidInitializer: vuidInitializer, }); - verify(mockLogger.log(LogLevel.INFO, LOG_MESSAGES.ODP_DISABLED)).once(); - odpManager.updateSettings(new OdpConfig('valid', 'host', 'pixel-url', [])); - expect(odpManager.odpConfig).toBeUndefined; + expect(vuidInitializer).toHaveBeenCalledTimes(1); + }); - await odpManager.fetchQualifiedSegments('user1', []); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_ENABLED)).once(); + it('should become ready only after odpIntegrationConfig is provided if vuid is not enabled', async () => { + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: false, + }); - odpManager.identifyUser('user1'); - verify(mockLogger.log(LogLevel.DEBUG, LOG_MESSAGES.ODP_IDENTIFY_FAILED_ODP_DISABLED)).once(); + // should not be ready untill odpIntegrationConfig is provided + await wait(500); + expect(odpManager.isReady()).toBe(false); - expect(odpManager.eventManager).toBeUndefined; - expect(odpManager.segmentManager).toBeUndefined; + const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; + odpManager.updateSettings(odpIntegrationConfig); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); }); - 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 become ready if odpIntegrationConfig is provided in constructor and then initialzeVuid', async () => { + const vuidPromise = resolvablePromise(); + const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; + + const vuidInitializer = () => { + return vuidPromise.promise; + } + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + vuidInitializer, + }); + + await wait(500); + expect(odpManager.isReady()).toBe(false); + + vuidPromise.resolve(); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); + }); + + it('should become ready after odpIntegrationConfig is provided using updateSettings() and then initialzeVuid finishes', async () => { + const vuidPromise = resolvablePromise(); + + const vuidInitializer = () => { + return vuidPromise.promise; + } + + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + vuidInitializer, + }); + + + expect(odpManager.isReady()).toBe(false); + + const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; + odpManager.updateSettings(odpIntegrationConfig); + + await wait(500); + expect(odpManager.isReady()).toBe(false); + + vuidPromise.resolve(); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); + }); + + it('should become ready after initialzeVuid finishes and then odpIntegrationConfig is provided using updateSettings()', async () => { + const vuidPromise = resolvablePromise(); + + const vuidInitializer = () => { + return vuidPromise.promise; + } + + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + vuidInitializer, + }); + + expect(odpManager.isReady()).toBe(false); + vuidPromise.resolve(); + + await wait(500); + expect(odpManager.isReady()).toBe(false); + + const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; + odpManager.updateSettings(odpIntegrationConfig); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); }); - it('should stop ODP Event Manager when close is called', () => { - const odpManager = odpManagerInstance(); + it('should become ready and stay in stopped state and not start eventManager if OdpNotIntegrated config is provided', async () => { + const vuidPromise = resolvablePromise(); + + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; + odpManager.updateSettings(odpIntegrationConfig); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); + expect(odpManager.getStatus()).toEqual(Status.Stopped); + verify(mockEventManager.start()).never(); + }); + + it('should pass the integrated odp config given in constructor to eventManger and segmentManager', async () => { + when(mockEventManager.updateSettings(anything())).thenReturn(undefined); + when(mockSegmentManager.updateSettings(anything())).thenReturn(undefined); + + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + verify(mockEventManager.updateSettings(anything())).once(); + const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + + verify(mockSegmentManager.updateSettings(anything())).once(); + const [segmentOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(segmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + }); + + it('should pass the integrated odp config given in updateSettings() to eventManger and segmentManager', async () => { + when(mockEventManager.updateSettings(anything())).thenReturn(undefined); + when(mockSegmentManager.updateSettings(anything())).thenReturn(undefined); + + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + odpManager.updateSettings(odpIntegrationConfig); + + verify(mockEventManager.updateSettings(anything())).once(); + const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + + verify(mockSegmentManager.updateSettings(anything())).once(); + const [segmentOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(segmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + }); + + it('should start if odp is integrated and start odpEventManger', async () => { + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + odpManager.updateSettings(odpIntegrationConfig); + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); + expect(odpManager.getStatus()).toEqual(Status.Running); + }); + + it('should just update config when updateSettings is called in running state', async () => { + const odpManager = testOdpManager({ + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + odpManager.updateSettings(odpIntegrationConfig); + + await odpManager.onReady(); + expect(odpManager.isReady()).toBe(true); + expect(odpManager.getStatus()).toEqual(Status.Running); + + const newOdpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyB, hostB, pixelB, segmentsB) + }; + + odpManager.updateSettings(newOdpIntegrationConfig); + + verify(mockEventManager.start()).once(); verify(mockEventManager.stop()).never(); + verify(mockEventManager.updateSettings(anything())).twice(); + const [firstEventOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(firstEventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + const [secondEventOdpConfig] = capture(mockEventManager.updateSettings).second(); + expect(secondEventOdpConfig.equals(newOdpIntegrationConfig.odpConfig)).toBe(true); + + verify(mockSegmentManager.updateSettings(anything())).twice(); + const [firstSegmentOdpConfig] = capture(mockEventManager.updateSettings).first(); + expect(firstSegmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); + const [secondSegmentOdpConfig] = capture(mockEventManager.updateSettings).second(); + expect(secondSegmentOdpConfig.equals(newOdpIntegrationConfig.odpConfig)).toBe(true); + }); - odpManager.close(); + it('should stop and stop eventManager if OdpNotIntegrated config is updated in running state', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + expect(odpManager.isReady()).toBe(true); + expect(odpManager.getStatus()).toEqual(Status.Running); + + const newOdpIntegrationConfig: OdpNotIntegratedConfig = { + integrated: false, + }; + + odpManager.updateSettings(newOdpIntegrationConfig); + + expect(odpManager.getStatus()).toEqual(Status.Stopped); verify(mockEventManager.stop()).once(); }); - it('should use new settings in event manager when ODP Config is updated', async () => { - const odpManager = new OdpManager({ - odpOptions: { - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - eventManager: new OdpEventManager({ - odpConfig, - apiManager: eventApiManager, - logger, - clientEngine: '', - clientVersion: '', - batchSize: 1, - flushInterval: 250, - }), - }, + it('should register vuid after becoming ready if odp is integrated', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + verify(mockEventManager.registerVuid(anything())).once(); + }); + + it('should call eventManager.identifyUser with correct parameters when identifyUser is called', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + const userId = 'user123'; + const vuid = 'vuid_123'; + + odpManager.identifyUser(userId, vuid); + const [userIdArg, vuidArg] = capture(mockEventManager.identifyUser).byCallIndex(0); + expect(userIdArg).toEqual(userId); + expect(vuidArg).toEqual(vuid); + + odpManager.identifyUser(userId); + const [userIdArg2, vuidArg2] = capture(mockEventManager.identifyUser).byCallIndex(1); + expect(userIdArg2).toEqual(userId); + expect(vuidArg2).toEqual(undefined); + + odpManager.identifyUser(vuid); + const [userIdArg3, vuidArg3] = capture(mockEventManager.identifyUser).byCallIndex(2); + expect(userIdArg3).toEqual(undefined); + expect(vuidArg3).toEqual(vuid); + }); + + it('should send event with correct parameters', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', 'value1'], ['key2', 'value2']]); + + odpManager.sendEvent({ + action: 'action', + type: 'type', + identifiers, + data, + }); + + const [event] = capture(mockEventManager.sendEvent).byCallIndex(0); + expect(event.action).toEqual('action'); + expect(event.type).toEqual('type'); + expect(event.identifiers).toEqual(identifiers); + expect(event.data).toEqual(data); + + // should use `fullstack` as type if empty string is provided + odpManager.sendEvent({ + type: '', + action: 'action', + identifiers, + data, + }); + + const [event2] = capture(mockEventManager.sendEvent).byCallIndex(1); + expect(event2.action).toEqual('action'); + expect(event2.type).toEqual('fullstack'); + expect(event2.identifiers).toEqual(identifiers); + }); + + + it('should throw an error if event action is empty string and not call eventManager', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', 'value1'], ['key2', 'value2']]); + + const sendEvent = () => odpManager.sendEvent({ + action: '', + type: 'type', + identifiers, + data, }); - odpManager.updateSettings(new OdpConfig(keyA, hostA, pixelA, segmentsA)); + expect(sendEvent).toThrow('ODP action is not valid'); + verify(mockEventManager.sendEvent(anything())).never(); + }); - expect(odpManager.odpConfig.apiKey).toBe(keyA); - expect(odpManager.odpConfig.apiHost).toBe(hostA); - expect(odpManager.odpConfig.pixelUrl).toBe(pixelA); + it('should throw an error if event data is invalid', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; - // odpManager.identifyUser(userA); + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); - // verify(mockEventApiManager.sendEvents(keyA, hostA, anything())).once(); + await odpManager.onReady(); - odpManager.updateSettings(new OdpConfig(keyB, hostB, pixelB, segmentsB)); - expect(odpManager.odpConfig.apiKey).toBe(keyB); - expect(odpManager.odpConfig.apiHost).toBe(hostB); - expect(odpManager.odpConfig.pixelUrl).toBe(pixelB); + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', {}]]); - // odpManager.identifyUser(userB); + const sendEvent = () => odpManager.sendEvent({ + action: 'action', + type: 'type', + identifiers, + data, + }); - // verify(mockEventApiManager.sendEvents(keyB, hostB, anything())).once(); + expect(sendEvent).toThrow(ERROR_MESSAGES.ODP_INVALID_DATA); + verify(mockEventManager.sendEvent(anything())).never(); }); - it('should use new settings in segment manager when ODP Config is updated', async () => { - const odpManager = new OdpManager({ - odpOptions: { - segmentManager: new OdpSegmentManager(odpConfig, new BrowserLRUCache(), segmentApiManager), - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - }, + it.only('should fetch qualified segments correctly for both fs_user_id and vuid', async () => { + const userId = 'user123'; + const vuid = 'vuid_123'; + + when(mockSegmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, anything())) + .thenResolve(['fs1', 'fs2']); + + when(mockSegmentManager.fetchQualifiedSegments(ODP_USER_KEY.VUID, vuid, anything())) + .thenResolve(['vuid1', 'vuid2']); + + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager: instance(mockSegmentManager), + eventManager, + logger, + vuidEnabled: true, }); - odpManager.updateSettings(new OdpConfig(keyA, hostA, pixelA, segmentsA)); + await odpManager.onReady(); - expect(odpManager.odpConfig.apiKey).toBe(keyA); - expect(odpManager.odpConfig.apiHost).toBe(hostA); - expect(odpManager.odpConfig.pixelUrl).toBe(pixelA); + const fsSegments = await odpManager.fetchQualifiedSegments(userId); + expect(fsSegments).toEqual(['fs1', 'fs2']); + + const vuidSegments = await odpManager.fetchQualifiedSegments(vuid); + expect(vuidSegments).toEqual(['vuid1', 'vuid2']); + }); + + + it('should stop itself and eventManager if stop is called', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); - await odpManager.fetchQualifiedSegments(userA); - verify(mockSegmentApiManager.fetchSegments(keyA, hostA, ODP_USER_KEY.FS_USER_ID, userA, anything())).once(); + await odpManager.onReady(); - odpManager.updateSettings(new OdpConfig(keyB, hostB, pixelB, segmentsB)); - expect(odpManager.odpConfig.apiKey).toBe(keyB); - expect(odpManager.odpConfig.apiHost).toBe(hostB); - expect(odpManager.odpConfig.pixelUrl).toBe(pixelB); + odpManager.stop(); - await odpManager.fetchQualifiedSegments(userB); - verify(mockSegmentApiManager.fetchSegments(keyB, hostB, ODP_USER_KEY.FS_USER_ID, userB, anything())).once(); + expect(odpManager.getStatus()).toEqual(Status.Stopped); + verify(mockEventManager.stop()).once(); }); - it('should get event manager', () => { - const odpManagerA = odpManagerInstance(); - expect(odpManagerA.eventManager).not.toBe(null); - const odpManagerB = new OdpManager({ + + it('should drop relevant calls and log error when odpIntegrationConfig is not available', async () => { + const odpManager = testOdpManager({ + odpIntegrationConfig: undefined, + segmentManager, + eventManager, logger, - odpOptions: { - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - }, + vuidEnabled: true, }); - expect(odpManagerB.eventManager).not.toBe(null); + + const segments = await odpManager.fetchQualifiedSegments('vuid_user1', []); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).once(); + expect(segments).toBeNull(); + + odpManager.identifyUser('vuid_user1'); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).twice(); + verify(mockEventManager.identifyUser(anything(), anything())).never(); + + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', {}]]); + + odpManager.sendEvent({ + action: 'action', + type: 'type', + identifiers, + data, + }); + + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).thrice(); + verify(mockEventManager.sendEvent(anything())).never(); + }); - it('should get segment manager', () => { - const odpManagerA = odpManagerInstance(); - expect(odpManagerA.segmentManager).not.toBe(null); + it('should drop relevant calls and log error when odp is not integrated', async () => { + const odpManager = testOdpManager({ + odpIntegrationConfig: { integrated: false }, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + + const segments = await odpManager.fetchQualifiedSegments('vuid_user1', []); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).once(); + expect(segments).toBeNull(); - const odpManagerB = new OdpManager({ - odpOptions: { - segmentsRequestHandler: defaultRequestHandler, - eventRequestHandler: defaultRequestHandler, - }, + odpManager.identifyUser('vuid_user1'); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).twice(); + verify(mockEventManager.identifyUser(anything(), anything())).never(); + + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', {}]]); + + odpManager.sendEvent({ + action: 'action', + type: 'type', + identifiers, + data, }); - expect(odpManagerB.eventManager).not.toBe(null); + + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).thrice(); + verify(mockEventManager.sendEvent(anything())).never(); }); }); + diff --git a/tests/odpSegmentManager.spec.ts b/tests/odpSegmentManager.spec.ts index 5deea4348..f4421175f 100644 --- a/tests/odpSegmentManager.spec.ts +++ b/tests/odpSegmentManager.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,8 +45,6 @@ describe('OdpSegmentManager', () => { const mockLogHandler = mock(); const mockRequestHandler = mock(); - let manager: OdpSegmentManager; - let odpConfig: OdpConfig; const apiManager = new MockOdpSegmentApiManager(instance(mockRequestHandler), instance(mockLogHandler)); let options: Array = []; @@ -57,6 +55,13 @@ describe('OdpSegmentManager', () => { const validTestOdpConfig = new OdpConfig('valid-key', 'host', 'pixel-url', ['new-customer']); const invalidTestOdpConfig = new OdpConfig('invalid-key', 'host', 'pixel-url', ['new-customer']); + const getSegmentsCache = () => { + return new LRUCache({ + maxSize: 1000, + timeout: 1000, + }); + } + beforeEach(() => { resetCalls(mockLogHandler); resetCalls(mockRequestHandler); @@ -64,93 +69,100 @@ describe('OdpSegmentManager', () => { const API_KEY = 'test-api-key'; const API_HOST = 'https://odp.example.com'; const PIXEL_URL = 'https://odp.pixel.com'; - odpConfig = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, []); - const segmentsCache = new LRUCache({ - maxSize: 1000, - timeout: 1000, - }); - - manager = new OdpSegmentManager(odpConfig, segmentsCache, apiManager); }); it('should fetch segments successfully on cache miss.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, '123', ['a']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager, userKey, '123', ['a']); const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['new-customer']); }); it('should fetch segments successfully on cache hit.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, userValue, ['a']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager, userKey, userValue, ['a']); const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['a']); }); - it('should throw an error when fetching segments returns an error.', async () => { - odpConfig.update(invalidTestOdpConfig); + it('should return null when fetching segments returns an error.', async () => { + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, invalidTestOdpConfig); const segments = await manager.fetchQualifiedSegments(userKey, userValue, []); expect(segments).toBeNull; }); it('should ignore the cache if the option enum is included in the options array.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, userValue, ['a']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager, userKey, userValue, ['a']); options = [OptimizelySegmentOption.IGNORE_CACHE]; const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['new-customer']); - expect(cacheCount()).toBe(1); + expect(cacheCount(manager)).toBe(1); }); it('should ignore the cache if the option string is included in the options array.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, userValue, ['a']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager,userKey, userValue, ['a']); // @ts-ignore options = ['IGNORE_CACHE']; const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['new-customer']); - expect(cacheCount()).toBe(1); + expect(cacheCount(manager)).toBe(1); }); it('should reset the cache if the option enum is included in the options array.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, userValue, ['a']); - setCache(userKey, '123', ['a']); - setCache(userKey, '456', ['a']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager, userKey, userValue, ['a']); + setCache(manager, userKey, '123', ['a']); + setCache(manager, userKey, '456', ['a']); options = [OptimizelySegmentOption.RESET_CACHE]; const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['new-customer']); - expect(peekCache(userKey, userValue)).toEqual(segments); - expect(cacheCount()).toBe(1); + expect(peekCache(manager, userKey, userValue)).toEqual(segments); + expect(cacheCount(manager)).toBe(1); + }); + + it('should reset the cache on settings update.', async () => { + const oldConfig = new OdpConfig('old-key', 'old-host', 'pixel-url', ['new-customer']); + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + + setCache(manager, userKey, userValue, ['a']); + expect(cacheCount(manager)).toBe(1); + + const newConfig = new OdpConfig('new-key', 'new-host', 'pixel-url', ['new-customer']); + manager.updateSettings(newConfig); + + expect(cacheCount(manager)).toBe(0); }); it('should reset the cache if the option string is included in the options array.', async () => { - odpConfig.update(validTestOdpConfig); - setCache(userKey, userValue, ['a']); - setCache(userKey, '123', ['a']); - setCache(userKey, '456', ['a']); - // @ts-ignore + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); + setCache(manager, userKey, userValue, ['a']); + setCache(manager, userKey, '123', ['a']); + setCache(manager, userKey, '456', ['a']); + // @ts-ignore options = ['RESET_CACHE']; const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); expect(segments).toEqual(['new-customer']); - expect(peekCache(userKey, userValue)).toEqual(segments); - expect(cacheCount()).toBe(1); + expect(peekCache(manager, userKey, userValue)).toEqual(segments); + expect(cacheCount(manager)).toBe(1); }); it('should make a valid cache key.', () => { + const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); expect('vuid-$-test-user').toBe(manager.makeCacheKey(userKey, userValue)); }); // Utility Functions - function setCache(userKey: string, userValue: string, value: string[]) { + function setCache(manager: OdpSegmentManager, userKey: string, userValue: string, value: string[]) { const cacheKey = manager.makeCacheKey(userKey, userValue); manager.segmentsCache.save({ key: cacheKey, @@ -158,10 +170,10 @@ describe('OdpSegmentManager', () => { }); } - function peekCache(userKey: string, userValue: string): string[] | null { + function peekCache(manager: OdpSegmentManager, userKey: string, userValue: string): string[] | null { const cacheKey = manager.makeCacheKey(userKey, userValue); return (manager.segmentsCache as LRUCache).peek(cacheKey); } - const cacheCount = () => (manager.segmentsCache as LRUCache).map.size; + const cacheCount = (manager: OdpSegmentManager) => (manager.segmentsCache as LRUCache).map.size; }); diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 2c28c259b..2af292e09 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,7 +42,6 @@ export const getTestPersistentCache = (): PersistentKeyValueCache => { }), set: jest.fn().mockImplementation((): Promise => { - console.log('mock set called'); return Promise.resolve(); }), @@ -57,3 +56,7 @@ export const getTestPersistentCache = (): PersistentKeyValueCache => { return cache; } + +export const wait = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +};