diff --git a/lib/common_exports.ts b/lib/common_exports.ts index 6c6374a70..c2718e911 100644 --- a/lib/common_exports.ts +++ b/lib/common_exports.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. @@ -17,3 +17,5 @@ export { LogLevel, LogHandler, getLogger, setLogHandler } from './modules/logging'; export { LOG_LEVEL } from './utils/enums'; export { createLogger } from './plugins/logger'; +export { createStaticProjectConfigManager } from './project_config/config_manager_factory'; +export { PollingConfigManagerConfig } from './project_config/config_manager_factory'; diff --git a/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js index 97d261c04..e30c9129e 100644 --- a/lib/core/bucketer/index.tests.js +++ b/lib/core/bucketer/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, 2019-2022, Optimizely + * Copyright 2016-2017, 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. @@ -25,7 +25,7 @@ import { LOG_LEVEL, } from '../../utils/enums'; import { createLogger } from '../../plugins/logger'; -import projectConfig from '../project_config'; +import projectConfig from '../../project_config/project_config'; import { getTestProjectConfig } from '../../tests/test_data'; var buildLogMessageFromArgs = args => sprintf(args[1], ...args.splice(2)); diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index ba0dd5fbb..b9197ebab 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -1,18 +1,18 @@ -/**************************************************************************** - * Copyright 2017-2022 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. * - ***************************************************************************/ +/** + * Copyright 2017-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. + * 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 sinon from 'sinon'; import { assert } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; @@ -29,11 +29,13 @@ import { createForwardingEventProcessor } from '../../plugins/event_processor/fo import { createNotificationCenter } from '../notification_center'; import Optimizely from '../../optimizely'; import OptimizelyUserContext from '../../optimizely_user_context'; -import projectConfig from '../project_config'; +import projectConfig, { createProjectConfig } from '../../project_config/project_config'; import AudienceEvaluator from '../audience_evaluator'; import errorHandler from '../../plugins/error_handler'; import eventDispatcher from '../../plugins/event_dispatcher/index.node'; import * as jsonSchemaValidator from '../../utils/json_schema_validator'; +import { getMockProjectConfigManager } from '../../tests/mock/mock_project_config_manager'; + import { getTestProjectConfig, getTestProjectConfigWithFeatures, @@ -1067,7 +1069,9 @@ describe('lib/core/decision_service', function() { beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: cloneDeep(testData), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(cloneDeep(testData)) + }), jsonSchemaValidator: jsonSchemaValidator, isValidInstance: true, logger: createdLogger, diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 28f97a09e..c3fea53eb 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -1,18 +1,18 @@ -/**************************************************************************** - * Copyright 2017-2022 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. * - ***************************************************************************/ +/** + * Copyright 2017-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. + * 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 { LogHandler } from '../../modules/logging'; import { sprintf } from '../../utils/fns'; @@ -38,7 +38,7 @@ import { getVariationKeyFromId, isActive, ProjectConfig, -} from '../project_config'; +} from '../../project_config/project_config'; import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator'; import * as stringValidator from '../../utils/string_value_validator'; import { diff --git a/lib/core/event_builder/event_helpers.tests.js b/lib/core/event_builder/event_helpers.tests.js index 3d722b975..552a72e24 100644 --- a/lib/core/event_builder/event_helpers.tests.js +++ b/lib/core/event_builder/event_helpers.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2019-2020, Optimizely + * Copyright 2019-2020, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ import sinon from 'sinon'; import { assert } from 'chai'; import fns from '../../utils/fns'; -import * as projectConfig from '../project_config'; +import * as projectConfig from '../../project_config/project_config'; import * as decision from '../decision'; import { buildImpressionEvent, buildConversionEvent } from './event_helpers'; diff --git a/lib/core/event_builder/event_helpers.ts b/lib/core/event_builder/event_helpers.ts index 071a1427a..9c0fc8257 100644 --- a/lib/core/event_builder/event_helpers.ts +++ b/lib/core/event_builder/event_helpers.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. @@ -27,7 +27,7 @@ import { getEventId, getLayerId, ProjectConfig, -} from '../project_config'; +} from '../../project_config/project_config'; const logger = getLogger('EVENT_BUILDER'); diff --git a/lib/core/event_builder/index.tests.js b/lib/core/event_builder/index.tests.js index 39ed140ad..4fd6053a9 100644 --- a/lib/core/event_builder/index.tests.js +++ b/lib/core/event_builder/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2021, Optimizely + * Copyright 2016-2021, 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,7 +18,7 @@ import { assert } from 'chai'; import fns from '../../utils/fns'; import testData from '../../tests/test_data'; -import projectConfig from '../project_config'; +import projectConfig from '../../project_config/project_config'; import packageJSON from '../../../package.json'; import { getConversionEvent, getImpressionEvent } from './'; diff --git a/lib/core/event_builder/index.ts b/lib/core/event_builder/index.ts index cd6781529..f896adbea 100644 --- a/lib/core/event_builder/index.ts +++ b/lib/core/event_builder/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2016-2022, Optimizely + * Copyright 2016-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. @@ -24,7 +24,7 @@ import { getLayerId, getVariationKeyFromId, ProjectConfig, -} from '../project_config'; +} from '../../project_config/project_config'; import * as eventTagUtils from '../../utils/event_tag_utils'; import { isAttributeValid } from '../../utils/attributes_validator'; import { EventTags, UserAttributes, Event as EventLoggingEndpoint } from '../../shared_types'; diff --git a/lib/core/notification_center/notification_registry.tests.ts b/lib/core/notification_center/notification_registry.tests.ts deleted file mode 100644 index 3a99b052c..000000000 --- a/lib/core/notification_center/notification_registry.tests.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright 2023, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it } from 'mocha'; -import { expect } from 'chai'; - -import { NotificationRegistry } from './notification_registry'; - -describe('Notification Registry', () => { - it('Returns null notification center when SDK Key is null', () => { - const notificationCenter = NotificationRegistry.getNotificationCenter(); - expect(notificationCenter).to.be.undefined; - }); - - it('Returns the same notification center when SDK Keys are the same and not null', () => { - const sdkKey = 'testSDKKey'; - const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); - const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); - expect(notificationCenterA).to.eql(notificationCenterB); - }); - - it('Returns different notification centers when SDK Keys are not the same', () => { - const sdkKeyA = 'testSDKKeyA'; - const sdkKeyB = 'testSDKKeyB'; - const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKeyA); - const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKeyB); - expect(notificationCenterA).to.not.eql(notificationCenterB); - }); - - it('Removes old notification centers from the registry when removeNotificationCenter is called on the registry', () => { - const sdkKey = 'testSDKKey'; - const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); - NotificationRegistry.removeNotificationCenter(sdkKey); - - const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); - - expect(notificationCenterA).to.not.eql(notificationCenterB); - }); - - it('Does not throw an error when calling removeNotificationCenter with a null SDK Key', () => { - const sdkKey = 'testSDKKey'; - const notificationCenterA = NotificationRegistry.getNotificationCenter(sdkKey); - NotificationRegistry.removeNotificationCenter(); - - const notificationCenterB = NotificationRegistry.getNotificationCenter(sdkKey); - - expect(notificationCenterA).to.eql(notificationCenterB); - }); -}); diff --git a/lib/core/notification_center/notification_registry.ts b/lib/core/notification_center/notification_registry.ts deleted file mode 100644 index 12fe1178e..000000000 --- a/lib/core/notification_center/notification_registry.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2023, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; -import { NotificationCenter, createNotificationCenter } from '../../core/notification_center'; - -/** - * Internal notification center registry for managing multiple notification centers. - */ -export class NotificationRegistry { - private static _notificationCenters = new Map(); - - constructor() {} - - /** - * Retrieves an SDK Key's corresponding notification center in the registry if it exists, otherwise it creates one - * @param sdkKey SDK Key to be used for the notification center tied to the ODP Manager - * @param logger Logger to be used for the corresponding notification center - * @returns {NotificationCenter | undefined} a notification center instance for ODP Manager if a valid SDK Key is provided, otherwise undefined - */ - static getNotificationCenter(sdkKey?: string, logger: LogHandler = getLogger()): NotificationCenter | undefined { - if (!sdkKey) { - logger.log(LogLevel.ERROR, 'No SDK key provided to getNotificationCenter.'); - return undefined; - } - - let notificationCenter; - if (this._notificationCenters.has(sdkKey)) { - notificationCenter = this._notificationCenters.get(sdkKey); - } else { - notificationCenter = createNotificationCenter({ - logger, - errorHandler: { handleError: () => {} }, - }); - this._notificationCenters.set(sdkKey, notificationCenter); - } - - return notificationCenter; - } - - static removeNotificationCenter(sdkKey?: string): void { - if (!sdkKey) { - return; - } - - const notificationCenter = this._notificationCenters.get(sdkKey); - if (notificationCenter) { - notificationCenter.clearAllNotificationListeners(); - this._notificationCenters.delete(sdkKey); - } - } -} diff --git a/lib/core/odp/odp_event_api_manager.ts b/lib/core/odp/odp_event_api_manager.ts index 35ffcc4e8..6b3362f8c 100644 --- a/lib/core/odp/odp_event_api_manager.ts +++ b/lib/core/odp/odp_event_api_manager.ts @@ -16,7 +16,7 @@ import { LogHandler, LogLevel } from '../../modules/logging'; import { OdpEvent } from './odp_event'; -import { RequestHandler } from '../../utils/http_request_handler/http'; +import { HttpMethod, RequestHandler } from '../../utils/http_request_handler/http'; import { OdpConfig } from './odp_config'; import { ERROR_MESSAGES } from '../../utils/enums'; @@ -109,7 +109,7 @@ export abstract class OdpEventApiManager implements IOdpEventApiManager { odpConfig: OdpConfig, events: OdpEvent[] ): { - method: string; + method: HttpMethod; endpoint: string; headers: { [key: string]: string }; data: string; diff --git a/lib/core/odp/odp_event_manager.ts b/lib/core/odp/odp_event_manager.ts index 3b91d7712..2ffbbeaa3 100644 --- a/lib/core/odp/odp_event_manager.ts +++ b/lib/core/odp/odp_event_manager.ts @@ -24,7 +24,7 @@ import { OdpConfig } from './odp_config'; import { IOdpEventApiManager } from './odp_event_api_manager'; import { invalidOdpDataFound } from './odp_utils'; import { IUserAgentParser } from './user_agent_parser'; -import { scheduleMicrotaskOrTimeout } from '../../utils/microtask'; +import { scheduleMicrotask } from '../../utils/microtask'; const MAX_RETRIES = 3; @@ -394,7 +394,7 @@ export abstract class OdpEventManager implements IOdpEventManager { if (batch.length > 0) { // put sending the event on another event loop - scheduleMicrotaskOrTimeout(async () => { + scheduleMicrotask(async () => { let shouldRetry: boolean; let attemptNumber = 0; do { diff --git a/lib/core/optimizely_config/index.tests.js b/lib/core/optimizely_config/index.tests.js index 9f147c1b5..d4100e0da 100644 --- a/lib/core/optimizely_config/index.tests.js +++ b/lib/core/optimizely_config/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2019-2021, Optimizely + * Copyright 2019-2021, 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,7 +18,7 @@ import { cloneDeep } from 'lodash'; import sinon from 'sinon'; import { createOptimizelyConfig, OptimizelyConfig } from './'; -import { createProjectConfig } from '../project_config'; +import { createProjectConfig } from '../../project_config/project_config'; import { getTestProjectConfigWithFeatures, getTypedAudiencesConfig, diff --git a/lib/core/optimizely_config/index.ts b/lib/core/optimizely_config/index.ts index 4b435b830..d8987b6c7 100644 --- a/lib/core/optimizely_config/index.ts +++ b/lib/core/optimizely_config/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2020-2023, Optimizely + * Copyright 2020-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,7 +14,7 @@ * limitations under the License. */ import { LoggerFacade, getLogger } from '../../modules/logging'; -import { ProjectConfig } from '../project_config'; +import { ProjectConfig } from '../../project_config/project_config'; import { DEFAULT_OPERATOR_TYPES } from '../condition_tree_evaluator'; import { Audience, diff --git a/lib/core/project_config/project_config_manager.tests.js b/lib/core/project_config/project_config_manager.tests.js deleted file mode 100644 index b8fe8f8d3..000000000 --- a/lib/core/project_config/project_config_manager.tests.js +++ /dev/null @@ -1,490 +0,0 @@ -/** - * 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. - * 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 sinon from 'sinon'; -import { assert } from 'chai'; -import { cloneDeep } from 'lodash'; - -import { sprintf } from '../../utils/fns'; -import * as logging from '../../modules/logging'; -import datafileManager from '../../modules/datafile-manager/index.node'; - -import * as projectConfig from './index'; -import { ERROR_MESSAGES, LOG_MESSAGES } from '../../utils/enums'; -import testData from '../../tests/test_data'; -import * as projectConfigManager from './project_config_manager'; -import * as optimizelyConfig from '../optimizely_config'; -import * as jsonSchemaValidator from '../../utils/json_schema_validator'; -import { createHttpPollingDatafileManager } from '../../plugins/datafile_manager/http_polling_datafile_manager'; - -const logger = logging.getLogger(); - -describe('lib/core/project_config/project_config_manager', function() { - var globalStubErrorHandler; - var stubLogHandler; - beforeEach(function() { - sinon.stub(datafileManager, 'HttpPollingDatafileManager').returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(null), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns({ then: function() {} }), - }); - globalStubErrorHandler = { - handleError: sinon.stub(), - }; - logging.setErrorHandler(globalStubErrorHandler); - logging.setLogLevel('notset'); - stubLogHandler = { - log: sinon.stub(), - }; - logging.setLogHandler(stubLogHandler); - }); - - afterEach(function() { - datafileManager.HttpPollingDatafileManager.restore(); - logging.resetErrorHandler(); - logging.resetLogger(); - }); - - it('should call the error handler and fulfill onReady with an unsuccessful result if neither datafile nor sdkKey are passed into the constructor', function() { - var manager = projectConfigManager.createProjectConfigManager({}); - sinon.assert.calledOnce(globalStubErrorHandler.handleError); - var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'PROJECT_CONFIG_MANAGER')); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile JSON is malformed', function() { - var invalidDatafileJSON = 'abc'; - var manager = projectConfigManager.createProjectConfigManager({ - datafile: invalidDatafileJSON, - }); - sinon.assert.calledOnce(globalStubErrorHandler.handleError); - var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_MALFORMED, 'CONFIG_VALIDATOR')); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile is not valid', function() { - var invalidDatafile = testData.getTestProjectConfig(); - delete invalidDatafile['projectId']; - var manager = projectConfigManager.createProjectConfigManager({ - datafile: invalidDatafile, - jsonSchemaValidator: jsonSchemaValidator, - }); - sinon.assert.calledOnce(globalStubErrorHandler.handleError); - var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual( - errorMessage, - sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR (Project Config JSON Schema)', 'projectId', 'is missing and it is required'), - ); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile version is not supported', function() { - var manager = projectConfigManager.createProjectConfigManager({ - datafile: testData.getUnsupportedVersionConfig(), - jsonSchemaValidator: jsonSchemaValidator, - }); - sinon.assert.calledOnce(globalStubErrorHandler.handleError); - var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5')); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - describe('skipping JSON schema validation', function() { - beforeEach(function() { - sinon.spy(jsonSchemaValidator, 'validate'); - }); - - afterEach(function() { - jsonSchemaValidator.validate.restore(); - }); - - it('should skip JSON schema validation if jsonSchemaValidator is not provided', function() { - var manager = projectConfigManager.createProjectConfigManager({ - datafile: testData.getTestProjectConfig(), - }); - sinon.assert.notCalled(jsonSchemaValidator.validate); - return manager.onReady(); - }); - - it('should not skip JSON schema validation if jsonSchemaValidator is provided', function() { - var manager = projectConfigManager.createProjectConfigManager({ - datafile: testData.getTestProjectConfig(), - jsonSchemaValidator: jsonSchemaValidator, - }); - sinon.assert.calledOnce(jsonSchemaValidator.validate); - sinon.assert.calledOnce(stubLogHandler.log); - var logMessage = stubLogHandler.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); - - return manager.onReady(); - }); - }); - - it('should return a valid datafile from getConfig and resolve onReady with a successful result', function() { - var configWithFeatures = testData.getTestProjectConfigWithFeatures(); - var manager = projectConfigManager.createProjectConfigManager({ - datafile: cloneDeep(configWithFeatures), - }); - assert.deepEqual(manager.getConfig(), projectConfig.createProjectConfig(configWithFeatures)); - return manager.onReady().then(function(result) { - assert.include(result, { - success: true, - }); - }); - }); - - 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, - }); - var onUpdateSpy = sinon.spy(); - manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function() { - sinon.assert.calledOnce(onUpdateSpy); - }); - }); - - describe('with a datafile manager', function() { - it('passes the correct options to datafile manager', function() { - var config = testData.getTestProjectConfig() - let datafileOptions = { - autoUpdate: true, - updateInterval: 10000, - } - projectConfigManager.createProjectConfigManager({ - datafile: config, - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger, config, datafileOptions), - }); - sinon.assert.calledOnce(datafileManager.HttpPollingDatafileManager); - sinon.assert.calledWithExactly( - datafileManager.HttpPollingDatafileManager, - sinon.match({ - datafile: JSON.stringify(config), - sdkKey: '12345', - autoUpdate: true, - updateInterval: 10000, - }) - ); - }); - - describe('when constructed with sdkKey and without datafile', function() { - it('updates itself when the datafile manager is ready, fulfills its onReady promise with a successful result, and then emits updates', function() { - var configWithFeatures = testData.getTestProjectConfigWithFeatures(); - datafileManager.HttpPollingDatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(JSON.stringify(cloneDeep(configWithFeatures))), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()), - }); - var manager = projectConfigManager.createProjectConfigManager({ - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - assert.isNull(manager.getConfig()); - return manager.onReady().then(function(result) { - assert.include(result, { - success: true, - }); - assert.deepEqual(manager.getConfig(), projectConfig.createProjectConfig(configWithFeatures)); - - var nextDatafile = testData.getTestProjectConfigWithFeatures(); - nextDatafile.experiments.push({ - key: 'anotherTestExp', - status: 'Running', - forcedVariations: {}, - audienceIds: [], - layerId: '253442', - trafficAllocation: [{ entityId: '99977477477747747', endOfRange: 10000 }], - id: '1237847778', - variations: [{ key: 'variation', id: '99977477477747747' }], - }); - nextDatafile.revision = '36'; - var fakeDatafileManager = datafileManager.HttpPollingDatafileManager.getCall(0).returnValue; - fakeDatafileManager.get.returns(cloneDeep(nextDatafile)); - var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - updateListener({ datafile: nextDatafile }); - assert.deepEqual(manager.getConfig(), projectConfig.createProjectConfig(nextDatafile)); - }); - }); - - 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(), - get: sinon.stub().returns(JSON.stringify(testData.getTestProjectConfigWithFeatures())), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()), - }); - var manager = projectConfigManager.createProjectConfigManager({ - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - var onUpdateSpy = sinon.spy(); - manager.onUpdate(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', async 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()), - }); - var manager = projectConfigManager.createProjectConfigManager({ - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - 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 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() { - var invalidDatafile = testData.getTestProjectConfig(); - delete invalidDatafile['projectId']; - datafileManager.HttpPollingDatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(JSON.stringify(invalidDatafile)), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()), - }); - var manager = projectConfigManager.createProjectConfigManager({ - jsonSchemaValidator: jsonSchemaValidator, - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - it('fullfils its ready promise with an unsuccessful result when the datafile manager onReady promise rejects', function() { - datafileManager.HttpPollingDatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(null), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.reject(new Error('Failed to become ready'))), - }); - var manager = projectConfigManager.createProjectConfigManager({ - jsonSchemaValidator: jsonSchemaValidator, - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - return manager.onReady().then(function(result) { - assert.include(result, { - success: false, - }); - }); - }); - - it('calls stop on its datafile manager when its stop method is called', function() { - var manager = projectConfigManager.createProjectConfigManager({ - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - manager.stop(); - sinon.assert.calledOnce(datafileManager.HttpPollingDatafileManager.getCall(0).returnValue.stop); - }); - - it('does not log an error message', function() { - projectConfigManager.createProjectConfigManager({ - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger), - }); - sinon.assert.notCalled(stubLogHandler.log); - }); - }); - - 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 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: mockDatafileManager, - }); - var onUpdateSpy = sinon.spy(); - manager.onUpdate(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 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: mockDatafileManager, - }); - var onUpdateSpy = sinon.spy(); - manager.onUpdate(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('test caching of optimizely config', function() { - beforeEach(function() { - sinon.stub(optimizelyConfig, 'createOptimizelyConfig'); - }); - - afterEach(function() { - optimizelyConfig.createOptimizelyConfig.restore(); - }); - - it('should return the same config until revision is changed', function() { - var manager = projectConfigManager.createProjectConfigManager({ - datafile: testData.getTestProjectConfig(), - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', logger, testData.getTestProjectConfig()), - }); - // validate it should return the existing optimizely config - manager.getOptimizelyConfig(); - sinon.assert.calledOnce(optimizelyConfig.createOptimizelyConfig); - // create config with new revision - 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 }); - manager.getOptimizelyConfig(); - // verify the optimizely config is updated - sinon.assert.calledTwice(optimizelyConfig.createOptimizelyConfig); - }); - }); - }); -}); diff --git a/lib/core/project_config/project_config_manager.ts b/lib/core/project_config/project_config_manager.ts deleted file mode 100644 index b0fe25ddd..000000000 --- a/lib/core/project_config/project_config_manager.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * 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. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { getLogger } from '../../modules/logging'; -import { sprintf } from '../../utils/fns'; - -import { ERROR_MESSAGES } from '../../utils/enums'; -import { createOptimizelyConfig } from '../optimizely_config'; -import { OnReadyResult, OptimizelyConfig, DatafileManager } from '../../shared_types'; -import { ProjectConfig, toDatafile, tryCreatingProjectConfig } from '../project_config'; -import { scheduleMicrotaskOrTimeout } from '../../utils/microtask'; - -const logger = getLogger(); -const MODULE_NAME = 'PROJECT_CONFIG_MANAGER'; - -interface ProjectConfigManagerConfig { - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile?: string | object; - jsonSchemaValidator?: { - validate(jsonObject: unknown): boolean; - }; - sdkKey?: string; - datafileManager?: DatafileManager; -} - -/** - * Return an error message derived from a thrown value. If the thrown value is - * an error, return the error's message property. Otherwise, return a default - * provided by the second argument. - * @param {Error|null} maybeError - * @param {string} defaultMessage - * @return {string} - */ -function getErrorMessage(maybeError: Error | null, defaultMessage?: string): string { - if (maybeError instanceof Error) { - return maybeError.message; - } - return defaultMessage || 'Unknown error'; -} - -/** - * ProjectConfigManager provides project config objects via its methods - * getConfig and onUpdate. It uses a DatafileManager to fetch datafiles. It is - * responsible for parsing and validating datafiles, and converting datafile - * string into project config objects. - * @param {ProjectConfigManagerConfig} config - */ -export class ProjectConfigManager { - private updateListeners: Array<(config: ProjectConfig) => void> = []; - private configObj: ProjectConfig | null = null; - private optimizelyConfigObj: OptimizelyConfig | null = null; - private readyPromise: Promise; - public jsonSchemaValidator: { validate(jsonObject: unknown): boolean } | undefined; - public datafileManager: DatafileManager | null = null; - - constructor(config: ProjectConfigManagerConfig) { - try { - this.jsonSchemaValidator = config.jsonSchemaValidator; - - if (!config.datafile && !config.sdkKey) { - const datafileAndSdkKeyMissingError = new Error( - sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME) - ); - this.readyPromise = Promise.resolve({ - success: false, - reason: getErrorMessage(datafileAndSdkKeyMissingError), - }); - logger.error(datafileAndSdkKeyMissingError); - return; - } - - let handleNewDatafileException = null; - if (config.datafile) { - handleNewDatafileException = this.handleNewDatafile(config.datafile); - } - - 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)); - this.datafileManager.on('update', this.onDatafileManagerUpdate.bind(this)); - } else if (this.configObj) { - this.readyPromise = Promise.resolve({ - success: true, - }); - } else { - this.readyPromise = Promise.resolve({ - success: false, - reason: getErrorMessage(handleNewDatafileException, 'Invalid datafile'), - }); - } - } catch (ex) { - logger.error(ex); - this.readyPromise = Promise.resolve({ - success: false, - reason: getErrorMessage(ex, 'Error in initialize'), - }); - } - } - - /** - * Respond to datafile manager's onReady promise becoming fulfilled. - * If there are validation or parse failures using the datafile provided by - * DatafileManager, ProjectConfigManager's ready promise is resolved with an - * unsuccessful result. Otherwise, ProjectConfigManager updates its own project - * config object from the new datafile, and its ready promise is resolved with a - * successful result. - */ - private onDatafileManagerReadyFulfill(): OnReadyResult { - if (this.datafileManager) { - const newDatafileError = this.handleNewDatafile(this.datafileManager.get()); - if (newDatafileError) { - return { - success: false, - reason: getErrorMessage(newDatafileError), - }; - } - return { success: true }; - } - - return { - success: false, - reason: getErrorMessage(null, 'Datafile manager is not provided'), - }; - } - - /** - * Respond to datafile manager's onReady promise becoming rejected. - * When DatafileManager's onReady promise is rejected, there is no possibility - * of obtaining a datafile. In this case, ProjectConfigManager's ready promise - * is fulfilled with an unsuccessful result. - * @param {Error} err - * @returns {Object} - */ - private onDatafileManagerReadyReject(err: Error): OnReadyResult { - return { - success: false, - reason: getErrorMessage(err, 'Failed to become ready'), - }; - } - - /** - * Respond to datafile manager's update event. Attempt to update own config - * object using latest datafile from datafile manager. Call own registered - * update listeners if successful - */ - private onDatafileManagerUpdate(): void { - if (this.datafileManager) { - this.handleNewDatafile(this.datafileManager.get()); - } - } - - /** - * Handle new datafile by attemping to create a new Project Config object. If successful and - * the new config object's revision is newer than the current one, sets/updates the project config - * and optimizely config object instance variables and returns null for the error. If unsuccessful, - * the project config and optimizely config objects will not be updated, and the error is returned. - * @param {string | object} newDatafile - * @returns {Error|null} error or null - */ - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - private handleNewDatafile(newDatafile: string | object): Error | null { - const { configObj, error } = tryCreatingProjectConfig({ - datafile: newDatafile, - jsonSchemaValidator: this.jsonSchemaValidator, - logger: logger, - }); - - if (error) { - logger.error(error); - } else { - const oldRevision = this.configObj ? this.configObj.revision : 'null'; - if (configObj && oldRevision !== configObj.revision) { - this.configObj = configObj; - this.optimizelyConfigObj = null; - scheduleMicrotaskOrTimeout(() => { - this.updateListeners.forEach(listener => listener(configObj)); - }) - } - } - - return error; - } - - /** - * Returns the current project config object, or null if no project config object - * is available - * @return {ProjectConfig|null} - */ - getConfig(): ProjectConfig | null { - return this.configObj; - } - - /** - * Returns the optimizely config object or null - * @return {OptimizelyConfig|null} - */ - getOptimizelyConfig(): OptimizelyConfig | null { - if (!this.optimizelyConfigObj && this.configObj) { - this.optimizelyConfigObj = createOptimizelyConfig(this.configObj, toDatafile(this.configObj), logger); - } - return this.optimizelyConfigObj; - } - - /** - * Returns a Promise that fulfills when this ProjectConfigManager is ready to - * use (meaning it has a valid project config object), or has failed to become - * ready. - * - * Failure can be caused by the following: - * - At least one of sdkKey or datafile is not provided in the constructor argument - * - The provided datafile was invalid - * - The datafile provided by the datafile manager was invalid - * - The datafile manager failed to fetch a datafile - * - * The returned Promise is fulfilled with a result object containing these - * properties: - * - success (boolean): True if this instance is ready to use with a valid - * project config object, or false if it failed to - * become ready - * - reason (string=): If success is false, this is a string property with - * an explanatory message. - * @return {Promise} - */ - onReady(): Promise { - return this.readyPromise; - } - - /** - * Add a listener for project config updates. The listener will be called - * whenever this instance has a new project config object available. - * Returns a dispose function that removes the subscription - * @param {Function} listener - * @return {Function} - */ - onUpdate(listener: (config: ProjectConfig) => void): () => void { - this.updateListeners.push(listener); - return () => { - const index = this.updateListeners.indexOf(listener); - if (index > -1) { - this.updateListeners.splice(index, 1); - } - }; - } - - /** - * Stop the internal datafile manager and remove all update listeners - */ - stop(): void { - if (this.datafileManager) { - this.datafileManager.stop(); - } - this.updateListeners = []; - } -} - -export function createProjectConfigManager(config: ProjectConfigManagerConfig): ProjectConfigManager { - return new ProjectConfigManager(config); -} diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index e14b91463..8b43a4902 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -33,6 +33,8 @@ import { OdpConfig } from './core/odp/odp_config'; import { BrowserOdpEventManager } from './plugins/odp/event_manager/index.browser'; import { BrowserOdpEventApiManager } from './plugins/odp/event_api_manager/index.browser'; import { OdpEvent } from './core/odp/odp_event'; +import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; +import { createProjectConfig } from './project_config/project_config'; var LocalStoragePendingEventsDispatcher = eventProcessor.LocalStoragePendingEventsDispatcher; @@ -123,12 +125,10 @@ describe('javascript-sdk (Browser)', function() { describe('when an eventDispatcher is not passed in', function() { it('should wrap the default eventDispatcher and invoke sendPendingEvents', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents); }); @@ -137,13 +137,11 @@ describe('javascript-sdk (Browser)', function() { describe('when an eventDispatcher is passed in', function() { it('should NOT wrap the default eventDispatcher and invoke sendPendingEvents', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); sinon.assert.notCalled(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents); }); @@ -151,17 +149,15 @@ describe('javascript-sdk (Browser)', function() { it('should invoke resendPendingEvents at most once', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents); optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, logger: silentLogger, }); @@ -174,23 +170,19 @@ describe('javascript-sdk (Browser)', function() { configValidator.validate.throws(new Error('Invalid config or something')); assert.doesNotThrow(function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); }); }); it('should create an instance of optimizely', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); assert.equal(optlyInstance.clientVersion, '5.3.4'); @@ -198,13 +190,12 @@ describe('javascript-sdk (Browser)', function() { it('should set the JavaScript client engine and version', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); + assert.equal('javascript-sdk', optlyInstance.clientEngine); assert.equal(packageJSON.version, optlyInstance.clientVersion); }); @@ -212,19 +203,19 @@ describe('javascript-sdk (Browser)', function() { it('should allow passing of "react-sdk" as the clientEngine', function() { var optlyInstance = optimizelyFactory.createInstance({ clientEngine: 'react-sdk', - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - optlyInstance.onReady().catch(function() {}); assert.equal('react-sdk', optlyInstance.clientEngine); }); it('should activate with provided event dispatcher', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -235,7 +226,9 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set and get a forced variation', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -250,7 +243,9 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set and unset a forced variation', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -271,7 +266,9 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -296,7 +293,9 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user, and unset one', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -324,7 +323,9 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user, and reset one', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -356,7 +357,9 @@ describe('javascript-sdk (Browser)', function() { it('should override bucketing when setForcedVariation is called', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -377,7 +380,9 @@ describe('javascript-sdk (Browser)', function() { it('should override bucketing when setForcedVariation is called for a not running experiment', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), errorHandler: fakeErrorHandler, eventDispatcher: optimizelyFactory.eventDispatcher, logger: silentLogger, @@ -656,7 +661,10 @@ describe('javascript-sdk (Browser)', function() { it('should include the VUID instantation promise of Browser ODP Manager in the Optimizely client onReady promise dependency array', () => { const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + onRunning: Promise.resolve(), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -688,7 +696,10 @@ describe('javascript-sdk (Browser)', function() { it('should accept a valid custom cache size', () => { const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + onRunning: Promise.resolve(), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -762,8 +773,14 @@ describe('javascript-sdk (Browser)', function() { updateSettings: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -773,13 +790,12 @@ describe('javascript-sdk (Browser)', function() { }, }); + projectConfigManager.pushUpdate(config); + const readyData = await client.onReady(); sinon.assert.called(fakeSegmentManager.updateSettings); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); - const segments = await client.fetchQualifiedSegments(testVuid); assert.deepEqual(segments, ['a']); @@ -798,8 +814,14 @@ describe('javascript-sdk (Browser)', function() { sendEvent: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -809,9 +831,9 @@ describe('javascript-sdk (Browser)', function() { eventManager: fakeEventManager, }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + + await client.onReady(); sinon.assert.called(fakeEventManager.start); }); @@ -827,8 +849,14 @@ describe('javascript-sdk (Browser)', function() { flush: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -838,9 +866,8 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); @@ -867,8 +894,14 @@ describe('javascript-sdk (Browser)', function() { }), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -878,10 +911,8 @@ describe('javascript-sdk (Browser)', function() { eventRequestHandler: fakeRequestHandler, }, }); - const readyData = await client.onReady(); - - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); client.sendOdpEvent('test', '', new Map([['eamil', 'test@test.test']]), new Map([['key', 'value']])); clock.tick(10000); @@ -905,9 +936,14 @@ describe('javascript-sdk (Browser)', function() { sendEvent: sinon.spy(), flush: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -917,9 +953,8 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); // fs-user-id client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED, undefined, new Map([['fs-user-id', 'fsUserA']])); @@ -977,8 +1012,15 @@ describe('javascript-sdk (Browser)', function() { flush: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -988,9 +1030,8 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); client.sendOdpEvent(''); sinon.assert.called(logger.error); @@ -1015,8 +1056,14 @@ describe('javascript-sdk (Browser)', function() { flush: sinon.spy(), }; + const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1025,10 +1072,8 @@ describe('javascript-sdk (Browser)', function() { eventManager: fakeEventManager, }, }); - - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); client.sendOdpEvent('dummy-action', ''); @@ -1039,8 +1084,14 @@ describe('javascript-sdk (Browser)', function() { }); it('should log an error when attempting to send an odp event when odp is disabled', async () => { + const config = createProjectConfig(testData.getTestProjectConfigWithFeatures()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1050,9 +1101,10 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + + await client.onReady(); + assert.isUndefined(client.odpManager); sinon.assert.calledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.INFO, 'ODP Disabled.'); @@ -1065,8 +1117,14 @@ describe('javascript-sdk (Browser)', function() { }); it('should log a warning when attempting to use an event batch size other than 1', async () => { + const config = createProjectConfig(testData.getTestProjectConfigWithFeatures()); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile: testData.getOdpIntegratedConfigWithSegments(), + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1076,9 +1134,9 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + + await client.onReady(); client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); @@ -1103,9 +1161,14 @@ describe('javascript-sdk (Browser)', function() { }); let datafile = testData.getOdpIntegratedConfigWithSegments(); + const config = createProjectConfig(datafile); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); const client = optimizelyFactory.createInstance({ - datafile, + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1116,9 +1179,8 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); @@ -1157,8 +1219,14 @@ describe('javascript-sdk (Browser)', function() { logger, }); const datafile = testData.getOdpIntegratedConfigWithSegments(); + const config = createProjectConfig(datafile); + const projectConfigManager = getMockProjectConfigManager({ + initConfig: config, + onRunning: Promise.resolve(), + }); + const client = optimizelyFactory.createInstance({ - datafile, + projectConfigManager, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, @@ -1169,9 +1237,8 @@ describe('javascript-sdk (Browser)', function() { }, }); - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); + projectConfigManager.pushUpdate(config); + await client.onReady(); clock.tick(100); diff --git a/lib/index.browser.ts b/lib/index.browser.ts index c0d62897c..f80a7b2c3 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -27,12 +27,13 @@ import eventProcessorConfigValidator from './utils/event_processor_config_valida import { createNotificationCenter } from './core/notification_center'; import { default as eventProcessor } from './plugins/event_processor'; import { OptimizelyDecideOption, Client, Config, OptimizelyOptions } from './shared_types'; -import { createHttpPollingDatafileManager } from './plugins/datafile_manager/browser_http_polling_datafile_manager'; import { BrowserOdpManager } from './plugins/odp_manager/index.browser'; import Optimizely from './optimizely'; import { IUserAgentParser } from './core/odp/user_agent_parser'; import { getUserAgentParser } from './plugins/odp/user_agent_parser/index.browser'; import * as commonExports from './common_exports'; +import { PollingConfigManagerConfig } from './project_config/config_manager_factory'; +import { createPollingProjectConfigManager } from './project_config/config_manager_factory.browser'; const logger = getLogger(); logHelper.setLogHandler(loggerPlugin.createLogger()); @@ -139,9 +140,6 @@ const createInstance = function(config: Config): Client | null { eventProcessor: eventProcessor.createEventProcessor(eventProcessorConfig), logger, errorHandler, - datafileManager: config.sdkKey - ? createHttpPollingDatafileManager(config.sdkKey, logger, config.datafile, config.datafileOptions) - : undefined, notificationCenter, isValidInstance, odpManager: odpExplicitlyOff ? undefined @@ -198,6 +196,7 @@ export { OptimizelyDecideOption, IUserAgentParser, getUserAgentParser, + createPollingProjectConfigManager, }; export * from './common_exports'; @@ -215,6 +214,7 @@ export default { __internalResetRetryState, OptimizelyDecideOption, getUserAgentParser, + createPollingProjectConfigManager, }; export * from './export_types'; diff --git a/lib/index.lite.tests.js b/lib/index.lite.tests.js index 30282dcf5..ba67811bd 100644 --- a/lib/index.lite.tests.js +++ b/lib/index.lite.tests.js @@ -21,6 +21,7 @@ import Optimizely from './optimizely'; import * as loggerPlugin from './plugins/logger'; import optimizelyFactory from './index.lite'; import configValidator from './utils/config_validator'; +import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; describe('optimizelyFactory', function() { describe('APIs', function() { @@ -56,24 +57,20 @@ describe('optimizelyFactory', function() { var localLogger = loggerPlugin.createLogger({ logLevel: enums.LOG_LEVEL.INFO }); assert.doesNotThrow(function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), logger: localLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this - optlyInstance.onReady().catch(function() {}); }); sinon.assert.calledWith(localLogger.log, enums.LOG_LEVEL.ERROR); }); it('should create an instance of optimizely', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this - optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); assert.equal(optlyInstance.clientVersion, '5.3.4'); diff --git a/lib/index.lite.ts b/lib/index.lite.ts index 730aab7af..b6a6bdfe9 100644 --- a/lib/index.lite.ts +++ b/lib/index.lite.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021-2022, Optimizely + * Copyright 2021-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,7 +30,6 @@ import Optimizely from './optimizely'; import { createNotificationCenter } from './core/notification_center'; import { createForwardingEventProcessor } from './plugins/event_processor/forwarding_event_processor'; import { OptimizelyDecideOption, Client, ConfigLite } from './shared_types'; -import { createNoOpDatafileManager } from './plugins/datafile_manager/no_op_datafile_manager'; import * as commonExports from './common_exports'; const logger = getLogger(); @@ -78,7 +77,6 @@ setLogLevel(LogLevel.ERROR); ...config, logger, errorHandler, - datafileManager: createNoOpDatafileManager(), eventProcessor, notificationCenter, isValidInstance: isValidInstance, diff --git a/lib/index.node.tests.js b/lib/index.node.tests.js index 98aac4c97..4acbdf5f6 100644 --- a/lib/index.node.tests.js +++ b/lib/index.node.tests.js @@ -23,6 +23,8 @@ import testData from './tests/test_data'; import * as loggerPlugin from './plugins/logger'; import optimizelyFactory from './index.node'; import configValidator from './utils/config_validator'; +import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; +import { createProjectConfig } from './project_config/project_config'; describe('optimizelyFactory', function() { describe('APIs', function() { @@ -58,11 +60,9 @@ describe('optimizelyFactory', function() { var localLogger = loggerPlugin.createLogger({ logLevel: enums.LOG_LEVEL.INFO }); assert.doesNotThrow(function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), logger: localLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this - optlyInstance.onReady().catch(function() {}); }); sinon.assert.calledWith(localLogger.log, enums.LOG_LEVEL.ERROR); }); @@ -71,23 +71,19 @@ describe('optimizelyFactory', function() { configValidator.validate.throws(new Error('Invalid config or something')); assert.doesNotThrow(function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), }); - // Invalid datafile causes onReady Promise rejection - catch this - optlyInstance.onReady().catch(function() {}); }); sinon.assert.calledOnce(console.error); }); it('should create an instance of optimizely', function() { var optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this - optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); assert.equal(optlyInstance.clientVersion, '5.3.4'); @@ -105,7 +101,9 @@ describe('optimizelyFactory', function() { it('should ignore invalid event flush interval and use default instead', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, @@ -121,7 +119,9 @@ describe('optimizelyFactory', function() { it('should use default event flush interval when none is provided', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, @@ -136,7 +136,9 @@ describe('optimizelyFactory', function() { it('should use provided event flush interval when valid', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, @@ -152,7 +154,9 @@ describe('optimizelyFactory', function() { it('should ignore invalid event batch size and use default instead', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, @@ -168,7 +172,9 @@ describe('optimizelyFactory', function() { it('should use default event batch size when none is provided', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, @@ -183,7 +189,9 @@ describe('optimizelyFactory', function() { it('should use provided event batch size when valid', function() { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: fakeLogger, diff --git a/lib/index.node.ts b/lib/index.node.ts index 50a7829b8..0bb12d21e 100644 --- a/lib/index.node.ts +++ b/lib/index.node.ts @@ -1,18 +1,18 @@ -/**************************************************************************** - * 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. * - * 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. * - ***************************************************************************/ +/** + * Copyright 2016-2017, 2019-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. + */ import { getLogger, setErrorHandler, getErrorHandler, LogLevel, setLogHandler, setLogLevel } from './modules/logging'; import Optimizely from './optimizely'; @@ -25,9 +25,9 @@ import eventProcessorConfigValidator from './utils/event_processor_config_valida import { createNotificationCenter } from './core/notification_center'; import { createEventProcessor } from './plugins/event_processor'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; -import { createHttpPollingDatafileManager } from './plugins/datafile_manager/http_polling_datafile_manager'; import { NodeOdpManager } from './plugins/odp_manager/index.node'; import * as commonExports from './common_exports'; +import { createPollingProjectConfigManager } from './project_config/config_manager_factory.node'; const logger = getLogger(); setLogLevel(LogLevel.ERROR); @@ -115,9 +115,6 @@ const createInstance = function(config: Config): Client | null { eventProcessor, logger, errorHandler, - datafileManager: config.sdkKey - ? createHttpPollingDatafileManager(config.sdkKey, logger, config.datafile, config.datafileOptions) - : undefined, notificationCenter, isValidInstance, odpManager: odpExplicitlyOff ? undefined @@ -144,6 +141,7 @@ export { setLogLevel, createInstance, OptimizelyDecideOption, + createPollingProjectConfigManager }; export * from './common_exports'; @@ -158,6 +156,7 @@ export default { setLogLevel, createInstance, OptimizelyDecideOption, + createPollingProjectConfigManager }; export * from './export_types'; diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index ee5a1975c..3be9b300c 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -25,9 +25,9 @@ import eventProcessorConfigValidator from './utils/event_processor_config_valida import { createNotificationCenter } from './core/notification_center'; import { createEventProcessor } from './plugins/event_processor/index.react_native'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; -import { createHttpPollingDatafileManager } from './plugins/datafile_manager/react_native_http_polling_datafile_manager'; import { BrowserOdpManager } from './plugins/odp_manager/index.browser'; import * as commonExports from './common_exports'; +import { createPollingProjectConfigManager } from './project_config/config_manager_factory.react_native'; import 'fast-text-encoding'; import 'react-native-get-random-values'; @@ -114,15 +114,6 @@ const createInstance = function(config: Config): Client | null { eventProcessor, logger, errorHandler, - datafileManager: config.sdkKey - ? createHttpPollingDatafileManager( - config.sdkKey, - logger, - config.datafile, - config.datafileOptions, - config.persistentCacheProvider, - ) - : undefined, notificationCenter, isValidInstance: isValidInstance, odpManager: odpExplicitlyOff ? undefined @@ -154,6 +145,7 @@ export { setLogLevel, createInstance, OptimizelyDecideOption, + createPollingProjectConfigManager, }; export * from './common_exports'; @@ -168,6 +160,7 @@ export default { setLogLevel, createInstance, OptimizelyDecideOption, + createPollingProjectConfigManager, }; export * from './export_types'; diff --git a/lib/modules/datafile-manager/backoffController.ts b/lib/modules/datafile-manager/backoffController.ts deleted file mode 100644 index 8021f8cbd..000000000 --- a/lib/modules/datafile-manager/backoffController.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright 2019-2020, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT } from './config'; - -function randomMilliseconds(): number { - return Math.round(Math.random() * 1000); -} - -export default class BackoffController { - private errorCount = 0; - - getDelay(): number { - if (this.errorCount === 0) { - return 0; - } - const baseWaitSeconds = - BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT[ - Math.min(BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1, this.errorCount) - ]; - return baseWaitSeconds * 1000 + randomMilliseconds(); - } - - countError(): void { - if (this.errorCount < BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1) { - this.errorCount++; - } - } - - reset(): void { - this.errorCount = 0; - } -} diff --git a/lib/modules/datafile-manager/browserDatafileManager.ts b/lib/modules/datafile-manager/browserDatafileManager.ts deleted file mode 100644 index 84ab1b10b..000000000 --- a/lib/modules/datafile-manager/browserDatafileManager.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { makeGetRequest } from './browserRequest'; -import HttpPollingDatafileManager from './httpPollingDatafileManager'; -import { Headers, AbortableRequest } from './http'; -import { DatafileManagerConfig } from './datafileManager'; - -export default class BrowserDatafileManager extends HttpPollingDatafileManager { - protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { - return makeGetRequest(reqUrl, headers); - } - - protected getConfigDefaults(): Partial { - return { - autoUpdate: false, - }; - } -} diff --git a/lib/modules/datafile-manager/browserRequest.ts b/lib/modules/datafile-manager/browserRequest.ts deleted file mode 100644 index ce47a63eb..000000000 --- a/lib/modules/datafile-manager/browserRequest.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { AbortableRequest, Response, Headers } from './http'; -import { REQUEST_TIMEOUT_MS } from './config'; -import { getLogger } from '../logging'; - -const logger = getLogger('DatafileManager'); - -const GET_METHOD = 'GET'; -const READY_STATE_DONE = 4; - -function parseHeadersFromXhr(req: XMLHttpRequest): Headers { - const allHeadersString = req.getAllResponseHeaders(); - - if (allHeadersString === null) { - return {}; - } - - const headerLines = allHeadersString.split('\r\n'); - const headers: Headers = {}; - headerLines.forEach(headerLine => { - const separatorIndex = headerLine.indexOf(': '); - if (separatorIndex > -1) { - const headerName = headerLine.slice(0, separatorIndex); - const headerValue = headerLine.slice(separatorIndex + 2); - if (headerValue.length > 0) { - headers[headerName] = headerValue; - } - } - }); - return headers; -} - -function setHeadersInXhr(headers: Headers, req: XMLHttpRequest): void { - Object.keys(headers).forEach(headerName => { - const header = headers[headerName]; - req.setRequestHeader(headerName, header!); - }); -} - -export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { - const req = new XMLHttpRequest(); - - const responsePromise: Promise = new Promise((resolve, reject) => { - req.open(GET_METHOD, reqUrl, true); - - setHeadersInXhr(headers, req); - - req.onreadystatechange = (): void => { - if (req.readyState === READY_STATE_DONE) { - const statusCode = req.status; - if (statusCode === 0) { - reject(new Error('Request error')); - return; - } - - const headers = parseHeadersFromXhr(req); - const resp: Response = { - statusCode: req.status, - body: req.responseText, - headers, - }; - resolve(resp); - } - }; - - req.timeout = REQUEST_TIMEOUT_MS; - - req.ontimeout = (): void => { - logger.error('Request timed out'); - }; - - req.send(); - }); - - return { - responsePromise, - abort(): void { - req.abort(); - }, - }; -} diff --git a/lib/modules/datafile-manager/eventEmitter.ts b/lib/modules/datafile-manager/eventEmitter.ts deleted file mode 100644 index 9cc2d8fbe..000000000 --- a/lib/modules/datafile-manager/eventEmitter.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { DatafileUpdate } from "./datafileManager"; - -export type Disposer = () => void; - -export type Listener = (arg?: any) => void; - -interface Listeners { - [index: string]: { - // index is event name - [index: string]: Listener; // index is listener id - }; -} - -export default class EventEmitter { - private listeners: Listeners = {}; - - private listenerId = 1; - - on(eventName: string, listener: Listener): Disposer { - if (!this.listeners[eventName]) { - this.listeners[eventName] = {}; - } - const currentListenerId = String(this.listenerId); - this.listenerId++; - this.listeners[eventName][currentListenerId] = listener; - return (): void => { - if (this.listeners[eventName]) { - delete this.listeners[eventName][currentListenerId]; - } - }; - } - - emit(eventName: string, arg?: DatafileUpdate): void { - const listeners = this.listeners[eventName]; - if (listeners) { - Object.keys(listeners).forEach(listenerId => { - const listener = listeners[listenerId]; - listener(arg); - }); - } - } - - removeAllListeners(): void { - this.listeners = {}; - } -} - -// TODO: Create a typed event emitter for use in TS only (not JS) diff --git a/lib/modules/datafile-manager/http.ts b/lib/modules/datafile-manager/http.ts deleted file mode 100644 index d4505dc59..000000000 --- a/lib/modules/datafile-manager/http.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Headers is the interface that bridges between the abstract datafile manager and - * any Node-or-browser-specific http header types. - * It's simplified and can only store one value per header name. - * We can extend or replace this type if requirements change and we need - * to work with multiple values per header name. - */ -export interface Headers { - [header: string]: string | undefined; -} - -export interface Response { - statusCode?: number; - body: string; - headers: Headers; -} - -export interface AbortableRequest { - abort(): void; - responsePromise: Promise; -} diff --git a/lib/modules/datafile-manager/httpPollingDatafileManager.ts b/lib/modules/datafile-manager/httpPollingDatafileManager.ts deleted file mode 100644 index 6dfce4c37..000000000 --- a/lib/modules/datafile-manager/httpPollingDatafileManager.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * 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. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getLogger } from '../logging'; -import { sprintf } from '../../utils/fns'; -import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'; -import EventEmitter, { Disposer } from './eventEmitter'; -import { AbortableRequest, Response, Headers } from './http'; -import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE, UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE } from './config'; -import BackoffController from './backoffController'; -import PersistentKeyValueCache from '../../plugins/key_value_cache/persistentKeyValueCache'; - -import { NotificationRegistry } from './../../core/notification_center/notification_registry'; -import { NOTIFICATION_TYPES } from '../../utils/enums'; - -const logger = getLogger('DatafileManager'); - -const UPDATE_EVT = 'update'; - -function isSuccessStatusCode(statusCode: number): boolean { - return statusCode >= 200 && statusCode < 400; -} - -const noOpKeyValueCache: PersistentKeyValueCache = { - get(): Promise { - return Promise.resolve(undefined); - }, - - set(): Promise { - return Promise.resolve(); - }, - - contains(): Promise { - return Promise.resolve(false); - }, - - remove(): Promise { - return Promise.resolve(false); - }, -}; - -export default abstract class HttpPollingDatafileManager implements DatafileManager { - // Make an HTTP get request to the given URL with the given headers - // Return an AbortableRequest, which has a promise for a Response. - // If we can't get a response, the promise is rejected. - // The request will be aborted if the manager is stopped while the request is in flight. - protected abstract makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest; - - // Return any default configuration options that should be applied - protected abstract getConfigDefaults(): Partial; - - private currentDatafile: string; - - private readonly readyPromise: Promise; - - private isReadyPromiseSettled: boolean; - - private readyPromiseResolver: () => void; - - private readyPromiseRejecter: (err: Error) => void; - - private readonly emitter: EventEmitter; - - private readonly autoUpdate: boolean; - - private readonly updateInterval: number; - - private currentTimeout: any; - - private isStarted: boolean; - - private lastResponseLastModified?: string; - - private datafileUrl: string; - - private currentRequest: AbortableRequest | null; - - private backoffController: BackoffController; - - private cacheKey: string; - - private cache: PersistentKeyValueCache; - - private sdkKey: string; - - // When true, this means the update interval timeout fired before the current - // sync completed. In that case, we should sync again immediately upon - // completion of the current request, instead of waiting another update - // interval. - private syncOnCurrentRequestComplete: boolean; - - constructor(config: DatafileManagerConfig) { - const configWithDefaultsApplied: DatafileManagerConfig = { - ...this.getConfigDefaults(), - ...config, - }; - const { - datafile, - autoUpdate = false, - sdkKey, - updateInterval = DEFAULT_UPDATE_INTERVAL, - urlTemplate = DEFAULT_URL_TEMPLATE, - cache = noOpKeyValueCache, - } = configWithDefaultsApplied; - this.cache = cache; - this.cacheKey = 'opt-datafile-' + sdkKey; - this.sdkKey = sdkKey; - this.isReadyPromiseSettled = false; - this.readyPromiseResolver = (): void => { }; - this.readyPromiseRejecter = (): void => { }; - this.readyPromise = new Promise((resolve, reject) => { - this.readyPromiseResolver = resolve; - this.readyPromiseRejecter = reject; - }); - - if (datafile) { - this.currentDatafile = datafile; - if (!sdkKey) { - this.resolveReadyPromise(); - } - } else { - this.currentDatafile = ''; - } - - this.isStarted = false; - - this.datafileUrl = sprintf(urlTemplate, sdkKey); - - this.emitter = new EventEmitter(); - - this.autoUpdate = autoUpdate; - - this.updateInterval = updateInterval; - if (this.updateInterval < MIN_UPDATE_INTERVAL) { - logger.warn(UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE); - } - - this.currentTimeout = null; - - this.currentRequest = null; - - this.backoffController = new BackoffController(); - - this.syncOnCurrentRequestComplete = false; - } - - get(): string { - return this.currentDatafile; - } - - start(): void { - if (!this.isStarted) { - logger.debug('Datafile manager started'); - this.isStarted = true; - this.backoffController.reset(); - this.setDatafileFromCacheIfAvailable(); - this.syncDatafile(); - } - } - - stop(): Promise { - logger.debug('Datafile manager stopped'); - this.isStarted = false; - if (this.currentTimeout) { - clearTimeout(this.currentTimeout); - this.currentTimeout = null; - } - - this.emitter.removeAllListeners(); - - if (this.currentRequest) { - this.currentRequest.abort(); - this.currentRequest = null; - } - - return Promise.resolve(); - } - - onReady(): Promise { - return this.readyPromise; - } - - on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void): Disposer { - return this.emitter.on(eventName, listener); - } - - private onRequestRejected(err: any): void { - if (!this.isStarted) { - return; - } - - this.backoffController.countError(); - - if (err instanceof Error) { - logger.error('Error fetching datafile: %s', err.message, err); - } else if (typeof err === 'string') { - logger.error('Error fetching datafile: %s', err); - } else { - logger.error('Error fetching datafile'); - } - } - - private onRequestResolved(response: Response): void { - if (!this.isStarted) { - return; - } - - if (typeof response.statusCode !== 'undefined' && isSuccessStatusCode(response.statusCode)) { - this.backoffController.reset(); - } else { - this.backoffController.countError(); - } - - this.trySavingLastModified(response.headers); - - const datafile = this.getNextDatafileFromResponse(response); - if (datafile !== '') { - logger.info('Updating datafile from response'); - this.currentDatafile = datafile; - this.cache.set(this.cacheKey, datafile); - if (!this.isReadyPromiseSettled) { - this.resolveReadyPromise(); - } else { - const datafileUpdate: DatafileUpdate = { - datafile, - }; - NotificationRegistry.getNotificationCenter(this.sdkKey, logger)?.sendNotifications( - NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE - ); - this.emitter.emit(UPDATE_EVT, datafileUpdate); - } - } - } - - private onRequestComplete(this: HttpPollingDatafileManager): void { - if (!this.isStarted) { - return; - } - - this.currentRequest = null; - - if (!this.isReadyPromiseSettled && !this.autoUpdate) { - // We will never resolve ready, so reject it - this.rejectReadyPromise(new Error('Failed to become ready')); - } - - if (this.autoUpdate && this.syncOnCurrentRequestComplete) { - this.syncDatafile(); - } - this.syncOnCurrentRequestComplete = false; - } - - private syncDatafile(): void { - const headers: Headers = {}; - if (this.lastResponseLastModified) { - headers['if-modified-since'] = this.lastResponseLastModified; - } - - logger.debug('Making datafile request to url %s with headers: %s', this.datafileUrl, () => JSON.stringify(headers)); - this.currentRequest = this.makeGetRequest(this.datafileUrl, headers); - - const onRequestComplete = (): void => { - this.onRequestComplete(); - }; - const onRequestResolved = (response: Response): void => { - this.onRequestResolved(response); - }; - const onRequestRejected = (err: any): void => { - this.onRequestRejected(err); - }; - this.currentRequest.responsePromise - .then(onRequestResolved, onRequestRejected) - .then(onRequestComplete, onRequestComplete); - - if (this.autoUpdate) { - this.scheduleNextUpdate(); - } - } - - private resolveReadyPromise(): void { - this.readyPromiseResolver(); - this.isReadyPromiseSettled = true; - } - - private rejectReadyPromise(err: Error): void { - this.readyPromiseRejecter(err); - this.isReadyPromiseSettled = true; - } - - private scheduleNextUpdate(): void { - const currentBackoffDelay = this.backoffController.getDelay(); - const nextUpdateDelay = Math.max(currentBackoffDelay, this.updateInterval); - logger.debug('Scheduling sync in %s ms', nextUpdateDelay); - this.currentTimeout = setTimeout(() => { - if (this.currentRequest) { - this.syncOnCurrentRequestComplete = true; - } else { - this.syncDatafile(); - } - }, nextUpdateDelay); - } - - private getNextDatafileFromResponse(response: Response): string { - logger.debug('Response status code: %s', response.statusCode); - if (typeof response.statusCode === 'undefined') { - return ''; - } - if (response.statusCode === 304) { - return ''; - } - if (isSuccessStatusCode(response.statusCode)) { - return response.body; - } - logger.error(`Datafile fetch request failed with status: ${response.statusCode}`); - return ''; - } - - private trySavingLastModified(headers: Headers): void { - const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified']; - if (typeof lastModifiedHeader !== 'undefined') { - this.lastResponseLastModified = lastModifiedHeader; - logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified); - } - } - - setDatafileFromCacheIfAvailable(): void { - this.cache.get(this.cacheKey).then(datafile => { - if (this.isStarted && !this.isReadyPromiseSettled && datafile) { - logger.debug('Using datafile from cache'); - this.currentDatafile = datafile; - this.resolveReadyPromise(); - } - }); - } -} diff --git a/lib/modules/datafile-manager/index.react_native.ts b/lib/modules/datafile-manager/index.react_native.ts deleted file mode 100644 index fa42e20ca..000000000 --- a/lib/modules/datafile-manager/index.react_native.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './datafileManager'; -export { default as HttpPollingDatafileManager } from './reactNativeDatafileManager'; diff --git a/lib/modules/datafile-manager/nodeDatafileManager.ts b/lib/modules/datafile-manager/nodeDatafileManager.ts deleted file mode 100644 index d97e14920..000000000 --- a/lib/modules/datafile-manager/nodeDatafileManager.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getLogger } from '../logging'; -import { makeGetRequest } from './nodeRequest'; -import HttpPollingDatafileManager from './httpPollingDatafileManager'; -import { Headers, AbortableRequest } from './http'; -import { NodeDatafileManagerConfig, DatafileManagerConfig } from './datafileManager'; -import { DEFAULT_URL_TEMPLATE, DEFAULT_AUTHENTICATED_URL_TEMPLATE } from './config'; - -const logger = getLogger('NodeDatafileManager'); - -export default class NodeDatafileManager extends HttpPollingDatafileManager { - private accessToken?: string; - - constructor(config: NodeDatafileManagerConfig) { - const defaultUrlTemplate = config.datafileAccessToken ? DEFAULT_AUTHENTICATED_URL_TEMPLATE : DEFAULT_URL_TEMPLATE; - super({ - ...config, - urlTemplate: config.urlTemplate || defaultUrlTemplate, - }); - this.accessToken = config.datafileAccessToken; - } - - protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { - const requestHeaders = Object.assign({}, headers); - if (this.accessToken) { - logger.debug('Adding Authorization header with Bearer Token'); - requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; - } - return makeGetRequest(reqUrl, requestHeaders); - } - - protected getConfigDefaults(): Partial { - return { - autoUpdate: true, - }; - } -} diff --git a/lib/modules/datafile-manager/nodeRequest.ts b/lib/modules/datafile-manager/nodeRequest.ts deleted file mode 100644 index 24ceed0e1..000000000 --- a/lib/modules/datafile-manager/nodeRequest.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import http from 'http'; -import https from 'https'; -import url from 'url'; -import { Headers, AbortableRequest, Response } from './http'; -import { REQUEST_TIMEOUT_MS } from './config'; -import decompressResponse from 'decompress-response'; - -// Shared signature between http.request and https.request -type ClientRequestCreator = (options: http.RequestOptions) => http.ClientRequest; - -function getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOptions { - return { - hostname: url.hostname, - path: url.path, - port: url.port, - protocol: url.protocol, - }; -} - -/** - * Convert incomingMessage.headers (which has type http.IncomingHttpHeaders) into our Headers type defined in src/http.ts. - * - * Our Headers type is simplified and can't represent mutliple values for the same header name. - * - * We don't currently need multiple values support, and the consumer code becomes simpler if it can assume at-most 1 value - * per header name. - * - */ -function createHeadersFromNodeIncomingMessage(incomingMessage: http.IncomingMessage): Headers { - const headers: Headers = {}; - Object.keys(incomingMessage.headers).forEach(headerName => { - const headerValue = incomingMessage.headers[headerName]; - if (typeof headerValue === 'string') { - headers[headerName] = headerValue; - } else if (typeof headerValue === 'undefined') { - // no value provided for this header - } else { - // array - if (headerValue.length > 0) { - // We don't care about multiple values - just take the first one - headers[headerName] = headerValue[0]; - } - } - }); - return headers; -} - -function getResponseFromRequest(request: http.ClientRequest): Promise { - // TODO: When we drop support for Node 6, consider using util.promisify instead of - // constructing own Promise - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - request.abort(); - reject(new Error('Request timed out')); - }, REQUEST_TIMEOUT_MS); - - request.once('response', (incomingMessage: http.IncomingMessage) => { - if (request.aborted) { - return; - } - - const response = decompressResponse(incomingMessage); - - response.setEncoding('utf8'); - - let responseData = ''; - response.on('data', (chunk: string) => { - if (!request.aborted) { - responseData += chunk; - } - }); - - response.on('end', () => { - if (request.aborted) { - return; - } - - clearTimeout(timeout); - - resolve({ - statusCode: incomingMessage.statusCode, - body: responseData, - headers: createHeadersFromNodeIncomingMessage(incomingMessage), - }); - }); - }); - - request.on('error', (err: any) => { - clearTimeout(timeout); - - if (err instanceof Error) { - reject(err); - } else if (typeof err === 'string') { - reject(new Error(err)); - } else { - reject(new Error('Request error')); - } - }); - }); -} - -export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { - // TODO: Use non-legacy URL parsing when we drop support for Node 6 - const parsedUrl = url.parse(reqUrl); - - let requester: ClientRequestCreator; - if (parsedUrl.protocol === 'http:') { - requester = http.request; - } else if (parsedUrl.protocol === 'https:') { - requester = https.request; - } else { - return { - responsePromise: Promise.reject(new Error(`Unsupported protocol: ${parsedUrl.protocol}`)), - abort(): void {}, - }; - } - - const requestOptions: http.RequestOptions = { - ...getRequestOptionsFromUrl(parsedUrl), - method: 'GET', - headers: { - ...headers, - 'accept-encoding': 'gzip,deflate', - }, - }; - - const request = requester(requestOptions); - const responsePromise = getResponseFromRequest(request); - - request.end(); - - return { - abort(): void { - request.abort(); - }, - responsePromise, - }; -} diff --git a/lib/modules/datafile-manager/reactNativeDatafileManager.ts b/lib/modules/datafile-manager/reactNativeDatafileManager.ts deleted file mode 100644 index c3857c2fe..000000000 --- a/lib/modules/datafile-manager/reactNativeDatafileManager.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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. - * 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 { makeGetRequest } from './browserRequest'; -import HttpPollingDatafileManager from './httpPollingDatafileManager'; -import { Headers, AbortableRequest } from './http'; -import { DatafileManagerConfig } from './datafileManager'; -import ReactNativeAsyncStorageCache from '../../plugins/key_value_cache/reactNativeAsyncStorageCache'; - -export default class ReactNativeDatafileManager extends HttpPollingDatafileManager { - protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { - return makeGetRequest(reqUrl, headers); - } - - protected getConfigDefaults(): Partial { - return { - autoUpdate: true, - cache: new ReactNativeAsyncStorageCache(), - }; - } -} diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 3f5b3e232..9047ee71a 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -1,18 +1,18 @@ -/**************************************************************************** - * 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. * - * 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. * - ***************************************************************************/ +/** + * 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { assert, expect } from 'chai'; import sinon from 'sinon'; import { sprintf } from '../utils/fns'; @@ -24,7 +24,7 @@ import OptimizelyUserContext from '../optimizely_user_context'; import { OptimizelyDecideOption } from '../shared_types'; import AudienceEvaluator from '../core/audience_evaluator'; import * as bucketer from '../core/bucketer'; -import * as projectConfigManager from '../core/project_config/project_config_manager'; +import * as projectConfigManager from '../project_config/project_config_manager'; import * as enums from '../utils/enums'; import eventDispatcher from '../plugins/event_dispatcher/index.node'; import errorHandler from '../plugins/error_handler'; @@ -32,13 +32,14 @@ import fns from '../utils/fns'; import * as logger from '../plugins/logger'; import * as decisionService from '../core/decision_service'; import * as jsonSchemaValidator from '../utils/json_schema_validator'; -import * as projectConfig from '../core/project_config'; +import * as projectConfig from '../project_config/project_config'; import testData from '../tests/test_data'; import { createForwardingEventProcessor } from '../plugins/event_processor/forwarding_event_processor'; import { createEventProcessor } from '../plugins/event_processor'; import { createNotificationCenter } from '../core/notification_center'; -import { createHttpPollingDatafileManager } from '../plugins/datafile_manager/http_polling_datafile_manager'; import { NodeOdpManager } from '../plugins/odp_manager/index.node'; +import { createProjectConfig } from '../project_config/project_config'; +import { getMockProjectConfigManager } from '../tests/mock/mock_project_config_manager'; var ERROR_MESSAGES = enums.ERROR_MESSAGES; var LOG_LEVEL = enums.LOG_LEVEL; @@ -109,37 +110,9 @@ describe('lib/optimizely', function() { }); describe('constructor', function() { - it('should construct an instance of the Optimizely class', function() { - var optlyInstance = new Optimizely({ - clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - notificationCenter, - eventProcessor, - }); - assert.instanceOf(optlyInstance, Optimizely); - }); - - it('should construct an instance of the Optimizely class when datafile is JSON string', function() { - var optlyInstance = new Optimizely({ - clientEngine: 'node-sdk', - datafile: JSON.stringify(testData.getTestProjectConfig()), - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - notificationCenter, - eventProcessor, - }); - assert.instanceOf(optlyInstance, Optimizely); - }); - it('should log if the client engine passed in is invalid', function() { new Optimizely({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager(), errorHandler: stubErrorHandler, eventDispatcher: stubEventDispatcher, logger: createdLogger, @@ -154,8 +127,8 @@ describe('lib/optimizely', function() { it('should log if the defaultDecideOptions passed in are invalid', function() { new Optimizely({ + projectConfigManager: getMockProjectConfigManager(), clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), errorHandler: stubErrorHandler, eventDispatcher: stubEventDispatcher, logger: createdLogger, @@ -171,8 +144,8 @@ describe('lib/optimizely', function() { it('should allow passing `react-sdk` as the clientEngine', function() { var instance = new Optimizely({ + projectConfigManager: getMockProjectConfigManager(), clientEngine: 'react-sdk', - datafile: testData.getTestProjectConfig(), errorHandler: stubErrorHandler, eventDispatcher: stubEventDispatcher, logger: createdLogger, @@ -201,7 +174,7 @@ describe('lib/optimizely', function() { new Optimizely({ clientEngine: 'node-sdk', logger: createdLogger, - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager(), jsonSchemaValidator: jsonSchemaValidator, userProfileService: userProfileServiceInstance, notificationCenter, @@ -226,7 +199,7 @@ describe('lib/optimizely', function() { new Optimizely({ clientEngine: 'node-sdk', logger: createdLogger, - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager(), jsonSchemaValidator: jsonSchemaValidator, userProfileService: invalidUserProfile, notificationCenter, @@ -246,80 +219,6 @@ describe('lib/optimizely', function() { ); }); }); - - describe('when an sdkKey is provided', function() { - it('should not log an error when sdkKey is provided and datafile is not provided', function() { - new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: stubErrorHandler, - eventDispatcher: eventDispatcher, - isValidInstance: true, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - sdkKey: '12345', - datafileManager: createHttpPollingDatafileManager('12345', createdLogger), - notificationCenter, - eventProcessor, - }); - sinon.assert.notCalled(stubErrorHandler.handleError); - }); - - it('passes datafile, datafileOptions, sdkKey, and other options to the project config manager', function() { - var config = testData.getTestProjectConfig(); - let datafileOptions = { - autoUpdate: true, - updateInterval: 2 * 60 * 1000, - }; - let datafileManager = createHttpPollingDatafileManager('12345', createdLogger, undefined, datafileOptions); - new Optimizely({ - clientEngine: 'node-sdk', - datafile: config, - datafileOptions: datafileOptions, - errorHandler: errorHandler, - eventDispatcher: eventDispatcher, - isValidInstance: true, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - sdkKey: '12345', - datafileManager: datafileManager, - notificationCenter, - eventProcessor, - }); - sinon.assert.calledOnce(projectConfigManager.createProjectConfigManager); - sinon.assert.calledWithExactly(projectConfigManager.createProjectConfigManager, { - datafile: config, - jsonSchemaValidator: jsonSchemaValidator, - sdkKey: '12345', - datafileManager: datafileManager, - }); - }); - }); - - it('should support constructing two instances using the same datafile object', function() { - var datafile = testData.getTypedAudiencesConfig(); - var optlyInstance = new Optimizely({ - clientEngine: 'node-sdk', - datafile: datafile, - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - notificationCenter, - eventProcessor, - }); - assert.instanceOf(optlyInstance, Optimizely); - var optlyInstance2 = new Optimizely({ - clientEngine: 'node-sdk', - datafile: datafile, - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - notificationCenter, - eventProcessor, - }); - assert.instanceOf(optlyInstance2, Optimizely); - }); }); }); @@ -334,9 +233,14 @@ describe('lib/optimizely', function() { logToConsole: false, }); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + // datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -972,8 +876,13 @@ describe('lib/optimizely', function() { reasons: [], }; bucketStub.returns(fakeDecisionResponse); + + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + var instance = new Optimizely({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -1061,7 +970,7 @@ describe('lib/optimizely', function() { it('should not activate when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -1748,8 +1657,12 @@ describe('lib/optimizely', function() { }); it('should track when logger is in DEBUG mode', function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + var instance = new Optimizely({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -1769,7 +1682,7 @@ describe('lib/optimizely', function() { it('should not track when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -1936,7 +1849,7 @@ describe('lib/optimizely', function() { it('should not return variation when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -2772,9 +2685,13 @@ describe('lib/optimizely', function() { describe('activate', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -2831,9 +2748,13 @@ describe('lib/optimizely', function() { describe('getVariation', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -2883,9 +2804,13 @@ describe('lib/optimizely', function() { }); it('should send notification with variation key and type feature-test when getVariation returns feature experiment variation', function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }); + var optly = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -2920,9 +2845,13 @@ describe('lib/optimizely', function() { var sandbox = sinon.sandbox.create(); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -4542,9 +4471,13 @@ describe('lib/optimizely', function() { describe('#createUserContext', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -4647,9 +4580,13 @@ describe('lib/optimizely', function() { var userId = 'tester'; describe('with empty default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -4695,7 +4632,7 @@ describe('lib/optimizely', function() { }); it('should return error decision object when SDK is not ready and do not dispatch an event', function() { - optlyInstance.projectConfigManager.getConfig.returns(null); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(null); var flagKey = 'feature_2'; var user = new OptimizelyUserContext({ optimizely: optlyInstance, @@ -4938,7 +4875,7 @@ describe('lib/optimizely', function() { it('should make a decision for rollout and do not dispatch an event when sendFlagDecisions is set to false', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.sendFlagDecisions = false; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var flagKey = 'feature_1'; var expectedVariables = optlyInstance.getAllFeatureVariables(flagKey, userId); var user = new OptimizelyUserContext({ @@ -5023,9 +4960,13 @@ describe('lib/optimizely', function() { describe('with EXCLUDE_VARIABLES flag in default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5133,9 +5074,13 @@ describe('lib/optimizely', function() { describe('with DISABLE_DECISION_EVENT flag in default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5198,9 +5143,13 @@ describe('lib/optimizely', function() { describe('with INCLUDE_REASONS flag in default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5222,7 +5171,7 @@ describe('lib/optimizely', function() { it('should include reason when experiment is not running', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].status = 'NotRunning'; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var flagKey = 'feature_1'; var user = new OptimizelyUserContext({ optimizely: optlyInstance, @@ -5251,9 +5200,13 @@ describe('lib/optimizely', function() { }), save: sinon.stub(), }; + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + var optlyInstanceWithUserProfile = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5280,7 +5233,7 @@ describe('lib/optimizely', function() { var variationKey = 'b'; var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].forcedVariations[userId] = variationKey; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5298,7 +5251,7 @@ describe('lib/optimizely', function() { optlyInstance.decisionService.forcedVariationMap[userId] = { '10390977673': variationKey }; var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.variationIdMap[variationKey] = { key: variationKey }; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5314,7 +5267,7 @@ describe('lib/optimizely', function() { var variationKey = 'invalid-key'; var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].forcedVariations[userId] = variationKey; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5410,7 +5363,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[1].trafficAllocation = []; newConfig.experiments[1].trafficAllocation.push({ endOfRange: 0, entityId: 'any' }); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5429,7 +5382,7 @@ describe('lib/optimizely', function() { var groupId = '13142870430'; var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.featureFlags[2].experimentIds.push(experimentId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5444,7 +5397,7 @@ describe('lib/optimizely', function() { var flagKey = 'feature_3'; var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.groups[0].trafficAllocation = []; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5475,7 +5428,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5499,7 +5452,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5524,7 +5477,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5549,7 +5502,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5574,7 +5527,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5599,7 +5552,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5624,7 +5577,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5649,7 +5602,7 @@ describe('lib/optimizely', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.experiments[0].audienceIds = []; newConfig.experiments[0].audienceIds.push(audienceId); - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var user = new OptimizelyUserContext({ optimizely: optlyInstance, userId, @@ -5681,9 +5634,13 @@ describe('lib/optimizely', function() { }), save: sinon.stub(), }; + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstanceWithUserProfile = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5724,9 +5681,13 @@ describe('lib/optimizely', function() { }), save: sinon.stub(), }; + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstanceWithUserProfile = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5771,9 +5732,13 @@ describe('lib/optimizely', function() { }), save: sinon.stub(), }; + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstanceWithUserProfile = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5802,9 +5767,13 @@ describe('lib/optimizely', function() { describe('#decideForKeys', function() { var userId = 'tester'; beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -5909,9 +5878,13 @@ describe('lib/optimizely', function() { var userId = 'tester'; describe('with empty default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6011,9 +5984,13 @@ describe('lib/optimizely', function() { describe('with ENABLED_FLAGS_ONLY flag in default decide options', function() { beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6113,9 +6090,13 @@ describe('lib/optimizely', function() { notificationCenter: notificationCenter, }); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6176,9 +6157,13 @@ describe('lib/optimizely', function() { }); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6209,10 +6194,7 @@ describe('lib/optimizely', function() { it('returns false if the instance is invalid', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: { - lasers: 300, - message: 'this is not a valid datafile', - }, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6655,7 +6637,7 @@ describe('lib/optimizely', function() { it('returns false and does not dispatch an event when sendFlagDecisions is not defined', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.sendFlagDecisions = undefined; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); assert.strictEqual(result, false); sinon.assert.notCalled(eventDispatcher.dispatchEvent); @@ -6668,7 +6650,7 @@ describe('lib/optimizely', function() { it('returns false and does not dispatch an event when sendFlagDecisions is set to false', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.sendFlagDecisions = false; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); assert.strictEqual(result, false); sinon.assert.notCalled(eventDispatcher.dispatchEvent); @@ -6681,7 +6663,7 @@ describe('lib/optimizely', function() { it('returns false and dispatch an event when sendFlagDecisions is set to true', function() { var newConfig = optlyInstance.projectConfigManager.getConfig(); newConfig.sendFlagDecisions = true; - optlyInstance.projectConfigManager.getConfig.returns(newConfig); + optlyInstance.projectConfigManager.getConfig = sinon.stub().returns(newConfig); var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); assert.strictEqual(result, false); sinon.assert.calledOnce(eventDispatcher.dispatchEvent); @@ -6753,10 +6735,7 @@ describe('lib/optimizely', function() { it('returns an empty array if the instance is invalid', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: { - lasers: 300, - message: 'this is not a valid datafile', - }, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -6794,9 +6773,13 @@ describe('lib/optimizely', function() { }); it('return features that are enabled for the user and send notification for every feature', function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -8874,7 +8857,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariable when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -8893,7 +8876,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariableBoolean when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -8912,7 +8895,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariableDouble when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -8931,7 +8914,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariableInteger when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -8950,7 +8933,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariableString when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -8969,7 +8952,7 @@ describe('lib/optimizely', function() { it('returns null from getFeatureVariableJSON when optimizely object is not a valid instance', function() { var instance = new Optimizely({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, logger: createdLogger, @@ -9002,9 +8985,13 @@ describe('lib/optimizely', function() { notificationCenter: notificationCenter, }); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTypedAudiencesConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTypedAudiencesConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9142,9 +9129,13 @@ describe('lib/optimizely', function() { notificationCenter: notificationCenter, }); beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTypedAudiencesConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTypedAudiencesConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9354,9 +9345,13 @@ describe('lib/optimizely', function() { var optlyInstance; beforeEach(function() { + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9650,9 +9645,13 @@ describe('lib/optimizely', function() { beforeEach(function() { eventProcessorStopPromise = Promise.resolve(); mockEventProcessor.stop.returns(eventProcessorStopPromise); + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9682,9 +9681,13 @@ describe('lib/optimizely', function() { beforeEach(function() { eventProcessorStopPromise = Promise.reject(new Error('Failed to stop')); mockEventProcessor.stop.returns(eventProcessorStopPromise); + const mockConfigManager = getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9741,9 +9744,13 @@ describe('lib/optimizely', function() { var optlyInstance; it('should call the project config manager stop method when the close method is called', function() { + const projectConfigManager = getMockProjectConfigManager(); + sinon.stub(projectConfigManager, 'stop'); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9753,14 +9760,14 @@ describe('lib/optimizely', function() { notificationCenter, }); optlyInstance.close(); - var fakeManager = projectConfigManager.createProjectConfigManager.getCall(0).returnValue; - sinon.assert.calledOnce(fakeManager.stop); + sinon.assert.calledOnce(projectConfigManager.stop); }); - describe('when no datafile is available yet ', function() { + describe('when no project config is available yet ', function() { beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', + projectConfigManager: getMockProjectConfigManager(), errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9808,18 +9815,12 @@ describe('lib/optimizely', function() { clearTimeoutSpy.restore(); }); - it('fulfills the promise with the value from the project config manager ready promise after the project config manager ready promise is fulfilled', function() { - projectConfigManager.createProjectConfigManager.callsFake(function(config) { - var currentConfig = config.datafile ? projectConfig.createProjectConfig(config.datafile) : null; - return { - stop: sinon.stub(), - getConfig: sinon.stub().returns(currentConfig), - onUpdate: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve({ success: true })), - }; - }); + it('fulfills the promise after the project config manager onRunning promise is fulfilled', function() { + const projectConfigManager = getMockProjectConfigManager(); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', + projectConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, @@ -9829,15 +9830,17 @@ describe('lib/optimizely', function() { notificationCenter, eventProcessor, }); - return optlyInstance.onReady().then(function(result) { - assert.deepEqual(result, { success: true }); - }); + + return optlyInstance.onReady(); }); - it('fulfills the promise with an unsuccessful result after the timeout has expired when the project config manager onReady promise still has not resolved', function() { + it('rejects the promise after the timeout has expired when the project config manager onReady promise still has not resolved', function() { + const projectConfigManager = getMockProjectConfigManager({ onRunning: new Promise(function() {}) }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9848,17 +9851,20 @@ describe('lib/optimizely', function() { }); var readyPromise = optlyInstance.onReady({ timeout: 500 }); clock.tick(501); - return readyPromise.then(function(result) { - assert.include(result, { - success: false, - }); + return readyPromise.then(() => { + return Promise.reject(new Error('Promise should not have resolved')); + }, (err) => { + assert.equal(err.message, 'onReady timeout expired after 500 ms') }); }); - it('fulfills the promise with an unsuccessful result after 30 seconds when no timeout argument is provided and the project config manager onReady promise still has not resolved', function() { + it('rejects the promise after 30 seconds when no timeout argument is provided and the project config manager onReady promise still has not resolved', function() { + const projectConfigManager = getMockProjectConfigManager({ onRunning: new Promise(function() {}) }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9869,17 +9875,20 @@ describe('lib/optimizely', function() { }); var readyPromise = optlyInstance.onReady(); clock.tick(300001); - return readyPromise.then(function(result) { - assert.include(result, { - success: false, - }); + return readyPromise.then(() => { + return Promise.reject(new Error('Promise should not have resolved')); + }, (err) => { + assert.equal(err.message, 'onReady timeout expired after 30000 ms') }); }); - it('fulfills the promise with an unsuccessful result after the instance is closed', function() { + it('rejects the promise after the instance is closed', function() { + const projectConfigManager = getMockProjectConfigManager({ onRunning: new Promise(function() {}) }); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9890,10 +9899,10 @@ describe('lib/optimizely', function() { }); var readyPromise = optlyInstance.onReady({ timeout: 100 }); optlyInstance.close(); - return readyPromise.then(function(result) { - assert.include(result, { - success: false, - }); + return readyPromise.then(() => { + return Promise.reject(new Error('Promise should not have resolved')); + }, (err) => { + assert.equal(err.message, 'Instance closed') }); }); @@ -9901,6 +9910,7 @@ describe('lib/optimizely', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager: getMockProjectConfigManager(), eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9927,17 +9937,10 @@ describe('lib/optimizely', function() { }); it('clears the timeout when the project config manager ready promise fulfills', function() { - projectConfigManager.createProjectConfigManager.callsFake(function(config) { - return { - stop: sinon.stub(), - getConfig: sinon.stub().returns(null), - onUpdate: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve({ success: true })), - }; - }); optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, + projectConfigManager: getMockProjectConfigManager(), eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, @@ -9958,18 +9961,13 @@ describe('lib/optimizely', function() { describe('project config updates', function() { var fakeProjectConfigManager; beforeEach(function() { - fakeProjectConfigManager = { - stop: sinon.stub(), - getConfig: sinon.stub().returns(null), - onUpdate: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns({ then: function() {} }), - }; - projectConfigManager.createProjectConfigManager.returns(fakeProjectConfigManager); + fakeProjectConfigManager = getMockProjectConfigManager(), optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, eventDispatcher: eventDispatcher, + projectConfigManager: fakeProjectConfigManager, jsonSchemaValidator: jsonSchemaValidator, logger: createdLogger, sdkKey: '12345', @@ -9986,10 +9984,13 @@ describe('lib/optimizely', function() { assert.isNull(optlyInstance.activate('myOtherExperiment', 'user98765')); // Project config manager receives new project config object - should use this now - var newConfig = projectConfig.createProjectConfig(testData.getTestProjectConfigWithFeatures()); - fakeProjectConfigManager.getConfig.returns(newConfig); - var updateListener = fakeProjectConfigManager.onUpdate.getCall(0).args[0]; - updateListener(newConfig); + + const datafile = testData.getTestProjectConfigWithFeatures(); + + const newConfig = createProjectConfig(datafile, JSON.stringify(datafile)); + + fakeProjectConfigManager.setConfig(newConfig); + fakeProjectConfigManager.pushUpdate(newConfig); // With the new project config containing this feature, should return true assert.isTrue(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user45678')); @@ -10017,9 +10018,9 @@ describe('lib/optimizely', function() { ], }); differentDatafile.revision = '44'; - var differentConfig = projectConfig.createProjectConfig(differentDatafile); - fakeProjectConfigManager.getConfig.returns(differentConfig); - updateListener(differentConfig); + var differentConfig = createProjectConfig(differentDatafile, JSON.stringify(differentDatafile)); + fakeProjectConfigManager.setConfig(differentConfig); + fakeProjectConfigManager.pushUpdate(differentConfig); // activate should return a variation for the new experiment assert.strictEqual(optlyInstance.activate('myOtherExperiment', 'user98765'), 'control'); @@ -10031,9 +10032,9 @@ describe('lib/optimizely', function() { enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, listener ); - var newConfig = projectConfig.createProjectConfig(testData.getTestProjectConfigWithFeatures()); - var updateListener = fakeProjectConfigManager.onUpdate.getCall(0).args[0]; - updateListener(newConfig); + var newConfig = createProjectConfig(testData.getTestProjectConfigWithFeatures()); + fakeProjectConfigManager.pushUpdate(newConfig); + sinon.assert.calledOnce(listener); }); }); @@ -10056,9 +10057,14 @@ describe('lib/optimizely', function() { batchSize: 1, notificationCenter: notificationCenter, }); + + const datafile = testData.getTestProjectConfig(); + const mockConfigManager = getMockProjectConfigManager(); + mockConfigManager.setConfig(createProjectConfig(datafile, JSON.stringify(datafile))); + optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler, logger, isValidInstance: true, @@ -10117,9 +10123,13 @@ describe('lib/optimizely', function() { }); beforeEach(function() { + const datafile = testData.getTestProjectConfig(); + const mockConfigManager = getMockProjectConfigManager(); + mockConfigManager.setConfig(createProjectConfig(datafile, JSON.stringify(datafile))); + optlyInstanceWithOdp = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), + projectConfigManager: mockConfigManager, errorHandler: errorHandler, eventDispatcher: eventDispatcher, jsonSchemaValidator: jsonSchemaValidator, diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 2a3eb5a0d..95d3682a3 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -1,18 +1,18 @@ -/**************************************************************************** - * 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. * - * 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. * - ***************************************************************************/ +/** + * Copyright 2020-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. + */ import { LoggerFacade, ErrorHandler } from '../modules/logging'; import { sprintf, objectValues } from '../utils/fns'; @@ -41,15 +41,14 @@ import { } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; -import { createProjectConfigManager, ProjectConfigManager } from '../core/project_config/project_config_manager'; +import { ProjectConfigManager } from '../project_config/project_config_manager'; import { createDecisionService, DecisionService, DecisionObj } from '../core/decision_service'; import { getImpressionEvent, getConversionEvent } from '../core/event_builder'; import { buildImpressionEvent, buildConversionEvent } from '../core/event_builder/event_helpers'; -import { NotificationRegistry } from '../core/notification_center/notification_registry'; import fns from '../utils/fns'; import { validate } from '../utils/attributes_validator'; import * as eventTagsValidator from '../utils/event_tags_validator'; -import * as projectConfig from '../core/project_config'; +import * as projectConfig from '../project_config/project_config'; import * as userProfileServiceValidator from '../utils/user_profile_service_validator'; import * as stringValidator from '../utils/string_value_validator'; import * as decision from '../core/decision'; @@ -69,6 +68,9 @@ import { FS_USER_ID_ALIAS, ODP_USER_KEY, } from '../utils/enums'; +import { Fn } from '../utils/type'; +import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { time } from 'console'; const MODULE_NAME = 'OPTIMIZELY'; @@ -81,8 +83,8 @@ type StringInputs = Partial>; export default class Optimizely implements Client { private isOptimizelyConfigValid: boolean; - private disposeOnUpdate: (() => void) | null; - private readyPromise: Promise<{ success: boolean; reason?: string }>; + private disposeOnUpdate?: Fn; + private readyPromise: Promise; // readyTimeout is specified as any to make this work in both browser & Node // eslint-disable-next-line @typescript-eslint/no-explicit-any private readyTimeouts: { [key: string]: { readyTimeout: any; onClose: () => void } }; @@ -128,12 +130,7 @@ export default class Optimizely implements Client { } }); this.defaultDecideOptions = defaultDecideOptions; - this.projectConfigManager = createProjectConfigManager({ - datafile: config.datafile, - jsonSchemaValidator: config.jsonSchemaValidator, - sdkKey: config.sdkKey, - datafileManager: config.datafileManager, - }); + this.projectConfigManager = config.projectConfigManager; this.disposeOnUpdate = this.projectConfigManager.onUpdate((configObj: projectConfig.ProjectConfig) => { this.logger.log( @@ -149,7 +146,8 @@ export default class Optimizely implements Client { this.updateOdpSettings(); }); - const projectConfigManagerReadyPromise = this.projectConfigManager.onReady(); + this.projectConfigManager.start(); + const projectConfigManagerRunningPromise = this.projectConfigManager.onRunning(); let userProfileService: UserProfileService | null = null; if (config.userProfileService) { @@ -176,13 +174,10 @@ export default class Optimizely implements Client { const eventProcessorStartedPromise = this.eventProcessor.start(); this.readyPromise = Promise.all([ - projectConfigManagerReadyPromise, + projectConfigManagerRunningPromise, 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]; - }); + ]); this.readyTimeouts = {}; this.nextReadyTimeoutId = 0; @@ -193,7 +188,7 @@ export default class Optimizely implements Client { * @return {projectConfig.ProjectConfig} */ getProjectConfig(): projectConfig.ProjectConfig | null { - return this.projectConfigManager.getConfig(); + return this.projectConfigManager.getConfig() || null; } /** @@ -1262,7 +1257,7 @@ export default class Optimizely implements Client { if (!configObj) { return null; } - return this.projectConfigManager.getOptimizelyConfig(); + return this.projectConfigManager.getOptimizelyConfig() || null; } catch (e) { this.logger.log(LOG_LEVEL.ERROR, e.message); this.errorHandler.handleError(e); @@ -1308,15 +1303,11 @@ export default class Optimizely implements Client { } this.notificationCenter.clearAllNotificationListeners(); - const sdkKey = this.projectConfigManager.getConfig()?.sdkKey; - if (sdkKey) { - NotificationRegistry.removeNotificationCenter(sdkKey); - } const eventProcessorStoppedPromise = this.eventProcessor.stop(); if (this.disposeOnUpdate) { this.disposeOnUpdate(); - this.disposeOnUpdate = null; + this.disposeOnUpdate = undefined; } if (this.projectConfigManager) { this.projectConfigManager.stop(); @@ -1377,7 +1368,7 @@ export default class Optimizely implements Client { * @param {number|undefined} options.timeout * @return {Promise} */ - onReady(options?: { timeout?: number }): Promise { + onReady(options?: { timeout?: number }): Promise { let timeoutValue: number | undefined; if (typeof options === 'object' && options !== null) { if (options.timeout !== undefined) { @@ -1388,27 +1379,20 @@ export default class Optimizely implements Client { timeoutValue = DEFAULT_ONREADY_TIMEOUT; } - let resolveTimeoutPromise: (value: OnReadyResult) => void; - const timeoutPromise = new Promise(resolve => { - resolveTimeoutPromise = resolve; - }); + const timeoutPromise = resolvablePromise(); - const timeoutId = this.nextReadyTimeoutId; - this.nextReadyTimeoutId++; + const timeoutId = this.nextReadyTimeoutId++; const onReadyTimeout = () => { delete this.readyTimeouts[timeoutId]; - resolveTimeoutPromise({ - success: false, - reason: sprintf('onReady timeout expired after %s ms', timeoutValue), - }); + timeoutPromise.reject(new Error( + sprintf('onReady timeout expired after %s ms', timeoutValue) + )); }; + const readyTimeout = setTimeout(onReadyTimeout, timeoutValue); const onClose = function() { - resolveTimeoutPromise({ - success: false, - reason: 'Instance closed', - }); + timeoutPromise.reject(new Error('Instance closed')); }; this.readyTimeouts[timeoutId] = { @@ -1419,9 +1403,6 @@ export default class Optimizely implements Client { this.readyPromise.then(() => { clearTimeout(readyTimeout); delete this.readyTimeouts[timeoutId]; - resolveTimeoutPromise({ - success: true, - }); }); return Promise.race([this.readyPromise, timeoutPromise]); diff --git a/lib/optimizely_user_context/index.tests.js b/lib/optimizely_user_context/index.tests.js index 2b6ce0653..8c436391c 100644 --- a/lib/optimizely_user_context/index.tests.js +++ b/lib/optimizely_user_context/index.tests.js @@ -1,18 +1,19 @@ -/**************************************************************************** - * Copyright 2020-2023, Optimizely, Inc. and contributors * - * * - * Licensed under the Apache License, Version 2.0 (the "License"); * - * you may not use this file except in compliance with the License. * - * You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * 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. * - ***************************************************************************/ +/** + * Copyright 2020-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. + */ + import { assert } from 'chai'; import sinon from 'sinon'; @@ -30,6 +31,8 @@ import eventDispatcher from '../plugins/event_dispatcher/index.node'; import { CONTROL_ATTRIBUTES, LOG_LEVEL, LOG_MESSAGES } from '../utils/enums'; import testData from '../tests/test_data'; import { OptimizelyDecideOption } from '../shared_types'; +import { getMockProjectConfigManager } from '../tests/mock/mock_project_config_manager'; +import { createProjectConfig } from '../project_config/project_config'; describe('lib/optimizely_user_context', function() { describe('APIs', function() { @@ -356,7 +359,9 @@ describe('lib/optimizely_user_context', function() { beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + }), errorHandler: errorHandler, eventProcessor, isValidInstance: true, @@ -689,7 +694,9 @@ describe('lib/optimizely_user_context', function() { beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + }), errorHandler: errorHandler, eventProcessor, isValidInstance: true, @@ -791,7 +798,9 @@ describe('lib/optimizely_user_context', function() { beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + }), errorHandler: errorHandler, eventProcessor, isValidInstance: true, @@ -833,7 +842,9 @@ describe('lib/optimizely_user_context', function() { }); var optlyInstance = new Optimizely({ clientEngine: 'node-sdk', - datafile: testData.getTestDecideProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + }), errorHandler: errorHandler, eventProcessor, isValidInstance: true, diff --git a/lib/optimizely_user_context/index.ts b/lib/optimizely_user_context/index.ts index 0b689237a..92b307dbb 100644 --- a/lib/optimizely_user_context/index.ts +++ b/lib/optimizely_user_context/index.ts @@ -1,18 +1,18 @@ -/**************************************************************************** - * 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. * - * 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. * - ***************************************************************************/ +/** + * Copyright 2020-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. + */ import Optimizely from '../optimizely'; import { EventTags, @@ -64,11 +64,9 @@ export default class OptimizelyUserContext implements IOptimizelyUserContext { this.forcedDecisionsMap = {}; if (shouldIdentifyUser) { - this.optimizely.onReady().then(({ success }) => { - if (success) { - this.identifyUser(); - } - }); + this.optimizely.onReady().then(() => { + this.identifyUser(); + }).catch(() => {}); } } diff --git a/lib/plugins/datafile_manager/browser_http_polling_datafile_manager.ts b/lib/plugins/datafile_manager/browser_http_polling_datafile_manager.ts deleted file mode 100644 index 9bc89aa53..000000000 --- a/lib/plugins/datafile_manager/browser_http_polling_datafile_manager.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright 2021-2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import { LoggerFacade } from '../../modules/logging'; - import { HttpPollingDatafileManager } from '../../modules/datafile-manager/index.browser'; - import { DatafileOptions, DatafileManagerConfig, DatafileManager } from '../../shared_types'; - import { toDatafile, tryCreatingProjectConfig } from '../../core/project_config'; - import fns from '../../utils/fns'; - - export function createHttpPollingDatafileManager( - sdkKey: string, - logger: LoggerFacade, - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile?: string | object, - datafileOptions?: DatafileOptions, - ): DatafileManager { - const datafileManagerConfig: DatafileManagerConfig = { sdkKey }; - if (datafileOptions === undefined || (typeof datafileOptions === 'object' && datafileOptions !== null)) { - fns.assign(datafileManagerConfig, datafileOptions); - } - if (datafile) { - const { configObj, error } = tryCreatingProjectConfig({ - datafile: datafile, - jsonSchemaValidator: undefined, - logger: logger, - }); - - if (error) { - logger.error(error); - } - if (configObj) { - datafileManagerConfig.datafile = toDatafile(configObj); - } - } - return new HttpPollingDatafileManager(datafileManagerConfig); - } diff --git a/lib/plugins/datafile_manager/http_polling_datafile_manager.tests.js b/lib/plugins/datafile_manager/http_polling_datafile_manager.tests.js deleted file mode 100644 index ffd9b369a..000000000 --- a/lib/plugins/datafile_manager/http_polling_datafile_manager.tests.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright 2021 Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import sinon from 'sinon'; -import { createHttpPollingDatafileManager } from './http_polling_datafile_manager'; -import * as projectConfig from '../../core/project_config'; -import datafileManager from '../../modules/datafile-manager/index.node'; - -describe('lib/plugins/datafile_manager/http_polling_datafile_manager', function() { - var sandbox = sinon.sandbox.create(); - - beforeEach(() => { - sandbox.stub(datafileManager,'HttpPollingDatafileManager') - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('when datafile is null', () => { - beforeEach(() => { - sandbox.stub(projectConfig, 'toDatafile'); - sandbox.stub(projectConfig, 'tryCreatingProjectConfig'); - }); - it('should create HttpPollingDatafileManager with correct options and not create project config', () => { - var logger = { - error: () => {}, - } - createHttpPollingDatafileManager('SDK_KEY', logger, undefined, { - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com' - }); - - sinon.assert.calledWithExactly(datafileManager.HttpPollingDatafileManager, { - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com', - sdkKey: 'SDK_KEY', - }); - sinon.assert.notCalled(projectConfig.tryCreatingProjectConfig); - sinon.assert.notCalled(projectConfig.toDatafile); - }); - }); - - describe('when initial datafile is provided', () => { - beforeEach(() => { - sandbox.stub(projectConfig, 'tryCreatingProjectConfig').returns({ configObj: { dummy: "Config" }, error: null}); - sandbox.stub(projectConfig, 'toDatafile').returns('{"dummy": "datafile"}'); - }); - it('should create project config and add datafile', () => { - var logger = { - error: () => {}, - } - var dummyDatafile = '{"dummy": "datafile"}'; - createHttpPollingDatafileManager('SDK_KEY', logger, dummyDatafile, { - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com' - }); - - sinon.assert.calledWithExactly(datafileManager.HttpPollingDatafileManager, { - datafile: dummyDatafile, - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com', - sdkKey: 'SDK_KEY', - }); - sinon.assert.calledWithExactly(projectConfig.tryCreatingProjectConfig, { - datafile: dummyDatafile, - jsonSchemaValidator: undefined, - logger, - }); - sinon.assert.calledWithExactly(projectConfig.toDatafile, {dummy: "Config"}); - }) - }) - - describe('error logging', () => { - beforeEach(() => { - sandbox.stub(projectConfig, 'tryCreatingProjectConfig').returns({ configObj: null, error: 'Error creating config'}); - sandbox.stub(projectConfig, 'toDatafile'); - }); - it('Should log error when error is thrown while creating project config', () => { - var logger = { - error: () => {}, - } - var errorSpy = sandbox.spy(logger, 'error'); - var dummyDatafile = '{"dummy": "datafile"}'; - createHttpPollingDatafileManager('SDK_KEY', logger, dummyDatafile, { - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com' - }); - - sinon.assert.calledWithExactly(datafileManager.HttpPollingDatafileManager, { - autoUpdate: true, - datafileAccessToken: 'THE_TOKEN', - updateInterval: 5000, - urlTemplate: 'http://example.com', - sdkKey: 'SDK_KEY', - }); - sinon.assert.calledWithExactly(projectConfig.tryCreatingProjectConfig, { - datafile: dummyDatafile, - jsonSchemaValidator: undefined, - logger, - }); - sinon.assert.notCalled(projectConfig.toDatafile); - sinon.assert.calledWithExactly(errorSpy, 'Error creating config'); - }) - }); -}); diff --git a/lib/plugins/datafile_manager/http_polling_datafile_manager.ts b/lib/plugins/datafile_manager/http_polling_datafile_manager.ts deleted file mode 100644 index 7bbd19738..000000000 --- a/lib/plugins/datafile_manager/http_polling_datafile_manager.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright 2021-2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { LoggerFacade } from '../../modules/logging'; -import datafileManager from '../../modules/datafile-manager/index.node'; -import { DatafileOptions, DatafileManagerConfig, DatafileManager } from '../../shared_types'; -import { toDatafile, tryCreatingProjectConfig } from '../../core/project_config'; -import fns from '../../utils/fns'; - -export function createHttpPollingDatafileManager( - sdkKey: string, - logger: LoggerFacade, - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile?: string | object, - datafileOptions?: DatafileOptions, -): DatafileManager { - const datafileManagerConfig: DatafileManagerConfig = { sdkKey }; - if (datafileOptions === undefined || (typeof datafileOptions === 'object' && datafileOptions !== null)) { - fns.assign(datafileManagerConfig, datafileOptions); - } - if (datafile) { - const { configObj, error } = tryCreatingProjectConfig({ - datafile: datafile, - jsonSchemaValidator: undefined, - logger: logger, - }); - - if (error) { - logger.error(error); - } - if (configObj) { - datafileManagerConfig.datafile = toDatafile(configObj); - } - } - return new datafileManager.HttpPollingDatafileManager(datafileManagerConfig); -} diff --git a/lib/plugins/datafile_manager/no_op_datafile_manager.tests.js b/lib/plugins/datafile_manager/no_op_datafile_manager.tests.js deleted file mode 100644 index 1eacb169b..000000000 --- a/lib/plugins/datafile_manager/no_op_datafile_manager.tests.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright 2021 Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import { assert } from 'chai'; - import { createNoOpDatafileManager } from './no_op_datafile_manager'; - - describe('lib/plugins/datafile_manager/no_op_datafile_manager', function() { - var dfm = createNoOpDatafileManager(); - - beforeEach(() => { - dfm.start(); - }); - - it('should return empty string when get is called', () => { - assert.equal(dfm.get(), ''); - }); - - it('should return a resolved promise when onReady is called', (done) => { - dfm.onReady().then(done); - }); - - it('should return a resolved promise when stop is called', (done) => { - dfm.stop().then(done); - }); - - it('should return an empty function when event listener is added', () => { - assert.equal(typeof(dfm.on('dummyEvent', () => {}, '')), 'function'); - }); - }); - \ No newline at end of file diff --git a/lib/plugins/datafile_manager/no_op_datafile_manager.ts b/lib/plugins/datafile_manager/no_op_datafile_manager.ts deleted file mode 100644 index 2f1926d4f..000000000 --- a/lib/plugins/datafile_manager/no_op_datafile_manager.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright 2021, 2023, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * 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 { DatafileManager, DatafileUpdateListener } from '../../shared_types'; - -/** - * No-operation Datafile Manager for Lite Bundle designed for Edge platforms - * https://github.com/optimizely/javascript-sdk/issues/699 - */ -class NoOpDatafileManager implements DatafileManager { - /* eslint-disable @typescript-eslint/no-unused-vars */ - on(_eventName: string, _listener: DatafileUpdateListener): () => void { - return (): void => {}; - } - - get(): string { - return ''; - } - - onReady(): Promise { - return Promise.resolve(); - } - - start(): void {} - - stop(): Promise { - return Promise.resolve(); - } -} - -export function createNoOpDatafileManager(): DatafileManager { - return new NoOpDatafileManager(); -} diff --git a/lib/plugins/datafile_manager/react_native_http_polling_datafile_manager.ts b/lib/plugins/datafile_manager/react_native_http_polling_datafile_manager.ts deleted file mode 100644 index 0d45d2116..000000000 --- a/lib/plugins/datafile_manager/react_native_http_polling_datafile_manager.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright 2021-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. - * 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 { LoggerFacade } from '../../modules/logging'; -import { HttpPollingDatafileManager } from '../../modules/datafile-manager/index.react_native'; -import { DatafileOptions, DatafileManager, PersistentCacheProvider } from '../../shared_types'; -import { DatafileManagerConfig } from '../../modules/datafile-manager/index.react_native'; -import { toDatafile, tryCreatingProjectConfig } from '../../core/project_config'; -import fns from '../../utils/fns'; - -export function createHttpPollingDatafileManager( - sdkKey: string, - logger: LoggerFacade, - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile?: string | object, - datafileOptions?: DatafileOptions, - persistentCacheProvider?: PersistentCacheProvider, - ): DatafileManager { - const datafileManagerConfig: DatafileManagerConfig = { sdkKey }; - if (datafileOptions === undefined || (typeof datafileOptions === 'object' && datafileOptions !== null)) { - fns.assign(datafileManagerConfig, datafileOptions); - } - if (datafile) { - const { configObj, error } = tryCreatingProjectConfig({ - datafile: datafile, - jsonSchemaValidator: undefined, - logger: logger, - }); - - if (error) { - logger.error(error); - } - if (configObj) { - datafileManagerConfig.datafile = toDatafile(configObj); - } - } - if (persistentCacheProvider) { - datafileManagerConfig.cache = persistentCacheProvider(); - } - return new HttpPollingDatafileManager(datafileManagerConfig); -} diff --git a/lib/plugins/odp/event_api_manager/index.browser.ts b/lib/plugins/odp/event_api_manager/index.browser.ts index 8a21a462c..e8feb29ee 100644 --- a/lib/plugins/odp/event_api_manager/index.browser.ts +++ b/lib/plugins/odp/event_api_manager/index.browser.ts @@ -1,23 +1,24 @@ -/**************************************************************************** - * 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. * - ***************************************************************************/ +/** + * 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. + */ import { OdpEvent } from '../../../core/odp/odp_event'; import { OdpEventApiManager } from '../../../core/odp/odp_event_api_manager'; import { LogLevel } from '../../../modules/logging'; import { OdpConfig, OdpIntegrationConfig } from '../../../core/odp/odp_config'; +import { HttpMethod } from '../../../utils/http_request_handler/http'; const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; @@ -41,7 +42,7 @@ export class BrowserOdpEventApiManager extends OdpEventApiManager { protected generateRequestData( odpConfig: OdpConfig, events: OdpEvent[] - ): { method: string; endpoint: string; headers: { [key: string]: string }; data: string } { + ): { method: HttpMethod; endpoint: string; headers: { [key: string]: string }; data: string } { const pixelApiEndpoint = this.getPixelApiEndpoint(odpConfig); const apiKey = odpConfig.apiKey; diff --git a/lib/plugins/odp/event_api_manager/index.node.ts b/lib/plugins/odp/event_api_manager/index.node.ts index 0b8b4e3ba..eea898787 100644 --- a/lib/plugins/odp/event_api_manager/index.node.ts +++ b/lib/plugins/odp/event_api_manager/index.node.ts @@ -1,23 +1,24 @@ -/**************************************************************************** - * 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. * - ***************************************************************************/ +/** + * 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. + */ 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 { HttpMethod } from '../../../utils/http_request_handler/http'; export class NodeOdpEventApiManager extends OdpEventApiManager { protected shouldSendEvents(events: OdpEvent[]): boolean { return true; @@ -26,7 +27,7 @@ export class NodeOdpEventApiManager extends OdpEventApiManager { protected generateRequestData( odpConfig: OdpConfig, events: OdpEvent[] - ): { method: string; endpoint: string; headers: { [key: string]: string }; data: string } { + ): { method: HttpMethod; endpoint: string; headers: { [key: string]: string }; data: string } { const { apiHost, apiKey } = odpConfig; diff --git a/lib/plugins/odp_manager/index.browser.ts b/lib/plugins/odp_manager/index.browser.ts index e7095364a..5001dc59f 100644 --- a/lib/plugins/odp_manager/index.browser.ts +++ b/lib/plugins/odp_manager/index.browser.ts @@ -83,10 +83,10 @@ export class BrowserOdpManager extends OdpManager { if (odpOptions?.segmentsRequestHandler) { customSegmentRequestHandler = odpOptions.segmentsRequestHandler; } else { - customSegmentRequestHandler = new BrowserRequestHandler( + customSegmentRequestHandler = new BrowserRequestHandler({ logger, - odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS - ); + timeout: odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS + }); } let segmentManager: IOdpSegmentManager; @@ -111,10 +111,10 @@ export class BrowserOdpManager extends OdpManager { if (odpOptions?.eventRequestHandler) { customEventRequestHandler = odpOptions.eventRequestHandler; } else { - customEventRequestHandler = new BrowserRequestHandler( + customEventRequestHandler = new BrowserRequestHandler({ logger, - odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS - ); + timeout:odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS + }); } let eventManager: IOdpEventManager; diff --git a/lib/plugins/odp_manager/index.node.ts b/lib/plugins/odp_manager/index.node.ts index bdd57f1ad..9eebc71d1 100644 --- a/lib/plugins/odp_manager/index.node.ts +++ b/lib/plugins/odp_manager/index.node.ts @@ -74,10 +74,10 @@ export class NodeOdpManager extends OdpManager { if (odpOptions?.segmentsRequestHandler) { customSegmentRequestHandler = odpOptions.segmentsRequestHandler; } else { - customSegmentRequestHandler = new NodeRequestHandler( + customSegmentRequestHandler = new NodeRequestHandler({ logger, - odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS - ); + timeout: odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS + }); } let segmentManager: IOdpSegmentManager; @@ -102,10 +102,10 @@ export class NodeOdpManager extends OdpManager { if (odpOptions?.eventRequestHandler) { customEventRequestHandler = odpOptions.eventRequestHandler; } else { - customEventRequestHandler = new NodeRequestHandler( + customEventRequestHandler = new NodeRequestHandler({ logger, - odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS - ); + timeout: odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS + }); } let eventManager: IOdpEventManager; diff --git a/lib/project_config/config_manager_factory.browser.spec.ts b/lib/project_config/config_manager_factory.browser.spec.ts new file mode 100644 index 000000000..bbabfb0ac --- /dev/null +++ b/lib/project_config/config_manager_factory.browser.spec.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./config_manager_factory', () => { + return { + getPollingConfigManager: vi.fn().mockReturnValueOnce({ foo: 'bar' }), + }; +}); + +vi.mock('../utils/http_request_handler/browser_request_handler', () => { + const BrowserRequestHandler = vi.fn(); + return { BrowserRequestHandler }; +}); + +import { getPollingConfigManager, PollingConfigManagerConfig, PollingConfigManagerFactoryOptions } from './config_manager_factory'; +import { createPollingProjectConfigManager } from './config_manager_factory.browser'; +import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; + +describe('createPollingConfigManager', () => { + const mockGetPollingConfigManager = vi.mocked(getPollingConfigManager); + const MockBrowserRequestHandler = vi.mocked(BrowserRequestHandler); + + beforeEach(() => { + mockGetPollingConfigManager.mockClear(); + MockBrowserRequestHandler.mockClear(); + }); + + it('creates and returns the instance by calling getPollingConfigManager', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(projectConfigManager, mockGetPollingConfigManager.mock.results[0].value)).toBe(true); + }); + + it('uses an instance of BrowserRequestHandler as requestHandler', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(mockGetPollingConfigManager.mock.calls[0][0].requestHandler, MockBrowserRequestHandler.mock.instances[0])).toBe(true); + }); + + it('uses uses autoUpdate = false by default', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager.mock.calls[0][0].autoUpdate).toBe(false); + }); + + it('uses the provided options', () => { + const config: PollingConfigManagerConfig = { + datafile: '{}', + jsonSchemaValidator: vi.fn(), + sdkKey: 'sdkKey', + updateInterval: 50000, + autoUpdate: true, + urlTemplate: 'urlTemplate', + datafileAccessToken: 'datafileAccessToken', + cache: { get: vi.fn(), set: vi.fn(), contains: vi.fn(), remove: vi.fn() }, + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager).toHaveBeenNthCalledWith(1, expect.objectContaining(config)); + }); +}); diff --git a/lib/project_config/config_manager_factory.browser.ts b/lib/project_config/config_manager_factory.browser.ts new file mode 100644 index 000000000..8ae0bfd9e --- /dev/null +++ b/lib/project_config/config_manager_factory.browser.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ + +import { getPollingConfigManager, PollingConfigManagerConfig } from './config_manager_factory'; +import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; +import { ProjectConfigManager } from './project_config_manager'; + +export const createPollingProjectConfigManager = (config: PollingConfigManagerConfig): ProjectConfigManager => { + const defaultConfig = { + autoUpdate: false, + requestHandler: new BrowserRequestHandler(), + }; + return getPollingConfigManager({ ...defaultConfig, ...config }); +}; diff --git a/lib/project_config/config_manager_factory.node.spec.ts b/lib/project_config/config_manager_factory.node.spec.ts new file mode 100644 index 000000000..2667e5cf5 --- /dev/null +++ b/lib/project_config/config_manager_factory.node.spec.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./config_manager_factory', () => { + return { + getPollingConfigManager: vi.fn().mockReturnValueOnce({ foo: 'bar' }), + }; +}); + +vi.mock('../utils/http_request_handler/node_request_handler', () => { + const NodeRequestHandler = vi.fn(); + return { NodeRequestHandler }; +}); + +import { getPollingConfigManager, PollingConfigManagerConfig } from './config_manager_factory'; +import { createPollingProjectConfigManager } from './config_manager_factory.node'; +import { NodeRequestHandler } from '../utils/http_request_handler/node_request_handler'; +import { DEFAULT_AUTHENTICATED_URL_TEMPLATE, DEFAULT_URL_TEMPLATE } from './constant'; + +describe('createPollingConfigManager', () => { + const mockGetPollingConfigManager = vi.mocked(getPollingConfigManager); + const MockNodeRequestHandler = vi.mocked(NodeRequestHandler); + + beforeEach(() => { + mockGetPollingConfigManager.mockClear(); + MockNodeRequestHandler.mockClear(); + }); + + it('creates and returns the instance by calling getPollingConfigManager', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(projectConfigManager, mockGetPollingConfigManager.mock.results[0].value)).toBe(true); + }); + + it('uses an instance of NodeRequestHandler as requestHandler', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(mockGetPollingConfigManager.mock.calls[0][0].requestHandler, MockNodeRequestHandler.mock.instances[0])).toBe(true); + }); + + it('uses uses autoUpdate = true by default', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager.mock.calls[0][0].autoUpdate).toBe(true); + }); + + it('uses the provided options', () => { + const config: PollingConfigManagerConfig = { + datafile: '{}', + jsonSchemaValidator: vi.fn(), + sdkKey: 'sdkKey', + updateInterval: 50000, + autoUpdate: false, + urlTemplate: 'urlTemplate', + datafileAccessToken: 'datafileAccessToken', + cache: { get: vi.fn(), set: vi.fn(), contains: vi.fn(), remove: vi.fn() }, + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager).toHaveBeenNthCalledWith(1, expect.objectContaining(config)); + }); +}); diff --git a/lib/project_config/config_manager_factory.node.ts b/lib/project_config/config_manager_factory.node.ts new file mode 100644 index 000000000..7a220bc12 --- /dev/null +++ b/lib/project_config/config_manager_factory.node.ts @@ -0,0 +1,28 @@ +/** + * 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. + */ + +import { getPollingConfigManager, PollingConfigManagerConfig } from "./config_manager_factory"; +import { NodeRequestHandler } from "../utils/http_request_handler/node_request_handler"; +import { ProjectConfigManager } from "./project_config_manager"; +import { DEFAULT_URL_TEMPLATE, DEFAULT_AUTHENTICATED_URL_TEMPLATE } from './constant'; + +export const createPollingProjectConfigManager = (config: PollingConfigManagerConfig): ProjectConfigManager => { + const defaultConfig = { + autoUpdate: true, + requestHandler: new NodeRequestHandler(), + }; + return getPollingConfigManager({ ...defaultConfig, ...config }); +}; diff --git a/lib/project_config/config_manager_factory.react_native.spec.ts b/lib/project_config/config_manager_factory.react_native.spec.ts new file mode 100644 index 000000000..a01b36c11 --- /dev/null +++ b/lib/project_config/config_manager_factory.react_native.spec.ts @@ -0,0 +1,102 @@ +/** + * 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. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./config_manager_factory', () => { + return { + getPollingConfigManager: vi.fn().mockReturnValueOnce({ foo: 'bar' }), + }; +}); + +vi.mock('../utils/http_request_handler/browser_request_handler', () => { + const BrowserRequestHandler = vi.fn(); + return { BrowserRequestHandler }; +}); + +vi.mock('../plugins/key_value_cache/reactNativeAsyncStorageCache', () => { + const ReactNativeAsyncStorageCache = vi.fn(); + return { 'default': ReactNativeAsyncStorageCache }; +}); + +import { getPollingConfigManager, PollingConfigManagerConfig, PollingConfigManagerFactoryOptions } from './config_manager_factory'; +import { createPollingProjectConfigManager } from './config_manager_factory.react_native'; +import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; +import ReactNativeAsyncStorageCache from '../plugins/key_value_cache/reactNativeAsyncStorageCache'; + +describe('createPollingConfigManager', () => { + const mockGetPollingConfigManager = vi.mocked(getPollingConfigManager); + const MockBrowserRequestHandler = vi.mocked(BrowserRequestHandler); + const MockReactNativeAsyncStorageCache = vi.mocked(ReactNativeAsyncStorageCache); + + beforeEach(() => { + mockGetPollingConfigManager.mockClear(); + MockBrowserRequestHandler.mockClear(); + MockReactNativeAsyncStorageCache.mockClear(); + }); + + it('creates and returns the instance by calling getPollingConfigManager', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(projectConfigManager, mockGetPollingConfigManager.mock.results[0].value)).toBe(true); + }); + + it('uses an instance of BrowserRequestHandler as requestHandler', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(mockGetPollingConfigManager.mock.calls[0][0].requestHandler, MockBrowserRequestHandler.mock.instances[0])).toBe(true); + }); + + it('uses uses autoUpdate = true by default', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager.mock.calls[0][0].autoUpdate).toBe(true); + }); + + it('uses an instance of ReactNativeAsyncStorageCache for caching by default', () => { + const config = { + sdkKey: 'sdkKey', + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(Object.is(mockGetPollingConfigManager.mock.calls[0][0].cache, MockReactNativeAsyncStorageCache.mock.instances[0])).toBe(true); + }); + + it('uses the provided options', () => { + const config: PollingConfigManagerConfig = { + datafile: '{}', + jsonSchemaValidator: vi.fn(), + sdkKey: 'sdkKey', + updateInterval: 50000, + autoUpdate: false, + urlTemplate: 'urlTemplate', + datafileAccessToken: 'datafileAccessToken', + cache: { get: vi.fn(), set: vi.fn(), contains: vi.fn(), remove: vi.fn() }, + }; + + const projectConfigManager = createPollingProjectConfigManager(config); + expect(mockGetPollingConfigManager).toHaveBeenNthCalledWith(1, expect.objectContaining(config)); + }); +}); diff --git a/lib/project_config/config_manager_factory.react_native.ts b/lib/project_config/config_manager_factory.react_native.ts new file mode 100644 index 000000000..6978ac61e --- /dev/null +++ b/lib/project_config/config_manager_factory.react_native.ts @@ -0,0 +1,29 @@ +/** + * 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. + */ + +import { getPollingConfigManager, PollingConfigManagerConfig } from "./config_manager_factory"; +import { BrowserRequestHandler } from "../utils/http_request_handler/browser_request_handler"; +import { ProjectConfigManager } from "./project_config_manager"; +import ReactNativeAsyncStorageCache from "../plugins/key_value_cache/reactNativeAsyncStorageCache"; + +export const createPollingProjectConfigManager = (config: PollingConfigManagerConfig): ProjectConfigManager => { + const defaultConfig = { + autoUpdate: true, + requestHandler: new BrowserRequestHandler(), + cache: new ReactNativeAsyncStorageCache(), + }; + return getPollingConfigManager({ ...defaultConfig, ...config }); +}; diff --git a/lib/project_config/config_manager_factory.spec.ts b/lib/project_config/config_manager_factory.spec.ts new file mode 100644 index 000000000..a79b3ae1a --- /dev/null +++ b/lib/project_config/config_manager_factory.spec.ts @@ -0,0 +1,112 @@ +/** + * 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. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./project_config_manager', () => { + const MockConfigManager = vi.fn(); + return { ProjectConfigManagerImpl: MockConfigManager }; +}); + +vi.mock('./polling_datafile_manager', () => { + const MockDatafileManager = vi.fn(); + return { PollingDatafileManager: MockDatafileManager }; +}); + +vi.mock('../utils/repeater/repeater', () => { + const MockIntervalRepeater = vi.fn(); + const MockExponentialBackoff = vi.fn(); + return { IntervalRepeater: MockIntervalRepeater, ExponentialBackoff: MockExponentialBackoff }; +}); + +import { ProjectConfigManagerImpl } from './project_config_manager'; +import { PollingDatafileManager } from './polling_datafile_manager'; +import { ExponentialBackoff, IntervalRepeater } from '../utils/repeater/repeater'; +import { getPollingConfigManager } from './config_manager_factory'; +import { DEFAULT_UPDATE_INTERVAL } from './constant'; + +describe('getPollingConfigManager', () => { + const MockProjectConfigManagerImpl = vi.mocked(ProjectConfigManagerImpl); + const MockPollingDatafileManager = vi.mocked(PollingDatafileManager); + const MockIntervalRepeater = vi.mocked(IntervalRepeater); + const MockExponentialBackoff = vi.mocked(ExponentialBackoff); + + beforeEach(() => { + MockProjectConfigManagerImpl.mockClear(); + MockPollingDatafileManager.mockClear(); + MockIntervalRepeater.mockClear(); + MockExponentialBackoff.mockClear(); + }); + + it('uses a repeater with exponential backoff for the datafileManager', () => { + const config = { + sdkKey: 'sdkKey', + requestHandler: { makeRequest: vi.fn() }, + }; + const projectConfigManager = getPollingConfigManager(config); + expect(Object.is(projectConfigManager, MockProjectConfigManagerImpl.mock.instances[0])).toBe(true); + const usedDatafileManager = MockProjectConfigManagerImpl.mock.calls[0][0].datafileManager; + expect(Object.is(usedDatafileManager, MockPollingDatafileManager.mock.instances[0])).toBe(true); + const usedRepeater = MockPollingDatafileManager.mock.calls[0][0].repeater; + expect(Object.is(usedRepeater, MockIntervalRepeater.mock.instances[0])).toBe(true); + const usedBackoff = MockIntervalRepeater.mock.calls[0][1]; + expect(Object.is(usedBackoff, MockExponentialBackoff.mock.instances[0])).toBe(true); + }); + + it('uses the default update interval if not provided', () => { + const config = { + sdkKey: 'sdkKey', + requestHandler: { makeRequest: vi.fn() }, + }; + getPollingConfigManager(config); + expect(MockIntervalRepeater.mock.calls[0][0]).toBe(DEFAULT_UPDATE_INTERVAL); + expect(MockPollingDatafileManager.mock.calls[0][0].updateInterval).toBe(DEFAULT_UPDATE_INTERVAL); + }); + + it('uses the provided options', () => { + const config = { + datafile: '{}', + jsonSchemaValidator: vi.fn(), + sdkKey: 'sdkKey', + requestHandler: { makeRequest: vi.fn() }, + updateInterval: 50000, + autoUpdate: true, + urlTemplate: 'urlTemplate', + datafileAccessToken: 'datafileAccessToken', + cache: { get: vi.fn(), set: vi.fn(), contains: vi.fn(), remove: vi.fn() }, + }; + + getPollingConfigManager(config); + expect(MockIntervalRepeater.mock.calls[0][0]).toBe(config.updateInterval); + expect(MockExponentialBackoff).toHaveBeenNthCalledWith(1, 1000, config.updateInterval, 500); + + expect(MockPollingDatafileManager).toHaveBeenNthCalledWith(1, expect.objectContaining({ + sdkKey: config.sdkKey, + autoUpdate: config.autoUpdate, + updateInterval: config.updateInterval, + urlTemplate: config.urlTemplate, + datafileAccessToken: config.datafileAccessToken, + requestHandler: config.requestHandler, + repeater: MockIntervalRepeater.mock.instances[0], + cache: config.cache, + })); + + expect(MockProjectConfigManagerImpl).toHaveBeenNthCalledWith(1, expect.objectContaining({ + datafile: config.datafile, + jsonSchemaValidator: config.jsonSchemaValidator, + })); + }); +}); diff --git a/lib/project_config/config_manager_factory.ts b/lib/project_config/config_manager_factory.ts new file mode 100644 index 000000000..4d1977663 --- /dev/null +++ b/lib/project_config/config_manager_factory.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { RequestHandler } from "../utils/http_request_handler/http"; +import { Transformer } from "../utils/type"; +import { DatafileManagerConfig } from "./datafile_manager"; +import { ProjectConfigManagerImpl, ProjectConfigManager } from "./project_config_manager"; +import { PollingDatafileManager } from "./polling_datafile_manager"; +import PersistentKeyValueCache from "../plugins/key_value_cache/persistentKeyValueCache"; +import { DEFAULT_UPDATE_INTERVAL } from './constant'; +import { ExponentialBackoff, IntervalRepeater } from "../utils/repeater/repeater"; + +export type StaticConfigManagerConfig = { + datafile: string, + jsonSchemaValidator?: Transformer, +}; + +export const createStaticProjectConfigManager = ( + config: StaticConfigManagerConfig +): ProjectConfigManager => { + return new ProjectConfigManagerImpl(config); +}; + +export type PollingConfigManagerConfig = { + datafile?: string, + sdkKey: string, + jsonSchemaValidator?: Transformer, + autoUpdate?: boolean; + updateInterval?: number; + urlTemplate?: string; + datafileAccessToken?: string; + cache?: PersistentKeyValueCache; +}; + +export type PollingConfigManagerFactoryOptions = PollingConfigManagerConfig & { requestHandler: RequestHandler }; + +export const getPollingConfigManager = ( + opt: PollingConfigManagerFactoryOptions +): ProjectConfigManager => { + const updateInterval = opt.updateInterval ?? DEFAULT_UPDATE_INTERVAL; + + const backoff = new ExponentialBackoff(1000, updateInterval, 500); + const repeater = new IntervalRepeater(updateInterval, backoff); + + const datafileManagerConfig: DatafileManagerConfig = { + sdkKey: opt.sdkKey, + autoUpdate: opt.autoUpdate, + updateInterval: updateInterval, + urlTemplate: opt.urlTemplate, + datafileAccessToken: opt.datafileAccessToken, + requestHandler: opt.requestHandler, + cache: opt.cache, + repeater, + }; + + const datafileManager = new PollingDatafileManager(datafileManagerConfig); + + return new ProjectConfigManagerImpl({ + datafile: opt.datafile, + datafileManager, + jsonSchemaValidator: opt.jsonSchemaValidator, + }); +}; diff --git a/lib/modules/datafile-manager/config.ts b/lib/project_config/constant.ts similarity index 100% rename from lib/modules/datafile-manager/config.ts rename to lib/project_config/constant.ts diff --git a/lib/modules/datafile-manager/datafileManager.ts b/lib/project_config/datafile_manager.ts similarity index 55% rename from lib/modules/datafile-manager/datafileManager.ts rename to lib/project_config/datafile_manager.ts index abf11d8e9..32798495e 100644 --- a/lib/modules/datafile-manager/datafileManager.ts +++ b/lib/project_config/datafile_manager.ts @@ -13,39 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import PersistentKeyValueCache from '../../plugins/key_value_cache/persistentKeyValueCache'; +import { Service } from '../service'; +import PersistentKeyValueCache from '../plugins/key_value_cache/persistentKeyValueCache'; +import { RequestHandler } from '../utils/http_request_handler/http'; +import { Fn, Consumer } from '../utils/type'; +import { Repeater } from '../utils/repeater/repeater'; +import { LoggerFacade } from '../modules/logging'; -export interface DatafileUpdate { - datafile: string; +export interface DatafileManager extends Service { + get(): string | undefined; + onUpdate(listener: Consumer): Fn; + setLogger(logger: LoggerFacade): void; } -export interface DatafileUpdateListener { - (datafileUpdate: DatafileUpdate): void; -} - -// TODO: Replace this with the one from js-sdk-models -interface Managed { - start(): void; - - stop(): Promise; -} - -export interface DatafileManager extends Managed { - get: () => string; - on: (eventName: string, listener: DatafileUpdateListener) => () => void; - onReady: () => Promise; -} - -export interface DatafileManagerConfig { +export type DatafileManagerConfig = { + requestHandler: RequestHandler; autoUpdate?: boolean; - datafile?: string; sdkKey: string; /** Polling interval in milliseconds to check for datafile updates. */ updateInterval?: number; urlTemplate?: string; cache?: PersistentKeyValueCache; -} - -export interface NodeDatafileManagerConfig extends DatafileManagerConfig { datafileAccessToken?: string; + initRetry?: number; + repeater: Repeater; + logger?: LoggerFacade; } diff --git a/lib/project_config/polling_datafile_manager.spec.ts b/lib/project_config/polling_datafile_manager.spec.ts new file mode 100644 index 000000000..8e12ac3f5 --- /dev/null +++ b/lib/project_config/polling_datafile_manager.spec.ts @@ -0,0 +1,951 @@ +/** + * 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 + * + * 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 { describe, it, expect, vi } from 'vitest'; + +import { PollingDatafileManager} from './polling_datafile_manager'; +import { getMockRepeater } from '../tests/mock/mock_repeater'; +import { getMockAbortableRequest, getMockRequestHandler } from '../tests/mock/mock_request_handler'; +import PersistentKeyValueCache from '../../lib/plugins/key_value_cache/persistentKeyValueCache'; +import { getMockLogger } from '../tests/mock/mock_logger'; +import { DEFAULT_AUTHENTICATED_URL_TEMPLATE, DEFAULT_URL_TEMPLATE, MIN_UPDATE_INTERVAL, UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE } from './constant'; +import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { ServiceState } from '../service'; +import exp from 'constants'; + +const testCache = (): PersistentKeyValueCache => ({ + get(key: string): Promise { + let val = undefined; + switch (key) { + case 'opt-datafile-keyThatExists': + val = JSON.stringify({ name: 'keyThatExists' }); + break; + } + return Promise.resolve(val); + }, + + set(): Promise { + return Promise.resolve(); + }, + + contains(): Promise { + return Promise.resolve(false); + }, + + remove(): Promise { + return Promise.resolve(false); + }, +}); + +describe('PollingDatafileManager', () => { + it('should log polling interval below MIN_UPDATE_INTERVAL', () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const logger = getMockLogger(); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: '123', + logger, + updateInterval: MIN_UPDATE_INTERVAL - 1000, + }); + manager.start(); + expect(logger.warn).toHaveBeenCalledWith(UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE); + }); + + it('should not log polling interval above MIN_UPDATE_INTERVAL', () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const logger = getMockLogger(); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: '123', + logger, + updateInterval: MIN_UPDATE_INTERVAL + 1000, + }); + manager.start(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('starts the repeater with immediateExecution on start', () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: '123', + }); + manager.start(); + expect(repeater.start).toHaveBeenCalledWith(true); + }); + + describe('when cached datafile is available', () => { + it('uses cached version of datafile, resolves onRunning() and calls onUpdate handlers while datafile fetch request waits', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); // response promise is pending + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + cache: testCache(), + }); + + manager.start(); + repeater.execute(0); + const listener = vi.fn(); + + manager.onUpdate(listener); + await expect(manager.onRunning()).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith(JSON.stringify({ name: 'keyThatExists' })); + }); + + it('uses cached version of datafile, resolves onRunning() and calls onUpdate handlers even if fetch request fails', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + cache: testCache(), + }); + + manager.start(); + repeater.execute(0); + + const listener = vi.fn(); + + manager.onUpdate(listener); + await expect(manager.onRunning()).resolves.toBeUndefined(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(JSON.stringify({ name: 'keyThatExists' })); + }); + + it('uses cached version of datafile, then calls onUpdate when fetch request succeeds after the cache read', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + cache: testCache(), + }); + + manager.start(); + repeater.execute(0); + + const listener = vi.fn(); + + manager.onUpdate(listener); + await expect(manager.onRunning()).resolves.toBeUndefined(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenNthCalledWith(1, JSON.stringify({ name: 'keyThatExists' })); + + mockResponse.mockResponse.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} }); + await mockResponse.mockResponse; + expect(listener).toHaveBeenNthCalledWith(2, '{"foo": "bar"}'); + }); + + it('ignores cached datafile if fetch request succeeds before cache read completes', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const cache = testCache(); + // this will be resolved after the requestHandler response is resolved + const cachePromise = resolvablePromise(); + cache.get = () => cachePromise.promise; + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + cache, + }); + + manager.start(); + repeater.execute(0); + + const listener = vi.fn(); + + manager.onUpdate(listener); + await expect(manager.onRunning()).resolves.toBeUndefined(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('{"foo": "bar"}'); + + cachePromise.resolve(JSON.stringify({ name: 'keyThatExists '})); + await cachePromise.promise; + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).not.toHaveBeenCalledWith(JSON.stringify({ name: 'keyThatExists' })); + }); + }); + + it('returns a failing promise to repeater if requestHandler.makeRequest return non-success status code', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 500, body: '', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + manager.start(); + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + }); + + it('returns a failing promise to repeater if requestHandler.makeRequest promise fails', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + manager.start(); + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + }); + + it('returns a promise that resolves to repeater if requestHandler.makeRequest succeedes', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + manager.start(); + const ret = repeater.execute(0); + await expect(ret).resolves.not.toThrow(); + }); + + describe('start', () => { + it('retries specified number of times before rejecting onRunning() and onTerminated() when autoupdate is true', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValue(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + initRetry: 5, + autoUpdate: true, + }); + + manager.start(); + + for(let i = 0; i < 6; i++) { + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + } + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(6); + await expect(manager.onRunning()).rejects.toThrow(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + it('retries specified number of times before rejecting onRunning() and onTerminated() when autoupdate is false', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValue(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + initRetry: 5, + autoUpdate: false, + }); + + manager.start(); + + for(let i = 0; i < 6; i++) { + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + } + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(6); + await expect(manager.onRunning()).rejects.toThrow(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + it('stops the repeater when initalization fails', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValue(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + initRetry: 0, + autoUpdate: false, + }); + + manager.start(); + repeater.execute(0); + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(1); + await expect(manager.onRunning()).rejects.toThrow(); + await expect(manager.onTerminated()).rejects.toThrow(); + expect(repeater.stop).toHaveBeenCalled(); + }); + + it('retries specified number of times before rejecting onRunning() and onTerminated() when provided cache does not contain datafile', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValue(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatDoesNotExists', + initRetry: 5, + cache: testCache(), + }); + + manager.start(); + + for(let i = 0; i < 6; i++) { + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + } + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(6); + await expect(manager.onRunning()).rejects.toThrow(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + it('retries init indefinitely if initRetry is not provided when autoupdate is true', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + const promiseCallback = vi.fn(); + manager.onRunning().finally(promiseCallback); + + manager.start(); + const testTry = 10_000; + + for(let i = 0; i < testTry; i++) { + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + } + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(testTry); + expect(promiseCallback).not.toHaveBeenCalled(); + }); + + it('retries init indefinitely if initRetry is not provided when autoupdate is false', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: false, + }); + + const promiseCallback = vi.fn(); + manager.onRunning().finally(promiseCallback); + + manager.start(); + const testTry = 10_000; + + for(let i = 0; i < testTry; i++) { + const ret = repeater.execute(0); + await expect(ret).rejects.toThrow(); + } + + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(testTry); + expect(promiseCallback).not.toHaveBeenCalled(); + }); + + it('successfully resolves onRunning() and calls onUpdate handlers when fetch request succeeds', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + const listener = vi.fn(); + + manager.onUpdate(listener); + + manager.start(); + repeater.execute(0); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('{"foo": "bar"}'); + }); + + it('successfully resolves onRunning() and calls onUpdate handlers when fetch request succeeds after retries', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockFailure = getMockAbortableRequest(Promise.reject('test error')); + const mockSuccess = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockFailure) + .mockReturnValueOnce(mockFailure).mockReturnValueOnce(mockSuccess); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + initRetry: 5, + }); + + const listener = vi.fn(); + + manager.onUpdate(listener); + + manager.start(); + for(let i = 0; i < 2; i++) { + const ret = repeater.execute(0); + expect(ret).rejects.toThrow(); + } + + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenCalledWith('{"foo": "bar"}'); + }); + + it('stops repeater after successful initialization if autoupdate is false', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: false, + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(repeater.stop).toHaveBeenCalled(); + }); + + it('saves the datafile in cache', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const cache = testCache(); + const spy = vi.spyOn(cache, 'set'); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatDoesNotExists', + cache, + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(spy).toHaveBeenCalledWith('opt-datafile-keyThatDoesNotExists', '{"foo": "bar"}'); + }); + }); + + describe('autoupdate', () => { + it('fetches datafile on each tick and calls onUpdate handlers when fetch request succeeds', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + const mockResponse2 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo2": "bar2"}', headers: {} })); + const mockResponse3 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo3": "bar3"}', headers: {} })); + + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse3); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + const listener = vi.fn(); + manager.onUpdate(listener); + + manager.start(); + + for(let i = 0; i <3; i++) { + const ret = repeater.execute(0); + await expect(ret).resolves.not.toThrow(); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenNthCalledWith(1, '{"foo": "bar"}'); + expect(listener).toHaveBeenNthCalledWith(2, '{"foo2": "bar2"}'); + expect(listener).toHaveBeenNthCalledWith(3, '{"foo3": "bar3"}'); + }); + + it('saves the datafile each time in cache', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + const mockResponse2 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo2": "bar2"}', headers: {} })); + const mockResponse3 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo3": "bar3"}', headers: {} })); + + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse3); + + const cache = testCache(); + const spy = vi.spyOn(cache, 'set'); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatDoesNotExists', + autoUpdate: true, + cache, + }); + + const listener = vi.fn(); + manager.onUpdate(listener); + + manager.start(); + + for(let i = 0; i <3; i++) { + const ret = repeater.execute(0); + await expect(ret).resolves.not.toThrow(); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(spy).toHaveBeenNthCalledWith(1, 'opt-datafile-keyThatDoesNotExists', '{"foo": "bar"}'); + expect(spy).toHaveBeenNthCalledWith(2, 'opt-datafile-keyThatDoesNotExists', '{"foo2": "bar2"}'); + expect(spy).toHaveBeenNthCalledWith(3, 'opt-datafile-keyThatDoesNotExists', '{"foo3": "bar3"}'); + }); + + it('logs an error if fetch request fails and does not call onUpdate handler', async () => { + const logger = getMockLogger(); + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + const mockResponse2= getMockAbortableRequest(Promise.reject('test error')); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse2); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + logger, + }); + + const listener = vi.fn(); + manager.onUpdate(listener); + + manager.start(); + for(let i = 0; i < 3; i++) { + await repeater.execute(0).catch(() => {}); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('logs an error if fetch returns non success response and does not call onUpdate handler', async () => { + const logger = getMockLogger(); + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + const mockResponse2= getMockAbortableRequest(Promise.resolve({ statusCode: 500, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse2); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + logger, + }); + + const listener = vi.fn(); + manager.onUpdate(listener); + + manager.start(); + for(let i = 0; i < 3; i++) { + await repeater.execute(0).catch(() => {}); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('saves and uses last-modified header', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest( + Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: { 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT' } })); + const mockResponse2 = getMockAbortableRequest( + Promise.resolve({ statusCode: 304, body: '', headers: {} })); + const mockResponse3 = getMockAbortableRequest( + Promise.resolve({ statusCode: 200, body: '{"foo2": "bar2"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse3); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatDoesNotExists', + autoUpdate: true, + }); + + manager.start(); + + for(let i = 0; i <3; i++) { + await repeater.execute(0); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + const secondCallHeaders = requestHandler.makeRequest.mock.calls[1][1]; + expect(secondCallHeaders['if-modified-since']).toBe('Fri, 08 Mar 2019 18:57:17 GMT'); + const thirdCallHeaders = requestHandler.makeRequest.mock.calls[1][1]; + expect(thirdCallHeaders['if-modified-since']).toBe('Fri, 08 Mar 2019 18:57:17 GMT'); + }); + + it('does not call onUpdate handler if status is 304', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse1 = getMockAbortableRequest( + Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: { 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT' } })); + const mockResponse2 = getMockAbortableRequest( + Promise.resolve({ statusCode: 304, body: '{"foo2": "bar2"}', headers: {} })); + const mockResponse3 = getMockAbortableRequest( + Promise.resolve({ statusCode: 200, body: '{"foo3": "bar3"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse1) + .mockReturnValueOnce(mockResponse2).mockReturnValueOnce(mockResponse3); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatDoesNotExists', + autoUpdate: true, + }); + + manager.start(); + + const listener = vi.fn(); + manager.onUpdate(listener); + + for(let i = 0; i <3; i++) { + await repeater.execute(0); + } + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).not.toHaveBeenCalledWith('{"foo2": "bar2"}'); + expect(listener).toHaveBeenNthCalledWith(1, '{"foo": "bar"}'); + expect(listener).toHaveBeenNthCalledWith(2, '{"foo3": "bar3"}'); + }); + }); + + it('sends the access token in the request Authorization header', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + datafileAccessToken: 'token123', + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(requestHandler.makeRequest.mock.calls[0][1].Authorization).toBe('Bearer token123'); + }); + + it('uses the provided urlTemplate', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + urlTemplate: 'https://example.com/datafile?key=%s', + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(requestHandler.makeRequest.mock.calls[0][0]).toBe('https://example.com/datafile?key=keyThatExists'); + }); + + it('uses the default urlTemplate if none is provided and datafileAccessToken is also not provided', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(requestHandler.makeRequest.mock.calls[0][0]).toBe(DEFAULT_URL_TEMPLATE.replace('%s', 'keyThatExists')); + }); + + it('uses the default authenticated urlTemplate if none is provided and datafileAccessToken is provided', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + datafileAccessToken: 'token123', + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + expect(requestHandler.makeRequest.mock.calls[0][0]).toBe(DEFAULT_AUTHENTICATED_URL_TEMPLATE.replace('%s', 'keyThatExists')); + }); + + it('returns the datafile from get', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.get()).toBe('{"foo": "bar"}'); + }); + + it('returns undefined from get before becoming ready', () => { + const repeater = getMockRepeater(); + const mockResponse = getMockAbortableRequest(); + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + }); + manager.start(); + expect(manager.get()).toBeUndefined(); + }); + + it('removes the onUpdate handler when the retuned function is called', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValue(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + const listener = vi.fn(); + const removeListener = manager.onUpdate(listener); + + manager.start(); + repeater.execute(0); + + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(listener).toHaveBeenCalledTimes(1); + removeListener(); + + await repeater.execute(0); + expect(listener).toHaveBeenCalledTimes(1); + }); + + describe('stop', () => { + it('rejects onRunning when stop is called if manager state is New', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + expect(manager.getState()).toBe(ServiceState.New); + manager.stop(); + await expect(manager.onRunning()).rejects.toThrow(); + }); + + it('rejects onRunning when stop is called if manager state is Starting', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + manager.start(); + expect(manager.getState()).toBe(ServiceState.Starting); + manager.stop(); + await expect(manager.onRunning()).rejects.toThrow(); + }); + + it('stops the repeater, set state to Termimated, and resolve onTerminated when stop is called', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + manager.start(); + await repeater.execute(0); + await expect(manager.onRunning()).resolves.not.toThrow(); + + manager.stop(); + await expect(manager.onTerminated()).resolves.not.toThrow(); + expect(repeater.stop).toHaveBeenCalled(); + expect(manager.getState()).toBe(ServiceState.Terminated); + }); + + it('aborts the current request if stop is called', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + manager.start(); + repeater.execute(0); + + expect(requestHandler.makeRequest).toHaveBeenCalledOnce(); + manager.stop(); + expect(mockResponse.abort).toHaveBeenCalled(); + }); + + it('does not call onUpdate handler after stop is called', async () => { + const repeater = getMockRepeater(); + const requestHandler = getMockRequestHandler(); + const mockResponse = getMockAbortableRequest(Promise.resolve({ statusCode: 200, body: '{"foo": "bar"}', headers: {} })); + requestHandler.makeRequest.mockReturnValueOnce(mockResponse); + + const manager = new PollingDatafileManager({ + repeater, + requestHandler, + sdkKey: 'keyThatExists', + autoUpdate: true, + }); + + const listener = vi.fn(); + manager.onUpdate(listener); + + manager.start(); + repeater.execute(0); + manager.stop(); + + expect(listener).not.toHaveBeenCalled(); + }); + }) +}); diff --git a/lib/project_config/polling_datafile_manager.ts b/lib/project_config/polling_datafile_manager.ts new file mode 100644 index 000000000..3784fbfd6 --- /dev/null +++ b/lib/project_config/polling_datafile_manager.ts @@ -0,0 +1,245 @@ +/** + * 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LoggerFacade } from '../modules/logging'; +import { sprintf } from '../utils/fns'; +import { DatafileManager, DatafileManagerConfig } from './datafile_manager'; +import { EventEmitter } from '../utils/event_emitter/event_emitter'; +import { DEFAULT_AUTHENTICATED_URL_TEMPLATE, DEFAULT_URL_TEMPLATE, MIN_UPDATE_INTERVAL, UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE } from './constant'; +import PersistentKeyValueCache from '../plugins/key_value_cache/persistentKeyValueCache'; + +import { BaseService, ServiceState } from '../service'; +import { RequestHandler, AbortableRequest, Headers, Response } from '../utils/http_request_handler/http'; +import { Repeater } from '../utils/repeater/repeater'; +import { Consumer, Fn } from '../utils/type'; +import { url } from 'inspector'; + +function isSuccessStatusCode(statusCode: number): boolean { + return statusCode >= 200 && statusCode < 400; +} + +export class PollingDatafileManager extends BaseService implements DatafileManager { + private requestHandler: RequestHandler; + private currentDatafile?: string; + private emitter: EventEmitter<{ update: string }>; + private autoUpdate: boolean; + private initRetryRemaining?: number; + private repeater: Repeater; + private updateInterval?: number; + + private lastResponseLastModified?: string; + private datafileUrl: string; + private currentRequest?: AbortableRequest; + private cacheKey: string; + private cache?: PersistentKeyValueCache; + private sdkKey: string; + private datafileAccessToken?: string; + private logger?: LoggerFacade; + + constructor(config: DatafileManagerConfig) { + super(); + const { + autoUpdate = false, + sdkKey, + datafileAccessToken, + urlTemplate, + cache, + initRetry, + repeater, + requestHandler, + updateInterval, + logger, + } = config; + this.cache = cache; + this.cacheKey = 'opt-datafile-' + sdkKey; + this.sdkKey = sdkKey; + this.datafileAccessToken = datafileAccessToken; + this.requestHandler = requestHandler; + this.emitter = new EventEmitter(); + this.autoUpdate = autoUpdate; + this.initRetryRemaining = initRetry; + this.repeater = repeater; + this.updateInterval = updateInterval; + this.logger = logger; + + const urlTemplateToUse = urlTemplate || + (datafileAccessToken ? DEFAULT_AUTHENTICATED_URL_TEMPLATE : DEFAULT_URL_TEMPLATE); + this.datafileUrl = sprintf(urlTemplateToUse, this.sdkKey); + } + + setLogger(logger: LoggerFacade): void { + this.logger = logger; + } + + onUpdate(listener: Consumer): Fn { + return this.emitter.on('update', listener); + } + + get(): string | undefined { + return this.currentDatafile; + } + + start(): void { + if (!this.isNew()) { + return; + } + + if (this.updateInterval !== undefined && this.updateInterval < MIN_UPDATE_INTERVAL) { + this.logger?.warn(UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE); + } + + this.state = ServiceState.Starting; + this.setDatafileFromCacheIfAvailable(); + this.repeater.setTask(this.syncDatafile.bind(this)); + this.repeater.start(true); + } + + stop(): void { + if (this.isDone()) { + return; + } + + if (this.isNew() || this.isStarting()) { + // TOOD: replace message with imported constants + this.startPromise.reject(new Error('Datafile manager stopped before it could be started')); + } + + this.logger?.debug('Datafile manager stopped'); + this.state = ServiceState.Terminated; + this.repeater.stop(); + this.currentRequest?.abort(); + this.emitter.removeAllListeners(); + this.stopPromise.resolve(); + } + + private handleInitFailure(): void { + this.state = ServiceState.Failed; + this.repeater.stop(); + // TODO: replace message with imported constants + const error = new Error('Failed to fetch datafile'); + this.startPromise.reject(error); + this.stopPromise.reject(error); + } + + private handleError(errorOrStatus: Error | number): void { + if (this.isDone()) { + return; + } + + // TODO: replace message with imported constants + if (errorOrStatus instanceof Error) { + this.logger?.error('Error fetching datafile: %s', errorOrStatus.message, errorOrStatus); + } else { + this.logger?.error(`Datafile fetch request failed with status: ${errorOrStatus}`); + } + + if(this.isStarting() && this.initRetryRemaining !== undefined) { + if (this.initRetryRemaining === 0) { + this.handleInitFailure(); + } else { + this.initRetryRemaining--; + } + } + } + + private async onRequestRejected(err: any): Promise { + this.handleError(err); + return Promise.reject(err); + } + + private async onRequestResolved(response: Response): Promise { + if (this.isDone()) { + return; + } + + this.saveLastModified(response.headers); + + if (!isSuccessStatusCode(response.statusCode)) { + this.handleError(response.statusCode); + return Promise.reject(new Error()); + } + + const datafile = this.getDatafileFromResponse(response); + if (datafile) { + this.handleDatafile(datafile); + // if autoUpdate is off, don't need to sync datafile any more + if (!this.autoUpdate) { + this.repeater.stop(); + } + } + } + + private makeDatafileRequest(): AbortableRequest { + const headers: Headers = {}; + if (this.lastResponseLastModified) { + headers['if-modified-since'] = this.lastResponseLastModified; + } + + if (this.datafileAccessToken) { + this.logger?.debug('Adding Authorization header with Bearer Token'); + headers['Authorization'] = `Bearer ${this.datafileAccessToken}`; + } + + this.logger?.debug('Making datafile request to url %s with headers: %s', this.datafileUrl, () => JSON.stringify(headers)); + return this.requestHandler.makeRequest(this.datafileUrl, headers, 'GET'); + } + + private async syncDatafile(): Promise { + this.currentRequest = this.makeDatafileRequest(); + return this.currentRequest.responsePromise + .then(this.onRequestResolved.bind(this), this.onRequestRejected.bind(this)) + .finally(() => this.currentRequest = undefined); + } + + private handleDatafile(datafile: string): void { + if (this.isDone()) { + return; + } + + this.currentDatafile = datafile; + this.cache?.set(this.cacheKey, datafile); + + if (this.isStarting()) { + this.startPromise.resolve(); + this.state = ServiceState.Running; + } + this.emitter.emit('update', datafile); + } + + private getDatafileFromResponse(response: Response): string | undefined{ + this.logger?.debug('Response status code: %s', response.statusCode); + if (response.statusCode === 304) { + return undefined; + } + return response.body; + } + + private saveLastModified(headers: Headers): void { + const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified']; + if (lastModifiedHeader !== undefined) { + this.lastResponseLastModified = lastModifiedHeader; + this.logger?.debug('Saved last modified header value from response: %s', this.lastResponseLastModified); + } + } + + private setDatafileFromCacheIfAvailable(): void { + this.cache?.get(this.cacheKey).then(datafile => { + if (datafile && this.isStarting()) { + this.handleDatafile(datafile); + } + }).catch(() => {}); + } +} diff --git a/lib/core/project_config/index.tests.js b/lib/project_config/project_config.tests.js similarity index 96% rename from lib/core/project_config/index.tests.js rename to lib/project_config/project_config.tests.js index 24134e3cd..c49a75dad 100644 --- a/lib/core/project_config/index.tests.js +++ b/lib/project_config/project_config.tests.js @@ -16,15 +16,15 @@ import sinon from 'sinon'; import { assert } from 'chai'; import { forEach, cloneDeep } from 'lodash'; -import { sprintf } from '../../utils/fns'; -import { getLogger } from '../../modules/logging'; +import { sprintf } from '../utils/fns'; +import { getLogger } from '../modules/logging'; -import fns from '../../utils/fns'; -import projectConfig from './'; -import { ERROR_MESSAGES, FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../../utils/enums'; -import * as loggerPlugin from '../../plugins/logger'; -import testDatafile from '../../tests/test_data'; -import configValidator from '../../utils/config_validator'; +import fns from '../utils/fns'; +import projectConfig from './project_config'; +import { ERROR_MESSAGES, FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../utils/enums'; +import * as loggerPlugin from '../plugins/logger'; +import testDatafile from '../tests/test_data'; +import configValidator from '../utils/config_validator'; var buildLogMessageFromArgs = args => sprintf(args[1], ...args.splice(2)); var logger = getLogger(); @@ -874,9 +874,7 @@ describe('lib/core/project_config', function() { describe('#tryCreatingProjectConfig', function() { var stubJsonSchemaValidator; beforeEach(function() { - stubJsonSchemaValidator = { - validate: sinon.stub().returns(true), - }; + stubJsonSchemaValidator = sinon.stub().returns(true); sinon.stub(configValidator, 'validateDatafile').returns(true); sinon.spy(logger, 'error'); }); @@ -900,7 +898,7 @@ describe('#tryCreatingProjectConfig', function() { }, }; - stubJsonSchemaValidator.validate.returns(true); + stubJsonSchemaValidator.returns(true); var result = projectConfig.tryCreatingProjectConfig({ datafile: configDatafile, @@ -908,29 +906,31 @@ describe('#tryCreatingProjectConfig', function() { logger: logger, }); - assert.deepInclude(result.configObj, configObj); + assert.deepInclude(result, configObj); }); - it('returns an error when validateDatafile throws', function() { + it('throws an error when validateDatafile throws', function() { configValidator.validateDatafile.throws(); - stubJsonSchemaValidator.validate.returns(true); - var { error } = projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, - jsonSchemaValidator: stubJsonSchemaValidator, - logger: logger, + stubJsonSchemaValidator.returns(true); + assert.throws(() => { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + }); }); - assert.isNotNull(error); }); - it('returns an error when jsonSchemaValidator.validate throws', function() { + it('throws an error when jsonSchemaValidator.validate throws', function() { configValidator.validateDatafile.returns(true); - stubJsonSchemaValidator.validate.throws(); - var { error } = projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, - jsonSchemaValidator: stubJsonSchemaValidator, - logger: logger, + stubJsonSchemaValidator.throws(); + assert.throws(() => { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + }); }); - assert.isNotNull(error); }); it('skips json validation when jsonSchemaValidator is not provided', function() { @@ -954,7 +954,7 @@ describe('#tryCreatingProjectConfig', function() { logger: logger, }); - assert.deepInclude(result.configObj, configObj); + assert.deepInclude(result, configObj); sinon.assert.notCalled(logger.error); }); }); diff --git a/lib/core/project_config/index.ts b/lib/project_config/project_config.ts similarity index 96% rename from lib/core/project_config/index.ts rename to lib/project_config/project_config.ts index 68ffbeacd..a31dabe5e 100644 --- a/lib/core/project_config/index.ts +++ b/lib/project_config/project_config.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { find, objectEntries, objectValues, sprintf, assign, keyBy } from '../../utils/fns'; +import { find, objectEntries, objectValues, sprintf, assign, keyBy } from '../utils/fns'; -import { ERROR_MESSAGES, LOG_LEVEL, LOG_MESSAGES, FEATURE_VARIABLE_TYPES } from '../../utils/enums'; -import configValidator from '../../utils/config_validator'; +import { ERROR_MESSAGES, LOG_LEVEL, LOG_MESSAGES, FEATURE_VARIABLE_TYPES } from '../utils/enums'; +import configValidator from '../utils/config_validator'; -import { LogHandler } from '../../modules/logging'; +import { LogHandler } from '../modules/logging'; import { Audience, Experiment, @@ -33,17 +33,16 @@ import { VariationVariable, Integration, FeatureVariableValue, -} from '../../shared_types'; -import { OdpConfig, OdpIntegrationConfig } from '../odp/odp_config'; +} from '../shared_types'; +import { OdpConfig, OdpIntegrationConfig } from '../core/odp/odp_config'; +import { Transformer } from '../utils/type'; interface TryCreatingProjectConfigConfig { // TODO[OASIS-6649]: Don't use object type // eslint-disable-next-line @typescript-eslint/ban-types datafile: string | object; - jsonSchemaValidator?: { - validate(jsonObject: unknown): boolean; - }; - logger: LogHandler; + jsonSchemaValidator?: Transformer; + logger?: LogHandler; } interface Event { @@ -797,34 +796,25 @@ export const toDatafile = function(projectConfig: ProjectConfig): string { /** * Try to create a project config object from the given datafile and * configuration properties. - * Returns an object with configObj and error properties. - * If successful, configObj is the project config object, and error is null. - * Otherwise, configObj is null and error is an error with more information. + * Returns a ProjectConfig if successful. + * Otherwise, throws an error. * @param {Object} config * @param {Object|string} config.datafile * @param {Object} config.jsonSchemaValidator * @param {Object} config.logger - * @returns {Object} Object containing configObj and error properties + * @returns {Object} ProjectConfig + * @throws {Error} */ export const tryCreatingProjectConfig = function( config: TryCreatingProjectConfigConfig -): { configObj: ProjectConfig | null; error: Error | null } { - let newDatafileObj; - try { - newDatafileObj = configValidator.validateDatafile(config.datafile); - } catch (error) { - return { configObj: null, error }; - } +): ProjectConfig { + const newDatafileObj = configValidator.validateDatafile(config.datafile); if (config.jsonSchemaValidator) { - try { - config.jsonSchemaValidator.validate(newDatafileObj); - config.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME); - } catch (error) { - return { configObj: null, error }; - } + config.jsonSchemaValidator(newDatafileObj); + config.logger?.log(LOG_LEVEL.INFO, LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME); } else { - config.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME); + config.logger?.log(LOG_LEVEL.INFO, LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME); } const createProjectConfigArgs = [newDatafileObj]; @@ -834,11 +824,7 @@ export const tryCreatingProjectConfig = function( } const newConfigObj = createProjectConfig(...createProjectConfigArgs); - - return { - configObj: newConfigObj, - error: null, - }; + return newConfigObj; }; /** diff --git a/lib/project_config/project_config_manager.spec.ts b/lib/project_config/project_config_manager.spec.ts new file mode 100644 index 000000000..5a568188d --- /dev/null +++ b/lib/project_config/project_config_manager.spec.ts @@ -0,0 +1,522 @@ +/** + * 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. + * 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 { describe, it, expect, vi } from 'vitest'; +import { ProjectConfigManagerImpl } from './project_config_manager'; +import { getMockLogger } from '../tests/mock/mock_logger'; +import { ServiceState } from '../service'; +import * as testData from '../tests/test_data'; +import { createProjectConfig } from './project_config'; +import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { getMockDatafileManager } from '../tests/mock/mock_datafile_manager'; +import { wait } from '../../tests/testUtils'; + +const cloneDeep = (x: any) => JSON.parse(JSON.stringify(x)); + +describe('ProjectConfigManagerImpl', () => { + it('should reject onRunning() and log error if neither datafile nor a datafileManager is passed into the constructor', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger}); + manager.start(); + await expect(manager.onRunning()).rejects.toThrow(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should set status to Failed if neither datafile nor a datafileManager is passed into the constructor', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger}); + manager.start(); + await expect(manager.onRunning()).rejects.toThrow(); + expect(manager.getState()).toBe(ServiceState.Failed); + }); + + it('should reject onTerminated if neither datafile nor a datafileManager is passed into the constructor', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger}); + manager.start(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + describe('when constructed with only a datafile', () => { + it('should reject onRunning() and log error if the datafile is invalid', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: {}}); + manager.start(); + await expect(manager.onRunning()).rejects.toThrow(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should set status to Failed if the datafile is invalid', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: {}}); + manager.start(); + await expect(manager.onRunning()).rejects.toThrow(); + expect(manager.getState()).toBe(ServiceState.Failed); + }); + + it('should reject onTerminated if the datafile is invalid', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger}); + manager.start(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + it('should fulfill onRunning() and set status to Running if the datafile is valid', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + manager.start(); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getState()).toBe(ServiceState.Running); + }); + + it('should call onUpdate listeners registered before or after start() with the project config after resolving onRunning()', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + const listener1 = vi.fn(); + manager.onUpdate(listener1); + manager.start(); + const listener2 = vi.fn(); + manager.onUpdate(listener2); + expect(listener1).not.toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalledOnce(); + + await manager.onRunning(); + + expect(listener1).toHaveBeenCalledOnce(); + expect(listener2).toHaveBeenCalledOnce(); + + expect(listener1).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + expect(listener2).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + }); + + it('should return the correct config from getConfig() both before or after onRunning() resolves', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + manager.start(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + await manager.onRunning(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + }); + }); + + describe('when constructed with a datafileManager', () => { + describe('when datafile is also provided', () => { + describe('when datafile is valid', () => { + it('should resolve onRunning() before datafileManger.onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ + onRunning: resolvablePromise().promise, // this will not be resolved + }); + vi.spyOn(datafileManager, 'onRunning'); + const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + manager.start(); + + expect(datafileManager.onRunning).toHaveBeenCalled(); + await expect(manager.onRunning()).resolves.not.toThrow(); + }); + + it('should resolve onRunning() even if datafileManger.onRunning() rejects', async () => { + const onRunning = Promise.reject(new Error('onRunning error')); + const datafileManager = getMockDatafileManager({ + onRunning, + }); + vi.spyOn(datafileManager, 'onRunning'); + const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + manager.start(); + + expect(datafileManager.onRunning).toHaveBeenCalled(); + await expect(manager.onRunning()).resolves.not.toThrow(); + }); + + it('should call the onUpdate handler before datafileManger.onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ + onRunning: resolvablePromise().promise, // this will not be resolved + }); + + const listener = vi.fn(); + + const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + manager.start(); + manager.onUpdate(listener); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + }); + + it('should return the correct config from getConfig() both before or after onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ + onRunning: resolvablePromise().promise, // this will not be resolved + }); + + const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + manager.start(); + + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + await manager.onRunning(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + }); + }); + + describe('when datafile is invalid', () => { + it('should reject onRunning() with the same error if datafileManager.onRunning() rejects', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.reject('test error') }); + const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + manager.start(); + await expect(manager.onRunning()).rejects.toBe('test error'); + }); + + it('should resolve onRunning() if datafileManager.onUpdate() is fired and should update config', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + manager.start(); + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + }); + + it('should resolve onRunning(), update config and call onUpdate listeners if datafileManager.onUpdate() is fired', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + manager.start(); + const listener = vi.fn(); + manager.onUpdate(listener); + + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + }); + + it('should return undefined from getConfig() before onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + manager.start(); + expect(manager.getConfig()).toBeUndefined(); + }); + + it('should return the correct config from getConfig() after onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + manager.start(); + + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + }); + }); + }); + + describe('when datafile is not provided', () => { + it('should reject onRunning() if datafileManager.onRunning() rejects', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.reject('test error') }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + await expect(manager.onRunning()).rejects.toBe('test error'); + }); + + it('should reject onRunning() and onTerminated if datafileManager emits an invalid datafile in the first onUpdate', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + datafileManager.pushUpdate('foo'); + await expect(manager.onRunning()).rejects.toThrow(); + await expect(manager.onTerminated()).rejects.toThrow(); + }); + + it('should resolve onRunning(), update config and call onUpdate listeners if datafileManager.onUpdate() is fired', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + const listener = vi.fn(); + manager.onUpdate(listener); + + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + }); + + it('should return undefined from getConfig() before onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + expect(manager.getConfig()).toBeUndefined(); + }); + + it('should return the correct config from getConfig() after onRunning() resolves', async () => { + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await expect(manager.onRunning()).resolves.not.toThrow(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + }); + }); + + it('should update the config and call onUpdate handlers when datafileManager onUpdate is fired with valid datafile', async () => { + const datafileManager = getMockDatafileManager({}); + + const datafile = testData.getTestProjectConfig(); + const manager = new ProjectConfigManagerImpl({ datafile, datafileManager }); + manager.start(); + + const listener = vi.fn(); + manager.onUpdate(listener); + + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + await manager.onRunning(); + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + + const updatedDatafile = cloneDeep(datafile); + updatedDatafile['revision'] = '99'; + datafileManager.pushUpdate(updatedDatafile); + await Promise.resolve(); + + expect(manager.getConfig()).toEqual(createProjectConfig(updatedDatafile)); + expect(listener).toHaveBeenNthCalledWith(2, createProjectConfig(updatedDatafile)); + }); + + it('should not call onUpdate handlers and should log error when datafileManager onUpdate is fired with invalid datafile', async () => { + const datafileManager = getMockDatafileManager({}); + + const logger = getMockLogger(); + const datafile = testData.getTestProjectConfig(); + const manager = new ProjectConfigManagerImpl({ logger, datafile, datafileManager }); + manager.start(); + + const listener = vi.fn(); + manager.onUpdate(listener); + + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + await manager.onRunning(); + expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + + expect(listener).toHaveBeenCalledWith(createProjectConfig(datafile)); + + const updatedDatafile = {}; + datafileManager.pushUpdate(updatedDatafile); + await Promise.resolve(); + + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(listener).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should use the JSON schema validator to validate the datafile', async () => { + const datafileManager = getMockDatafileManager({}); + + const datafile = testData.getTestProjectConfig(); + const jsonSchemaValidator = vi.fn().mockReturnValue(true); + const manager = new ProjectConfigManagerImpl({ datafile, datafileManager, jsonSchemaValidator }); + manager.start(); + + await manager.onRunning(); + + const updatedDatafile = cloneDeep(datafile); + updatedDatafile['revision'] = '99'; + datafileManager.pushUpdate(updatedDatafile); + await Promise.resolve(); + + expect(jsonSchemaValidator).toHaveBeenCalledTimes(2); + expect(jsonSchemaValidator).toHaveBeenNthCalledWith(1, datafile); + expect(jsonSchemaValidator).toHaveBeenNthCalledWith(2, updatedDatafile); + }); + + it('should not call onUpdate handlers when datafileManager onUpdate is fired with the same datafile', async () => { + const datafileManager = getMockDatafileManager({}); + + const datafile = testData.getTestProjectConfig(); + const manager = new ProjectConfigManagerImpl({ datafile, datafileManager }); + manager.start(); + + const listener = vi.fn(); + manager.onUpdate(listener); + + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + await manager.onRunning(); + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + + datafileManager.pushUpdate(cloneDeep(datafile)); + await Promise.resolve(); + + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should remove onUpdate handlers when the returned fuction is called', async () => { + const datafile = testData.getTestProjectConfig(); + const datafileManager = getMockDatafileManager({}); + + const manager = new ProjectConfigManagerImpl({ datafile }); + manager.start(); + + const listener = vi.fn(); + const dispose = manager.onUpdate(listener); + + await manager.onRunning(); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + + dispose(); + + datafileManager.pushUpdate(cloneDeep(testData.getTestProjectConfigWithFeatures())); + await Promise.resolve(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should work with datafile specified as string', async () => { + const datafile = testData.getTestProjectConfig(); + + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(datafile) }); + manager.start(); + + const listener = vi.fn(); + manager.onUpdate(listener); + + await manager.onRunning(); + expect(listener).toHaveBeenCalledWith(createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + }); + + it('should reject onRunning() and log error if the datafile string is an invalid json', async () => { + const logger = getMockLogger(); + const manager = new ProjectConfigManagerImpl({ logger, datafile: 'foo'}); + manager.start(); + await expect(manager.onRunning()).rejects.toThrow(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should reject onRunning() and log error if the datafile version is not supported', async () => { + const logger = getMockLogger(); + const datafile = testData.getUnsupportedVersionConfig(); + const manager = new ProjectConfigManagerImpl({ logger, datafile }); + manager.start(); + + await expect(manager.onRunning()).rejects.toThrow(); + expect(logger.error).toHaveBeenCalled(); + }); + + describe('stop()', () => { + it('should reject onRunning() if stop is called when the datafileManager state is New', async () => { + const datafileManager = getMockDatafileManager({}); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + + expect(manager.getState()).toBe(ServiceState.New); + manager.stop(); + await expect(manager.onRunning()).rejects.toThrow(); + }); + + it('should reject onRunning() if stop is called when the datafileManager state is Starting', async () => { + const datafileManager = getMockDatafileManager({}); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + + manager.start(); + expect(manager.getState()).toBe(ServiceState.Starting); + manager.stop(); + await expect(manager.onRunning()).rejects.toThrow(); + }); + + it('should call datafileManager.stop()', async () => { + const datafileManager = getMockDatafileManager({}); + const spy = vi.spyOn(datafileManager, 'stop'); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + manager.stop(); + expect(spy).toHaveBeenCalled(); + }); + + it('should set status to Terminated immediately if no datafile manager is provided and resolve onTerminated', async () => { + const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig() }); + manager.stop(); + expect(manager.getState()).toBe(ServiceState.Terminated); + await expect(manager.onTerminated()).resolves.not.toThrow(); + }); + + it('should set status to Stopping while awaiting for datafileManager onTerminated', async () => { + const datafileManagerTerminated = resolvablePromise(); + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await manager.onRunning(); + manager.stop(); + + for (let i = 0; i < 100; i++) { + expect(manager.getState()).toBe(ServiceState.Stopping); + await wait(0); + } + }); + + it('should set status to Terminated and resolve onTerminated after datafileManager.onTerminated() resolves', async () => { + const datafileManagerTerminated = resolvablePromise(); + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await manager.onRunning(); + manager.stop(); + + for (let i = 0; i < 50; i++) { + expect(manager.getState()).toBe(ServiceState.Stopping); + await wait(0); + } + + datafileManagerTerminated.resolve(); + await expect(manager.onTerminated()).resolves.not.toThrow(); + expect(manager.getState()).toBe(ServiceState.Terminated); + }); + + it('should set status to Failed and reject onTerminated after datafileManager.onTerminated() rejects', async () => { + const datafileManagerTerminated = resolvablePromise(); + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await manager.onRunning(); + manager.stop(); + + for (let i = 0; i < 50; i++) { + expect(manager.getState()).toBe(ServiceState.Stopping); + await wait(0); + } + + datafileManagerTerminated.reject(); + await expect(manager.onTerminated()).rejects.toThrow(); + expect(manager.getState()).toBe(ServiceState.Failed); + }); + + it('should not call onUpdate handlers after stop is called', async () => { + const datafileManagerTerminated = resolvablePromise(); + const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); + const manager = new ProjectConfigManagerImpl({ datafileManager }); + manager.start(); + const listener = vi.fn(); + manager.onUpdate(listener); + + datafileManager.pushUpdate(testData.getTestProjectConfig()); + await manager.onRunning(); + + expect(listener).toHaveBeenCalledTimes(1); + manager.stop(); + datafileManager.pushUpdate(testData.getTestProjectConfigWithFeatures()); + + datafileManagerTerminated.resolve(); + await expect(manager.onTerminated()).resolves.not.toThrow(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/lib/project_config/project_config_manager.ts b/lib/project_config/project_config_manager.ts new file mode 100644 index 000000000..c03ee9b4c --- /dev/null +++ b/lib/project_config/project_config_manager.ts @@ -0,0 +1,219 @@ +/** + * 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. + * 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 { LoggerFacade } from '../modules/logging'; +import { createOptimizelyConfig } from '../core/optimizely_config'; +import { OptimizelyConfig } from '../shared_types'; +import { DatafileManager } from './datafile_manager'; +import { ProjectConfig, toDatafile, tryCreatingProjectConfig } from './project_config'; +import { scheduleMicrotask } from '../utils/microtask'; +import { Service, ServiceState, BaseService } from '../service'; +import { Consumer, Fn, Transformer } from '../utils/type'; +import { EventEmitter } from '../utils/event_emitter/event_emitter'; + +interface ProjectConfigManagerConfig { + // TODO: Don't use object type + // eslint-disable-next-line @typescript-eslint/ban-types + datafile?: string | object; + jsonSchemaValidator?: Transformer, + datafileManager?: DatafileManager; + logger?: LoggerFacade; +} + +export interface ProjectConfigManager extends Service { + setLogger(logger: LoggerFacade): void; + getConfig(): ProjectConfig | undefined; + getOptimizelyConfig(): OptimizelyConfig | undefined; + onUpdate(listener: Consumer): Fn; +} + +/** + * ProjectConfigManager provides project config objects via its methods + * getConfig and onUpdate. It uses a DatafileManager to fetch datafile if provided. + * It is responsible for parsing and validating datafiles, and converting datafile + * string into project config objects. + * @param {ProjectConfigManagerConfig} config + */ +export class ProjectConfigManagerImpl extends BaseService implements ProjectConfigManager { + private datafile?: string | object; + private projectConfig?: ProjectConfig; + private optimizelyConfig?: OptimizelyConfig; + public jsonSchemaValidator?: Transformer; + public datafileManager?: DatafileManager; + private eventEmitter: EventEmitter<{ update: ProjectConfig }> = new EventEmitter(); + private logger?: LoggerFacade; + + constructor(config: ProjectConfigManagerConfig) { + super(); + this.logger = config.logger; + this.jsonSchemaValidator = config.jsonSchemaValidator; + this.datafile = config.datafile; + this.datafileManager = config.datafileManager; + } + + setLogger(logger: LoggerFacade): void { + this.logger = logger; + } + + start(): void { + if (!this.isNew()) { + return; + } + + this.state = ServiceState.Starting; + if (!this.datafile && !this.datafileManager) { + // TODO: replace message with imported constants + this.handleInitError(new Error('You must provide at least one of sdkKey or datafile')); + return; + } + + if (this.datafile) { + this.handleNewDatafile(this.datafile, true); + } + + this.datafileManager?.start(); + + // This handles the case where the datafile manager starts successfully. The + // datafile manager will only start successfully when it has downloaded a datafile, + // an will fire an onUpdate event. + this.datafileManager?.onUpdate(this.handleNewDatafile.bind(this)); + + // If the datafile manager runs successfully, it will emit a onUpdate event. We can + // handle the success case in the onUpdate handler. Hanlding the error case in the + // catch callback + this.datafileManager?.onRunning().catch((err) => { + this.handleDatafileManagerError(err); + }); + } + + private handleInitError(error: Error): void { + this.logger?.error(error); + this.state = ServiceState.Failed; + this.datafileManager?.stop(); + this.startPromise.reject(error); + this.stopPromise.reject(error); + } + + private handleDatafileManagerError(err: Error): void { + // TODO: replace message with imported constants + this.logger?.error('datafile manager failed to start', err); + + // If datafile manager onRunning() promise is rejected, and the project config manager + // is still in starting state, that means a datafile was not provided in cofig or was invalid, + // otherwise the state would have already been set to running synchronously. + // In this case, we cannot recover. + if (this.isStarting()) { + this.handleInitError(err); + } + } + + /** + * Handle new datafile by attemping to create a new Project Config object. If successful and + * the new config object's revision is newer than the current one, sets/updates the project config + * and emits onUpdate event. If unsuccessful, + * the project config and optimizely config objects will not be updated. If the error + * is fatal, handleInitError will be called. + */ + private handleNewDatafile(newDatafile: string | object, fromConfig = false): void { + if (this.isDone()) { + return; + } + + try { + const config = tryCreatingProjectConfig({ + datafile: newDatafile, + jsonSchemaValidator: this.jsonSchemaValidator, + logger: this.logger, + }); + + if(this.isStarting()) { + this.state = ServiceState.Running; + this.startPromise.resolve(); + } + + if (this.projectConfig?.revision !== config.revision) { + this.projectConfig = config; + this.optimizelyConfig = undefined; + scheduleMicrotask(() => { + this.eventEmitter.emit('update', config); + }) + } + } catch (err) { + this.logger?.error(err); + + // if the state is starting and no datafileManager is provided, we cannot recover. + // If the state is starting and the datafileManager has emitted a datafile, + // that means a datafile was not provided in config or an invalid datafile was provided, + // otherwise the state would have already been set to running synchronously. + // If the first datafile emitted by the datafileManager is invalid, + // we consider this to be an initialization error as well. + const fatalError = (this.isStarting() && !this.datafileManager) || + (this.isStarting() && !fromConfig); + if (fatalError) { + this.handleInitError(err); + } + } + } + + getConfig(): ProjectConfig | undefined { + return this.projectConfig; + } + + getOptimizelyConfig(): OptimizelyConfig | undefined { + if (!this.optimizelyConfig && this.projectConfig) { + this.optimizelyConfig = createOptimizelyConfig(this.projectConfig, toDatafile(this.projectConfig), this.logger); + } + return this.optimizelyConfig; + } + + /** + * Add a listener for project config updates. The listener will be called + * whenever this instance has a new project config object available. + * Returns a dispose function that removes the subscription + * @param {Function} listener + * @return {Function} + */ + onUpdate(listener: Consumer): Fn { + return this.eventEmitter.on('update', listener); + } + + stop(): void { + if (this.isDone()) { + return; + } + + if (this.isNew() || this.isStarting()) { + // TOOD: replace message with imported constants + this.startPromise.reject(new Error('Datafile manager stopped before it could be started')); + } + + this.state = ServiceState.Stopping; + this.eventEmitter.removeAllListeners(); + if (!this.datafileManager) { + this.state = ServiceState.Terminated; + this.stopPromise.resolve(); + return; + } + + this.datafileManager.stop(); + this.datafileManager.onTerminated().then(() => { + this.state = ServiceState.Terminated; + this.stopPromise.resolve(); + }).catch((err) => { + this.state = ServiceState.Failed; + this.stopPromise.reject(err); + }); + } +} diff --git a/lib/core/project_config/project_config_schema.ts b/lib/project_config/project_config_schema.ts similarity index 99% rename from lib/core/project_config/project_config_schema.ts rename to lib/project_config/project_config_schema.ts index 70cecb3d4..c33f013ae 100644 --- a/lib/core/project_config/project_config_schema.ts +++ b/lib/project_config/project_config_schema.ts @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, 2020, Optimizely + * Copyright 2016-2017, 2020, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/service.spec.ts b/lib/service.spec.ts new file mode 100644 index 000000000..1faae69ac --- /dev/null +++ b/lib/service.spec.ts @@ -0,0 +1,107 @@ +/** + * 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 + * + * 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 { it, expect } from 'vitest'; +import { BaseService, ServiceState } from './service'; + +class TestService extends BaseService { + constructor() { + super(); + } + + start(): void { + this.setState(ServiceState.Running); + this.startPromise.resolve(); + } + + failStart(): void { + this.setState(ServiceState.Failed); + this.startPromise.reject(); + } + + stop(): void { + this.setState(ServiceState.Running); + this.startPromise.resolve(); + } + + failStop(): void { + this.setState(ServiceState.Failed); + this.startPromise.reject(); + } + + setState(state: ServiceState): void { + this.state = state; + } +} + + +it('should set state to New on construction', async () => { + const service = new TestService(); + expect(service.getState()).toBe(ServiceState.New); +}); + +it('should return correct state when getState() is called', () => { + const service = new TestService(); + expect(service.getState()).toBe(ServiceState.New); + service.setState(ServiceState.Running); + expect(service.getState()).toBe(ServiceState.Running); + service.setState(ServiceState.Terminated); + expect(service.getState()).toBe(ServiceState.Terminated); + service.setState(ServiceState.Failed); + expect(service.getState()).toBe(ServiceState.Failed); +}); + +it('should return an appropraite promise when onRunning() is called', () => { + const service1 = new TestService(); + const onRunning1 = service1.onRunning(); + + const service2 = new TestService(); + const onRunning2 = service2.onRunning(); + + return new Promise((done) => { + Promise.all([ + onRunning1.then(() => { + expect(service1.getState()).toBe(ServiceState.Running); + }), onRunning2.catch(() => { + expect(service2.getState()).toBe(ServiceState.Failed); + }) + ]).then(() => done()); + + service1.start(); + service2.failStart(); + }); +}); + +it('should return an appropraite promise when onRunning() is called', () => { + const service1 = new TestService(); + const onRunning1 = service1.onRunning(); + + const service2 = new TestService(); + const onRunning2 = service2.onRunning(); + + return new Promise((done) => { + Promise.all([ + onRunning1.then(() => { + expect(service1.getState()).toBe(ServiceState.Running); + }), onRunning2.catch(() => { + expect(service2.getState()).toBe(ServiceState.Failed); + }) + ]).then(() => done()); + + service1.start(); + service2.failStart(); + }); +}); diff --git a/lib/service.ts b/lib/service.ts new file mode 100644 index 000000000..48ad8fbff --- /dev/null +++ b/lib/service.ts @@ -0,0 +1,94 @@ +/** + * 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. + */ + +import { resolvablePromise, ResolvablePromise } from "./utils/promise/resolvablePromise"; + + +/** + * The service interface represents an object with an operational state, + * with methods to start and stop. The design of this interface in modelled + * after Guava Service interface (https://github.com/google/guava/wiki/ServiceExplained). + */ + +export enum ServiceState { + New, + Starting, + Running, + Stopping, + Terminated, + Failed, +} + +export interface Service { + getState(): ServiceState; + start(): void; + // onRunning will reject if the service fails to start + // or stopped before it could start. + // It will resolve if the service is starts successfully. + onRunning(): Promise; + stop(): void; + // onTerminated will reject if the service enters a failed state + // either by failing to start or stop. + // It will resolve if the service is stopped successfully. + onTerminated(): Promise; +} + +export abstract class BaseService implements Service { + protected state: ServiceState; + protected startPromise: ResolvablePromise; + protected stopPromise: ResolvablePromise; + + constructor() { + this.state = ServiceState.New; + this.startPromise = resolvablePromise(); + this.stopPromise = resolvablePromise(); + + // avoid unhandled promise rejection + this.startPromise.promise.catch(() => {}); + this.stopPromise.promise.catch(() => {}); + } + + onRunning(): Promise { + return this.startPromise.promise; + } + + onTerminated(): Promise { + return this.stopPromise.promise; + } + + getState(): ServiceState { + return this.state; + } + + isStarting(): boolean { + return this.state === ServiceState.Starting; + } + + isNew(): boolean { + return this.state === ServiceState.New; + } + + isDone(): boolean { + return [ + ServiceState.Stopping, + ServiceState.Terminated, + ServiceState.Failed + ].includes(this.state); + } + + abstract start(): void; + abstract stop(): void; +} diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 08291ecf2..69c1080d3 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -37,7 +37,8 @@ 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'; +import { ProjectConfig } from './project_config/project_config'; +import { ProjectConfigManager } from './project_config/project_config_manager'; export interface BucketerParams { experimentId: string; @@ -281,6 +282,7 @@ export enum OptimizelyDecideOption { * options required to create optimizely object */ export interface OptimizelyOptions { + projectConfigManager: ProjectConfigManager; UNSTABLE_conditionEvaluators?: unknown; clientEngine: string; clientVersion?: string; @@ -372,7 +374,7 @@ export interface Client { attributes?: UserAttributes ): { [variableKey: string]: unknown } | null; getOptimizelyConfig(): OptimizelyConfig | null; - onReady(options?: { timeout?: number }): Promise<{ success: boolean; reason?: string }>; + onReady(options?: { timeout?: number }): Promise; close(): Promise<{ success: boolean; reason?: string }>; sendOdpEvent(action: string, type?: string, identifiers?: Map, data?: Map): void; getProjectConfig(): ProjectConfig | null; @@ -398,7 +400,6 @@ export type PersistentCacheProvider = () => PersistentCache; * For compatibility with the previous declaration file */ export interface Config extends ConfigLite { - datafileOptions?: DatafileOptions; // Options for Datafile Manager eventBatchSize?: number; // Maximum size of events to be dispatched in a batch eventFlushInterval?: number; // Maximum time for an event to be enqueued eventMaxQueueSize?: number; // Maximum size for the event queue @@ -412,10 +413,7 @@ export interface Config extends ConfigLite { * For compatibility with the previous declaration file */ export interface ConfigLite { - // Datafile string - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile?: object | string; + projectConfigManager: ProjectConfigManager; // errorHandler object for logging error errorHandler?: ErrorHandler; // event dispatcher function diff --git a/lib/tests/mock/mock_datafile_manager.ts b/lib/tests/mock/mock_datafile_manager.ts new file mode 100644 index 000000000..f2aa450b9 --- /dev/null +++ b/lib/tests/mock/mock_datafile_manager.ts @@ -0,0 +1,77 @@ +/** + * 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. + */ + +import { Consumer } from '../../utils/type'; +import { DatafileManager } from '../../project_config/datafile_manager'; +import { EventEmitter } from '../../utils/event_emitter/event_emitter'; +import { BaseService } from '../../service'; +import { LoggerFacade } from '../../modules/logging'; + +type MockConfig = { + datafile?: string | object; + onRunning?: Promise, + onTerminated?: Promise, +} + +class MockDatafileManager extends BaseService implements DatafileManager { + eventEmitter: EventEmitter<{ update: string}> = new EventEmitter(); + datafile: string | object | undefined; + + constructor(opt: MockConfig) { + super(); + this.datafile = opt.datafile; + this.startPromise.resolve(opt.onRunning || Promise.resolve()); + this.stopPromise.resolve(opt.onTerminated || Promise.resolve()); + } + + start(): void { + return; + } + + stop(): void { + return; + } + + setLogger(logger: LoggerFacade): void { + } + + get(): string | undefined { + if (typeof this.datafile === 'object') { + return JSON.stringify(this.datafile); + } + return this.datafile; + } + + setDatafile(datafile: string): void { + this.datafile = datafile; + } + + onUpdate(listener: Consumer): () => void { + return this.eventEmitter.on('update', listener) + } + + pushUpdate(datafile: string | object): void { + if (typeof datafile === 'object') { + datafile = JSON.stringify(datafile); + } + this.datafile = datafile; + this.eventEmitter.emit('update', datafile); + } +} + +export const getMockDatafileManager = (opt: MockConfig): MockDatafileManager => { + return new MockDatafileManager(opt); +}; diff --git a/lib/modules/datafile-manager/index.browser.ts b/lib/tests/mock/mock_logger.ts similarity index 61% rename from lib/modules/datafile-manager/index.browser.ts rename to lib/tests/mock/mock_logger.ts index 78a6879d5..7af7d26e8 100644 --- a/lib/modules/datafile-manager/index.browser.ts +++ b/lib/tests/mock/mock_logger.ts @@ -1,11 +1,11 @@ /** - * Copyright 2022, Optimizely + * 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 * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,5 +14,15 @@ * limitations under the License. */ -export * from './datafileManager'; -export { default as HttpPollingDatafileManager } from './browserDatafileManager'; +import { vi } from 'vitest'; +import { LoggerFacade } from '../../modules/logging'; + +export const getMockLogger = () : LoggerFacade => { + return { + info: vi.fn(), + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; +}; diff --git a/lib/tests/mock/mock_project_config_manager.ts b/lib/tests/mock/mock_project_config_manager.ts new file mode 100644 index 000000000..af7a8ba84 --- /dev/null +++ b/lib/tests/mock/mock_project_config_manager.ts @@ -0,0 +1,51 @@ +/** + * 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 + * + * 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 { ProjectConfigManager } from '../../project_config/project_config_manager'; +import { ProjectConfig } from '../../project_config/project_config'; +import { Consumer } from '../../utils/type'; + +type MockOpt = { + initConfig?: ProjectConfig, + onRunning?: Promise, + onTerminated?: Promise, +} + +export const getMockProjectConfigManager = (opt: MockOpt = {}): ProjectConfigManager => { + return { + config: opt.initConfig, + start: () => {}, + onRunning: () => opt.onRunning || Promise.resolve(), + stop: () => {}, + onTerminated: () => opt.onTerminated || Promise.resolve(), + getConfig: function() { + return this.config; + }, + setConfig: function(config: ProjectConfig) { + this.config = config; + }, + onUpdate: function(listener: Consumer) { + if (this.listeners === undefined) { + this.listeners = []; + } + this.listeners.push(listener); + return () => {}; + }, + pushUpdate: function(config: ProjectConfig) { + this.listeners.forEach((listener: any) => listener(config)); + } + } as any as ProjectConfigManager; +}; diff --git a/lib/tests/mock/mock_repeater.ts b/lib/tests/mock/mock_repeater.ts new file mode 100644 index 000000000..3f330f000 --- /dev/null +++ b/lib/tests/mock/mock_repeater.ts @@ -0,0 +1,67 @@ +/** + * 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. + */ + +import { vi } from 'vitest'; + +import { Repeater } from '../../utils/repeater/repeater'; +import { AsyncTransformer } from '../../utils/type'; + +export class MockRepeater implements Repeater { + private handler?: AsyncTransformer; + + start(): void { + } + + stop(): void { + } + + reset(): void { + } + + setTask(handler: AsyncTransformer): void { + this.handler = handler; + } + + pushTick(failureCount: number): void { + this.handler?.(failureCount); + } +} + +//ignore ts no return type error +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const getMockRepeater = () => { + const mock = { + isRunning: false, + handler: undefined as any, + start: vi.fn(), + stop: vi.fn(), + reset: vi.fn(), + setTask(handler: AsyncTransformer) { + this.handler = handler; + }, + // throw if not running. This ensures tests cannot + // do mock exection when the repeater is supposed to be not running. + execute(failureCount: number): Promise { + if (!this.isRunning) throw new Error(); + const ret = this.handler?.(failureCount); + ret?.catch(() => {}); + return ret; + }, + }; + mock.start.mockImplementation(() => mock.isRunning = true); + mock.stop.mockImplementation(() => mock.isRunning = false); + return mock; +} diff --git a/lib/tests/mock/mock_request_handler.ts b/lib/tests/mock/mock_request_handler.ts new file mode 100644 index 000000000..3369bf125 --- /dev/null +++ b/lib/tests/mock/mock_request_handler.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +import { vi } from 'vitest'; +import { AbortableRequest, Response } from '../../utils/http_request_handler/http'; +import { ResolvablePromise, resolvablePromise } from '../../utils/promise/resolvablePromise'; + + +export type MockAbortableRequest = AbortableRequest & { + mockResponse: ResolvablePromise; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const getMockAbortableRequest = (res?: Promise) => { + const response = resolvablePromise(); + if (res) response.resolve(res); + return { + mockResponse: response, + responsePromise: response.promise, + abort: vi.fn(), + }; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const getMockRequestHandler = () => { + const mock = { + makeRequest: vi.fn(), + } + return mock; +} diff --git a/lib/tests/test_data.js b/lib/tests/test_data.ts similarity index 99% rename from lib/tests/test_data.js rename to lib/tests/test_data.ts index eddf1a3fd..76d822eb8 100644 --- a/lib/tests/test_data.js +++ b/lib/tests/test_data.ts @@ -1,5 +1,5 @@ /** - * Copyright 2016-2021, Optimizely + * Copyright 2016-2021, 2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import cloneDeep from 'lodash/cloneDeep'; -var config = { +/* eslint-disable */ +const cloneDeep = (x: any) => JSON.parse(JSON.stringify(x)); + +const config: any = { revision: '42', version: '2', events: [ diff --git a/lib/utils/event_emitter/event_emitter.spec.ts b/lib/utils/event_emitter/event_emitter.spec.ts new file mode 100644 index 000000000..fb5cfe441 --- /dev/null +++ b/lib/utils/event_emitter/event_emitter.spec.ts @@ -0,0 +1,101 @@ +/** + * 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. + */ + +import { it, vi, expect } from 'vitest'; + +import { EventEmitter } from './event_emitter'; + +it('should call all registered listeners correctly on emit event', () => { + const emitter = new EventEmitter<{ foo: number, bar: string, baz: boolean}>(); + const fooListener1 = vi.fn(); + const fooListener2 = vi.fn(); + + emitter.on('foo', fooListener1); + emitter.on('foo', fooListener2); + + const barListener1 = vi.fn(); + const barListener2 = vi.fn(); + + emitter.on('bar', barListener1); + emitter.on('bar', barListener2); + + const bazListener = vi.fn(); + emitter.on('baz', bazListener); + + emitter.emit('foo', 1); + emitter.emit('bar', 'hello'); + + expect(fooListener1).toHaveBeenCalledOnce(); + expect(fooListener1).toHaveBeenCalledWith(1); + expect(fooListener2).toHaveBeenCalledOnce(); + expect(fooListener2).toHaveBeenCalledWith(1); + expect(barListener1).toHaveBeenCalledOnce(); + expect(barListener1).toHaveBeenCalledWith('hello'); + expect(barListener2).toHaveBeenCalledOnce(); + expect(barListener2).toHaveBeenCalledWith('hello'); + + expect(bazListener).not.toHaveBeenCalled(); +}); + +it('should remove listeners correctly when the function returned from on is called', () => { + const emitter = new EventEmitter<{ foo: number, bar: string }>(); + const fooListener1 = vi.fn(); + const fooListener2 = vi.fn(); + + const dispose = emitter.on('foo', fooListener1); + emitter.on('foo', fooListener2); + + const barListener1 = vi.fn(); + const barListener2 = vi.fn(); + + emitter.on('bar', barListener1); + emitter.on('bar', barListener2); + + dispose(); + emitter.emit('foo', 1); + emitter.emit('bar', 'hello'); + + expect(fooListener1).not.toHaveBeenCalled(); + expect(fooListener2).toHaveBeenCalledOnce(); + expect(fooListener2).toHaveBeenCalledWith(1); + expect(barListener1).toHaveBeenCalledWith('hello'); + expect(barListener1).toHaveBeenCalledWith('hello'); +}) + +it('should remove all listeners when removeAllListeners() is called', () => { + const emitter = new EventEmitter<{ foo: number, bar: string, baz: boolean}>(); + const fooListener1 = vi.fn(); + const fooListener2 = vi.fn(); + + emitter.on('foo', fooListener1); + emitter.on('foo', fooListener2); + + const barListener1 = vi.fn(); + const barListener2 = vi.fn(); + + emitter.on('bar', barListener1); + emitter.on('bar', barListener2); + + emitter.removeAllListeners(); + + emitter.emit('foo', 1); + emitter.emit('bar', 'hello'); + + expect(fooListener1).not.toHaveBeenCalled(); + expect(fooListener2).not.toHaveBeenCalled(); + expect(barListener1).not.toHaveBeenCalled(); + expect(barListener2).not.toHaveBeenCalled(); +}); diff --git a/lib/utils/event_emitter/event_emitter.ts b/lib/utils/event_emitter/event_emitter.ts new file mode 100644 index 000000000..22b22be5d --- /dev/null +++ b/lib/utils/event_emitter/event_emitter.ts @@ -0,0 +1,53 @@ +/** + * 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 + * + * 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 { Fn } from "../type"; + +type Consumer = (arg: T) => void; + +type Listeners = { + [Key in keyof T]?: Map>; +}; + +export class EventEmitter { + private id = 0; + private listeners: Listeners = {} as Listeners; + + on(eventName: E, listener: Consumer): Fn { + if (!this.listeners[eventName]) { + this.listeners[eventName] = new Map(); + } + + const curId = this.id++; + this.listeners[eventName]?.set(curId, listener); + return () => { + this.listeners[eventName]?.delete(curId); + } + } + + emit(eventName: E, data: T[E]): void { + const listeners = this.listeners[eventName]; + if (listeners) { + listeners.forEach(listener => { + listener(data); + }); + } + } + + removeAllListeners(): void { + this.listeners = {}; + } +} diff --git a/lib/utils/http_request_handler/browser_request_handler.ts b/lib/utils/http_request_handler/browser_request_handler.ts index 3a2fe73f0..a2756e318 100644 --- a/lib/utils/http_request_handler/browser_request_handler.ts +++ b/lib/utils/http_request_handler/browser_request_handler.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. @@ -22,12 +22,12 @@ import { REQUEST_TIMEOUT_MS } from '../enums'; * Handles sending requests and receiving responses over HTTP via XMLHttpRequest */ export class BrowserRequestHandler implements RequestHandler { - private readonly logger: LogHandler; - private readonly timeout: number; + private logger?: LogHandler; + private timeout: number; - public constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { - this.logger = logger; - this.timeout = timeout; + public constructor(opt: { logger?: LogHandler, timeout?: number } = {}) { + this.logger = opt.logger; + this.timeout = opt.timeout ?? REQUEST_TIMEOUT_MS; } /** @@ -67,7 +67,7 @@ export class BrowserRequestHandler implements RequestHandler { request.timeout = this.timeout; request.ontimeout = (): void => { - this.logger.log(LogLevel.WARNING, 'Request timed out'); + this.logger?.log(LogLevel.WARNING, 'Request timed out'); }; request.send(data); @@ -122,7 +122,7 @@ export class BrowserRequestHandler implements RequestHandler { } } } catch { - this.logger.log(LogLevel.WARNING, `Unable to parse & skipped header item '${headerLine}'`); + this.logger?.log(LogLevel.WARNING, `Unable to parse & skipped header item '${headerLine}'`); } }); return headers; diff --git a/lib/utils/http_request_handler/http.ts b/lib/utils/http_request_handler/http.ts index 98c1cedc6..ca7e63ae3 100644 --- a/lib/utils/http_request_handler/http.ts +++ b/lib/utils/http_request_handler/http.ts @@ -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. @@ -25,7 +25,7 @@ export interface Headers { * Simplified Response object containing only needed information */ export interface Response { - statusCode?: number; + statusCode: number; body: string; headers: Headers; } @@ -35,13 +35,14 @@ export interface Response { */ export interface AbortableRequest { abort(): void; - responsePromise: Promise; } +export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; + /** * Client that handles sending requests and receiving responses */ export interface RequestHandler { - makeRequest(requestUrl: string, headers: Headers, method: string, data?: string): AbortableRequest; + makeRequest(requestUrl: string, headers: Headers, method: HttpMethod, data?: string): AbortableRequest; } diff --git a/lib/utils/http_request_handler/node_request_handler.ts b/lib/utils/http_request_handler/node_request_handler.ts index 458089540..26bc6cbda 100644 --- a/lib/utils/http_request_handler/node_request_handler.ts +++ b/lib/utils/http_request_handler/node_request_handler.ts @@ -25,12 +25,12 @@ import { REQUEST_TIMEOUT_MS } from '../enums'; * Handles sending requests and receiving responses over HTTP via NodeJS http module */ export class NodeRequestHandler implements RequestHandler { - private readonly logger: LogHandler; + private readonly logger?: LogHandler; private readonly timeout: number; - constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { - this.logger = logger; - this.timeout = timeout; + constructor(opt: { logger?: LogHandler, timeout?: number } = {}) { + this.logger = opt.logger; + this.timeout = opt.timeout ?? REQUEST_TIMEOUT_MS; } /** @@ -163,6 +163,11 @@ export class NodeRequestHandler implements RequestHandler { return; } + if (!incomingMessage.statusCode) { + reject(new Error('No status code in response')); + return; + } + resolve({ statusCode: incomingMessage.statusCode, body: responseData, diff --git a/lib/utils/json_schema_validator/index.tests.js b/lib/utils/json_schema_validator/index.tests.js index 597ce15b7..61df2abaa 100644 --- a/lib/utils/json_schema_validator/index.tests.js +++ b/lib/utils/json_schema_validator/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2020, 2022, Optimizely + * Copyright 2016-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. @@ -18,7 +18,7 @@ import { assert } from 'chai'; import { validate } from './'; import { ERROR_MESSAGES } from '../enums'; -import testData from '../../tests/test_data.js'; +import testData from '../../tests/test_data'; describe('lib/utils/json_schema_validator', function() { diff --git a/lib/utils/json_schema_validator/index.ts b/lib/utils/json_schema_validator/index.ts index fb164808e..7ad8708c9 100644 --- a/lib/utils/json_schema_validator/index.ts +++ b/lib/utils/json_schema_validator/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, 2020, 2022 Optimizely + * Copyright 2016-2017, 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. @@ -17,7 +17,7 @@ import { sprintf } from '../fns'; import { JSONSchema4, validate as jsonSchemaValidator } from 'json-schema'; import { ERROR_MESSAGES } from '../enums'; -import schema from '../../core/project_config/project_config_schema'; +import schema from '../../project_config/project_config_schema'; const MODULE_NAME = 'JSON_SCHEMA_VALIDATOR'; diff --git a/lib/utils/microtask/index.spec.ts b/lib/utils/microtask/index.spec.ts new file mode 100644 index 000000000..8d5fd9622 --- /dev/null +++ b/lib/utils/microtask/index.spec.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { scheduleMicrotask } from '.'; + +describe('scheduleMicrotask', () => { + it('should use queueMicrotask if available', async () => { + expect(typeof globalThis.queueMicrotask).toEqual('function'); + const cb = vi.fn(); + scheduleMicrotask(cb); + await Promise.resolve(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('should polyfill if queueMicrotask is not available', async () => { + const originalQueueMicrotask = globalThis.queueMicrotask; + globalThis.queueMicrotask = undefined as any; // as any to pacify TS + + expect(globalThis.queueMicrotask).toBeUndefined(); + + const cb = vi.fn(); + scheduleMicrotask(cb); + await Promise.resolve(); + expect(cb).toHaveBeenCalledTimes(1); + + expect(globalThis.queueMicrotask).toBeUndefined(); + globalThis.queueMicrotask = originalQueueMicrotask; + }); +}); diff --git a/lib/utils/microtask/index.tests.js b/lib/utils/microtask/index.tests.js deleted file mode 100644 index 16091ad68..000000000 --- a/lib/utils/microtask/index.tests.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 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. - */ - -import { scheduleMicrotaskOrTimeout } from './'; - -describe('scheduleMicrotaskOrTimeout', () => { - it('should use queueMicrotask if available', (done) => { - // Assuming queueMicrotask is available in the environment - scheduleMicrotaskOrTimeout(() => { - done(); - }); - }); - - it('should fallback to setTimeout if queueMicrotask is not available', (done) => { - // Temporarily remove queueMicrotask to test the fallback - const originalQueueMicrotask = window.queueMicrotask; - window.queueMicrotask = undefined; - - scheduleMicrotaskOrTimeout(() => { - // Restore queueMicrotask before calling done - window.queueMicrotask = originalQueueMicrotask; - done(); - }); - }); -}); diff --git a/lib/utils/microtask/index.ts b/lib/utils/microtask/index.ts index 816b17a27..02e2c474e 100644 --- a/lib/utils/microtask/index.ts +++ b/lib/utils/microtask/index.ts @@ -16,10 +16,10 @@ type Callback = () => void; -export const scheduleMicrotaskOrTimeout = (callback: Callback): void =>{ +export const scheduleMicrotask = (callback: Callback): void => { if (typeof queueMicrotask === 'function') { queueMicrotask(callback); } else { - setTimeout(callback); + Promise.resolve().then(callback); } -} \ No newline at end of file +} diff --git a/lib/utils/repeater/repeater.spec.ts b/lib/utils/repeater/repeater.spec.ts new file mode 100644 index 000000000..cebb17e38 --- /dev/null +++ b/lib/utils/repeater/repeater.spec.ts @@ -0,0 +1,284 @@ +/** + * 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 + * + * 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 { expect, vi, it, beforeEach, afterEach, describe } from 'vitest'; +import { ExponentialBackoff, IntervalRepeater } from './repeater'; +import { advanceTimersByTime } from '../../../tests/testUtils'; +import { ad } from 'vitest/dist/chunks/reporters.C_zwCd4j'; +import { resolvablePromise } from '../promise/resolvablePromise'; + +describe("ExponentialBackoff", () => { + it("should return the base with jitter on the first call", () => { + const exponentialBackoff = new ExponentialBackoff(5000, 10000, 1000); + const time = exponentialBackoff.backoff(); + + expect(time).toBeGreaterThanOrEqual(5000); + expect(time).toBeLessThanOrEqual(6000); + }); + + it('should use a random jitter within the specified limit', () => { + const exponentialBackoff1 = new ExponentialBackoff(5000, 10000, 1000); + const exponentialBackoff2 = new ExponentialBackoff(5000, 10000, 1000); + + const time = exponentialBackoff1.backoff(); + const time2 = exponentialBackoff2.backoff(); + + expect(time).toBeGreaterThanOrEqual(5000); + expect(time).toBeLessThanOrEqual(6000); + + expect(time2).toBeGreaterThanOrEqual(5000); + expect(time2).toBeLessThanOrEqual(6000); + + expect(time).not.toEqual(time2); + }); + + it("should double the time when backoff() is called", () => { + const exponentialBackoff = new ExponentialBackoff(5000, 20000, 1000); + const time = exponentialBackoff.backoff(); + + expect(time).toBeGreaterThanOrEqual(5000); + expect(time).toBeLessThanOrEqual(6000); + + const time2 = exponentialBackoff.backoff(); + expect(time2).toBeGreaterThanOrEqual(10000); + expect(time2).toBeLessThanOrEqual(11000); + + const time3 = exponentialBackoff.backoff(); + expect(time3).toBeGreaterThanOrEqual(20000); + expect(time3).toBeLessThanOrEqual(21000); + }); + + it('should not exceed the max time', () => { + const exponentialBackoff = new ExponentialBackoff(5000, 10000, 1000); + const time = exponentialBackoff.backoff(); + expect(time).toBeGreaterThanOrEqual(5000); + expect(time).toBeLessThanOrEqual(6000); + + const time2 = exponentialBackoff.backoff(); + expect(time2).toBeGreaterThanOrEqual(10000); + expect(time2).toBeLessThanOrEqual(11000); + + const time3 = exponentialBackoff.backoff(); + expect(time3).toBeGreaterThanOrEqual(10000); + expect(time3).toBeLessThanOrEqual(11000); + }); + + it('should reset the backoff time when reset() is called', () => { + const exponentialBackoff = new ExponentialBackoff(5000, 10000, 1000); + const time = exponentialBackoff.backoff(); + expect(time).toBeGreaterThanOrEqual(5000); + expect(time).toBeLessThanOrEqual(6000); + + exponentialBackoff.reset(); + const time2 = exponentialBackoff.backoff(); + expect(time2).toBeGreaterThanOrEqual(5000); + expect(time2).toBeLessThanOrEqual(6000); + }); +}); + + +describe("IntervalRepeater", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should call the handler at the specified interval', async() => { + const handler = vi.fn().mockResolvedValue(undefined); + + const intervalRepeater = new IntervalRepeater(2000); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(2); + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(3); + }); + + it('should call the handler with correct failureCount value', async() => { + const handler = vi.fn().mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce(undefined); + + const intervalRepeater = new IntervalRepeater(2000); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toBe(0); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.mock.calls[1][0]).toBe(1); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(3); + expect(handler.mock.calls[2][0]).toBe(2); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(4); + expect(handler.mock.calls[3][0]).toBe(3); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(5); + expect(handler.mock.calls[4][0]).toBe(0); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(6); + expect(handler.mock.calls[5][0]).toBe(1); + }); + + it('should backoff when the handler fails if backoffController is provided', async() => { + const handler = vi.fn().mockRejectedValue(new Error()); + + const backoffController = { + backoff: vi.fn().mockReturnValue(1100), + reset: vi.fn(), + }; + + const intervalRepeater = new IntervalRepeater(30000, backoffController); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(30000); + expect(handler).toHaveBeenCalledTimes(1); + expect(backoffController.backoff).toHaveBeenCalledTimes(1); + + await advanceTimersByTime(1100); + expect(handler).toHaveBeenCalledTimes(2); + expect(backoffController.backoff).toHaveBeenCalledTimes(2); + + await advanceTimersByTime(1100); + expect(handler).toHaveBeenCalledTimes(3); + expect(backoffController.backoff).toHaveBeenCalledTimes(3); + }); + + it('should use the regular interval when the handler fails if backoffController is not provided', async() => { + const handler = vi.fn().mockRejectedValue(new Error()); + + const intervalRepeater = new IntervalRepeater(30000); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(30000); + expect(handler).toHaveBeenCalledTimes(1); + + await advanceTimersByTime(10000); + expect(handler).toHaveBeenCalledTimes(1); + await advanceTimersByTime(20000); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('should reset the backoffController after handler success', async () => { + const handler = vi.fn().mockRejectedValueOnce(new Error) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const backoffController = { + + backoff: vi.fn().mockReturnValue(1100), + reset: vi.fn(), + }; + + const intervalRepeater = new IntervalRepeater(30000, backoffController); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(30000); + expect(handler).toHaveBeenCalledTimes(1); + expect(backoffController.backoff).toHaveBeenCalledTimes(1); + + await advanceTimersByTime(1100); + expect(handler).toHaveBeenCalledTimes(2); + expect(backoffController.backoff).toHaveBeenCalledTimes(2); + + await advanceTimersByTime(1100); + expect(handler).toHaveBeenCalledTimes(3); + + expect(backoffController.backoff).toHaveBeenCalledTimes(2); // backoff should not be called again + expect(backoffController.reset).toHaveBeenCalledTimes(1); // reset should be called once + + await advanceTimersByTime(1100); + expect(handler).toHaveBeenCalledTimes(3); // handler should be called after 30000ms + await advanceTimersByTime(30000 - 1100); + expect(handler).toHaveBeenCalledTimes(4); // handler should be called after 30000ms + }); + + + it('should wait for handler promise to resolve before scheduling another tick', async() => { + const ret = resolvablePromise(); + const handler = vi.fn().mockReturnValue(ret); + + const intervalRepeater = new IntervalRepeater(2000); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + + // should not schedule another call cause promise is pending + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + + ret.resolve(undefined); + await ret.promise; + + // Advance the timers to the next tick + await advanceTimersByTime(2000); + // The handler should be called again after the promise has resolved + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('should not call the handler after stop is called', async() => { + const ret = resolvablePromise(); + const handler = vi.fn().mockReturnValue(ret); + + const intervalRepeater = new IntervalRepeater(2000); + intervalRepeater.setTask(handler); + + intervalRepeater.start(); + + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + + intervalRepeater.stop(); + + ret.resolve(undefined); + await ret.promise; + + await advanceTimersByTime(2000); + await advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/utils/repeater/repeater.ts b/lib/utils/repeater/repeater.ts new file mode 100644 index 000000000..f758f0dc9 --- /dev/null +++ b/lib/utils/repeater/repeater.ts @@ -0,0 +1,136 @@ +/** + * 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 + * + * 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 { AsyncTransformer } from "../type"; +import { scheduleMicrotask } from "../microtask"; + +// A repeater will invoke the task repeatedly. The time at which the task is invoked +// is determined by the implementation. +// The task is a function that takes a number as an argument and returns a promise. +// The number argument is the number of times the task previously failed consecutively. +// If the retuned promise resolves, the repeater will assume the task succeeded, +// and will reset the failure count. If the promise is rejected, the repeater will +// assume the task failed and will increase the current consecutive failure count. +export interface Repeater { + // If immediateExecution is true, the first exection of + // the task will be immediate but asynchronous. + start(immediateExecution?: boolean): void; + stop(): void; + reset(): void; + setTask(task: AsyncTransformer): void; +} + +export interface BackoffController { + backoff(): number; + reset(): void; +} + +export class ExponentialBackoff implements BackoffController { + private base: number; + private max: number; + private current: number; + private maxJitter: number; + + constructor(base: number, max: number, maxJitter: number) { + this.base = base; + this.max = max; + this.maxJitter = maxJitter; + this.current = base; + } + + backoff(): number { + const ret = this.current + this.maxJitter * Math.random(); + this.current = Math.min(this.current * 2, this.max); + return ret; + } + + reset(): void { + this.current = this.base; + } +} + +// IntervalRepeater is a Repeater that invokes the task at a fixed interval +// after the completion of the previous task invocation. If a backoff controller +// is provided, the repeater will use the backoff controller to determine the +// time between invocations after a failure instead. It will reset the backoffController +// on success. + +export class IntervalRepeater implements Repeater { + private timeoutId?: NodeJS.Timeout; + private task?: AsyncTransformer; + private interval: number; + private failureCount = 0; + private backoffController?: BackoffController; + private isRunning = false; + + constructor(interval: number, backoffController?: BackoffController) { + this.interval = interval; + this.backoffController = backoffController; + } + + private handleSuccess() { + this.failureCount = 0; + this.backoffController?.reset(); + this.setTimer(this.interval); + } + + private handleFailure() { + this.failureCount++; + const time = this.backoffController?.backoff() ?? this.interval; + this.setTimer(time); + } + + private setTimer(timeout: number) { + if (!this.isRunning){ + return; + } + this.timeoutId = setTimeout(this.executeTask.bind(this), timeout); + } + + private executeTask() { + if (!this.task) { + return; + } + this.task(this.failureCount).then( + this.handleSuccess.bind(this), + this.handleFailure.bind(this) + ); + } + + start(immediateExecution?: boolean): void { + this.isRunning = true; + if(immediateExecution) { + scheduleMicrotask(this.executeTask.bind(this)); + } else { + this.setTimer(this.interval); + } + } + + stop(): void { + this.isRunning = false; + clearInterval(this.timeoutId); + } + + reset(): void { + this.failureCount = 0; + this.backoffController?.reset(); + this.stop(); + } + + setTask(task: AsyncTransformer): void { + this.task = task; + } +} diff --git a/lib/modules/datafile-manager/index.node.ts b/lib/utils/type.ts similarity index 61% rename from lib/modules/datafile-manager/index.node.ts rename to lib/utils/type.ts index 4015d3adf..9c9a704dc 100644 --- a/lib/modules/datafile-manager/index.node.ts +++ b/lib/utils/type.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * 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. @@ -14,7 +14,12 @@ * limitations under the License. */ -import NodeDatafileManager from './nodeDatafileManager'; -export * from './datafileManager'; -export { NodeDatafileManager as HttpPollingDatafileManager }; -export default { HttpPollingDatafileManager: NodeDatafileManager }; +export type Fn = () => void; +export type AsyncTransformer = (arg: A) => Promise; +export type Transformer = (arg: A) => B; + +export type Consumer = (arg: T) => void; +export type AsyncComsumer = (arg: T) => Promise; + +export type Producer = () => T; +export type AsyncProducer = () => Promise; diff --git a/tests/backoffController.spec.ts b/tests/backoffController.spec.ts deleted file mode 100644 index 846ac0c52..000000000 --- a/tests/backoffController.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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. - * 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 { describe, it, expect } from 'vitest'; - -import BackoffController from '../lib/modules/datafile-manager/backoffController'; - -describe('backoffController', () => { - describe('getDelay', () => { - it('returns 0 from getDelay if there have been no errors', () => { - const controller = new BackoffController(); - expect(controller.getDelay()).toBe(0); - }); - - it('increases the delay returned from getDelay (up to a maximum value) after each call to countError', () => { - const controller = new BackoffController(); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(8000); - expect(controller.getDelay()).toBeLessThan(9000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(16000); - expect(controller.getDelay()).toBeLessThan(17000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(32000); - expect(controller.getDelay()).toBeLessThan(33000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(64000); - expect(controller.getDelay()).toBeLessThan(65000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(128000); - expect(controller.getDelay()).toBeLessThan(129000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(256000); - expect(controller.getDelay()).toBeLessThan(257000); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(512000); - expect(controller.getDelay()).toBeLessThan(513000); - // Maximum reached - additional errors should not increase the delay further - controller.countError(); - expect(controller.getDelay()).toBeGreaterThanOrEqual(512000); - expect(controller.getDelay()).toBeLessThan(513000); - }); - - it('resets the error count when reset is called', () => { - const controller = new BackoffController(); - controller.countError(); - expect(controller.getDelay()).toBeGreaterThan(0); - controller.reset(); - expect(controller.getDelay()).toBe(0); - }); - }); -}); diff --git a/tests/browserDatafileManager.spec.ts b/tests/browserDatafileManager.spec.ts deleted file mode 100644 index d643b2cb3..000000000 --- a/tests/browserDatafileManager.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * 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. - * 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 { describe, beforeEach, afterEach, it, expect, vi, MockInstance } from 'vitest'; - -import BrowserDatafileManager from '../lib/modules/datafile-manager/browserDatafileManager'; -import * as browserRequest from '../lib/modules/datafile-manager/browserRequest'; -import { Headers, AbortableRequest } from '../lib/modules/datafile-manager/http'; -import { advanceTimersByTime, getTimerCount } from './testUtils'; - -describe('browserDatafileManager', () => { - let makeGetRequestSpy: MockInstance<(reqUrl: string, headers: Headers) => AbortableRequest>; - beforeEach(() => { - vi.useFakeTimers(); - makeGetRequestSpy = vi.spyOn(browserRequest, 'makeGetRequest'); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.clearAllTimers(); - }); - - it('calls makeGetRequest when started', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - - const manager = new BrowserDatafileManager({ - sdkKey: '1234', - autoUpdate: false, - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json'); - expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({}); - - await manager.onReady(); - await manager.stop(); - }); - - it('calls makeGetRequest for live update requests', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - }), - }); - const manager = new BrowserDatafileManager({ - sdkKey: '1234', - autoUpdate: true, - }); - manager.start(); - await manager.onReady(); - await advanceTimersByTime(300000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json'); - expect(makeGetRequestSpy.mock.calls[1][1]).toEqual({ - 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT', - }); - - await manager.stop(); - }); - - it('defaults to false for autoUpdate', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - }), - }); - const manager = new BrowserDatafileManager({ - sdkKey: '1234', - }); - manager.start(); - await manager.onReady(); - // Should not set a timeout for a later update - expect(getTimerCount()).toBe(0); - - await manager.stop(); - }); -}); diff --git a/tests/browserRequest.spec.ts b/tests/browserRequest.spec.ts deleted file mode 100644 index 42a52329f..000000000 --- a/tests/browserRequest.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @jest-environment jsdom - */ -/** - * 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. - * 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 { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'; - -import { FakeXMLHttpRequest, FakeXMLHttpRequestStatic, fakeXhr } from 'nise'; -import { makeGetRequest } from '../lib/modules/datafile-manager/browserRequest'; - -describe('browserRequest', () => { - describe('makeGetRequest', () => { - let mockXHR: FakeXMLHttpRequestStatic; - let xhrs: FakeXMLHttpRequest[]; - beforeEach(() => { - xhrs = []; - mockXHR = fakeXhr.useFakeXMLHttpRequest(); - mockXHR.onCreate = (req): number => xhrs.push(req); - }); - - afterEach(() => { - mockXHR.restore(); - }); - - it('makes a GET request to the argument URL', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - - expect(xhrs.length).toBe(1); - const xhr = xhrs[0]; - const { url, method } = xhr; - expect({ url, method }).toEqual({ - url: 'https://cdn.optimizely.com/datafiles/123.json', - method: 'GET', - }); - - xhr.respond(200, {}, '{"foo":"bar"}'); - - await req.responsePromise; - }); - - it('returns a 200 response back to its superclass', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - - const xhr = xhrs[0]; - xhr.respond(200, {}, '{"foo":"bar"}'); - - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - headers: {}, - body: '{"foo":"bar"}', - }); - }); - - it('returns a 404 response back to its superclass', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - - const xhr = xhrs[0]; - xhr.respond(404, {}, ''); - - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 404, - headers: {}, - body: '', - }); - }); - - it('includes headers from the headers argument in the request', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/dataifles/123.json', { - 'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT', - }); - - expect(xhrs.length).toBe(1); - expect(xhrs[0].requestHeaders['if-modified-since']).toBe('Fri, 08 Mar 2019 18:57:18 GMT'); - - xhrs[0].respond(404, {}, ''); - - await req.responsePromise; - }); - - it('includes headers from the response in the eventual response in the return value', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - - const xhr = xhrs[0]; - xhr.respond( - 200, - { - 'content-type': 'application/json', - 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', - }, - '{"foo":"bar"}' - ); - - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'content-type': 'application/json', - 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', - }, - }); - }); - - it('returns a rejected promise when there is a request error', async () => { - const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - xhrs[0].error(); - await expect(req.responsePromise).rejects.toThrow(); - }); - - it('sets a timeout on the request object', () => { - const onCreateMock = vi.fn(); - mockXHR.onCreate = onCreateMock; - makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}); - expect(onCreateMock).toBeCalledTimes(1); - expect(onCreateMock.mock.calls[0][0].timeout).toBe(60000); - }); - }); -}); diff --git a/tests/browserRequestHandler.spec.ts b/tests/browserRequestHandler.spec.ts index 763bba54e..f28ee1f26 100644 --- a/tests/browserRequestHandler.spec.ts +++ b/tests/browserRequestHandler.spec.ts @@ -34,7 +34,7 @@ describe('BrowserRequestHandler', () => { xhrs = []; mockXHR = fakeXhr.useFakeXMLHttpRequest(); mockXHR.onCreate = (request): number => xhrs.push(request); - browserRequestHandler = new BrowserRequestHandler(new NoOpLogger()); + browserRequestHandler = new BrowserRequestHandler({ logger: new NoOpLogger() }); }); afterEach(() => { @@ -135,7 +135,7 @@ describe('BrowserRequestHandler', () => { const onCreateMock = vi.fn(); mockXHR.onCreate = onCreateMock; - new BrowserRequestHandler(new NoOpLogger(), timeout).makeRequest(host, {}, 'get'); + new BrowserRequestHandler({ logger: new NoOpLogger(), timeout }).makeRequest(host, {}, 'get'); expect(onCreateMock).toBeCalledTimes(1); expect(onCreateMock.mock.calls[0][0].timeout).toBe(timeout); diff --git a/tests/eventEmitter.spec.ts b/tests/eventEmitter.spec.ts deleted file mode 100644 index 16e91b83e..000000000 --- a/tests/eventEmitter.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * 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. - * 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 { expect, vi, it, beforeEach, describe } from 'vitest'; -import EventEmitter from '../lib/modules/datafile-manager/eventEmitter'; - -describe('event_emitter', () => { - describe('on', () => { - let emitter: EventEmitter; - beforeEach(() => { - emitter = new EventEmitter(); - }); - - it('can add a listener for the update event', () => { - const listener = vi.fn(); - emitter.on('update', listener); - emitter.emit('update', { datafile: 'abcd' }); - expect(listener).toBeCalledTimes(1); - }); - - it('passes the argument from emit to the listener', () => { - const listener = vi.fn(); - emitter.on('update', listener); - emitter.emit('update', { datafile: 'abcd' }); - expect(listener).toBeCalledWith({ datafile: 'abcd' }); - }); - - it('returns a dispose function that removes the listener', () => { - const listener = vi.fn(); - const disposer = emitter.on('update', listener); - disposer(); - emitter.emit('update', { datafile: 'efgh' }); - expect(listener).toBeCalledTimes(0); - }); - - it('can add several listeners for the update event', () => { - const listener1 = vi.fn(); - const listener2 = vi.fn(); - const listener3 = vi.fn(); - emitter.on('update', listener1); - emitter.on('update', listener2); - emitter.on('update', listener3); - emitter.emit('update', { datafile: 'abcd' }); - expect(listener1).toBeCalledTimes(1); - expect(listener2).toBeCalledTimes(1); - expect(listener3).toBeCalledTimes(1); - }); - - it('can add several listeners and remove only some of them', () => { - const listener1 = vi.fn(); - const listener2 = vi.fn(); - const listener3 = vi.fn(); - const disposer1 = emitter.on('update', listener1); - const disposer2 = emitter.on('update', listener2); - emitter.on('update', listener3); - emitter.emit('update', { datafile: 'abcd' }); - expect(listener1).toBeCalledTimes(1); - expect(listener2).toBeCalledTimes(1); - expect(listener3).toBeCalledTimes(1); - disposer1(); - disposer2(); - emitter.emit('update', { datafile: 'efgh' }); - expect(listener1).toBeCalledTimes(1); - expect(listener2).toBeCalledTimes(1); - expect(listener3).toBeCalledTimes(2); - }); - - it('can add listeners for different events and remove only some of them', () => { - const readyListener = vi.fn(); - const updateListener = vi.fn(); - const readyDisposer = emitter.on('ready', readyListener); - const updateDisposer = emitter.on('update', updateListener); - emitter.emit('ready'); - expect(readyListener).toBeCalledTimes(1); - expect(updateListener).toBeCalledTimes(0); - emitter.emit('update', { datafile: 'abcd' }); - expect(readyListener).toBeCalledTimes(1); - expect(updateListener).toBeCalledTimes(1); - readyDisposer(); - emitter.emit('ready'); - expect(readyListener).toBeCalledTimes(1); - expect(updateListener).toBeCalledTimes(1); - emitter.emit('update', { datafile: 'efgh' }); - expect(readyListener).toBeCalledTimes(1); - expect(updateListener).toBeCalledTimes(2); - updateDisposer(); - emitter.emit('update', { datafile: 'ijkl' }); - expect(readyListener).toBeCalledTimes(1); - expect(updateListener).toBeCalledTimes(2); - }); - - it('can remove all listeners', () => { - const readyListener = vi.fn(); - const updateListener = vi.fn(); - emitter.on('ready', readyListener); - emitter.on('update', updateListener); - emitter.removeAllListeners(); - emitter.emit('update', { datafile: 'abcd' }); - emitter.emit('ready'); - expect(readyListener).toBeCalledTimes(0); - expect(updateListener).toBeCalledTimes(0); - }); - }); -}); diff --git a/tests/httpPollingDatafileManager.spec.ts b/tests/httpPollingDatafileManager.spec.ts deleted file mode 100644 index 201fe0eae..000000000 --- a/tests/httpPollingDatafileManager.spec.ts +++ /dev/null @@ -1,744 +0,0 @@ -/** - * 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. - * 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 { describe, beforeEach, afterEach, beforeAll, it, expect, vi, MockInstance } from 'vitest'; - -import HttpPollingDatafileManager from '../lib/modules/datafile-manager/httpPollingDatafileManager'; -import { Headers, AbortableRequest, Response } from '../lib/modules/datafile-manager/http'; -import { DatafileManagerConfig } from '../lib/modules/datafile-manager/datafileManager'; -import { advanceTimersByTime, getTimerCount } from './testUtils'; -import PersistentKeyValueCache from '../lib/plugins/key_value_cache/persistentKeyValueCache'; - - -vi.mock('../lib/modules/datafile-manager/backoffController', () => { - const MockBackoffController = vi.fn(); - MockBackoffController.prototype.getDelay = vi.fn().mockImplementation(() => 0); - MockBackoffController.prototype.countError = vi.fn(); - MockBackoffController.prototype.reset = vi.fn(); - - return { - 'default': MockBackoffController, - } -}); - - -import BackoffController from '../lib/modules/datafile-manager/backoffController'; -import { LoggerFacade, getLogger } from '../lib/modules/logging'; -import { resetCalls, spy, verify } from 'ts-mockito'; - -// Test implementation: -// - Does not make any real requests: just resolves with queued responses (tests push onto queuedResponses) -export class TestDatafileManager extends HttpPollingDatafileManager { - queuedResponses: (Response | Error)[] = []; - - responsePromises: Promise[] = []; - - simulateResponseDelay = false; - - // Need these unsued vars for the mock call types to work (being able to check calls) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - makeGetRequest(url: string, headers: Headers): AbortableRequest { - const nextResponse: Error | Response | undefined = this.queuedResponses.pop(); - let responsePromise: Promise; - if (nextResponse === undefined) { - responsePromise = Promise.reject('No responses queued'); - } else if (nextResponse instanceof Error) { - responsePromise = Promise.reject(nextResponse); - } else { - if (this.simulateResponseDelay) { - // Actual response will have some delay. This is required to get expected behavior for caching. - responsePromise = new Promise(resolve => setTimeout(() => resolve(nextResponse), 50)); - } else { - responsePromise = Promise.resolve(nextResponse); - } - } - this.responsePromises.push(responsePromise); - return { responsePromise, abort: vi.fn() }; - } - - getConfigDefaults(): Partial { - return {}; - } -} - -const testCache: PersistentKeyValueCache = { - get(key: string): Promise { - let val = undefined; - switch (key) { - case 'opt-datafile-keyThatExists': - val = JSON.stringify({ name: 'keyThatExists' }); - break; - } - return Promise.resolve(val); - }, - - set(): Promise { - return Promise.resolve(); - }, - - contains(): Promise { - return Promise.resolve(false); - }, - - remove(): Promise { - return Promise.resolve(false); - }, -}; - -describe('httpPollingDatafileManager', () => { - - let spiedLogger: LoggerFacade; - - const loggerName = 'DatafileManager'; - - beforeAll(() => { - const actualLogger = getLogger(loggerName); - spiedLogger = spy(actualLogger); - }); - - beforeEach(() => { - vi.useFakeTimers(); - resetCalls(spiedLogger); - }); - - let manager: TestDatafileManager; - afterEach(async () => { - if (manager) { - manager.stop(); - } - vi.clearAllMocks(); - vi.restoreAllMocks(); - vi.clearAllTimers(); - }); - - describe('when constructed with sdkKey and datafile and autoUpdate: true,', () => { - beforeEach(() => { - manager = new TestDatafileManager({ datafile: JSON.stringify({ foo: 'abcd' }), sdkKey: '123', autoUpdate: true }); - }); - - it('returns the passed datafile from get', () => { - expect(JSON.parse(manager.get())).toEqual({ foo: 'abcd' }); - }); - - it('after being started, fetches the datafile, updates itself, and updates itself again after a timeout', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"fooz": "barz"}', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - } - ); - const updateFn = vi.fn(); - manager.on('update', updateFn); - manager.start(); - expect(manager.responsePromises.length).toBe(1); - await manager.responsePromises[0]; - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - updateFn.mockReset(); - - await advanceTimersByTime(300000); - - expect(manager.responsePromises.length).toBe(2); - await manager.responsePromises[1]; - expect(updateFn).toBeCalledTimes(1); - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"fooz": "barz"}' }); - expect(JSON.parse(manager.get())).toEqual({ fooz: 'barz' }); - }); - }); - - describe('when constructed with sdkKey and datafile and autoUpdate: false,', () => { - beforeEach(() => { - manager = new TestDatafileManager({ - datafile: JSON.stringify({ foo: 'abcd' }), - sdkKey: '123', - autoUpdate: false, - }); - }); - - it('returns the passed datafile from get', () => { - expect(JSON.parse(manager.get())).toEqual({ foo: 'abcd' }); - }); - - it('after being started, fetches the datafile, updates itself once, but does not schedule a future update', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - expect(manager.responsePromises.length).toBe(1); - await manager.responsePromises[0]; - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(getTimerCount()).toBe(0); - }); - }); - - describe('when constructed with sdkKey and autoUpdate: true', () => { - beforeEach(() => { - manager = new TestDatafileManager({ sdkKey: '123', updateInterval: 1000, autoUpdate: true }); - }); - - it('logs an error if fetching datafile fails', async () => { - manager.queuedResponses.push( - { - statusCode: 500, - body: '', - headers: {}, - } - ); - - manager.start(); - await advanceTimersByTime(1000); - await manager.responsePromises[0]; - - verify(spiedLogger.error('Datafile fetch request failed with status: 500')).once(); - }); - - describe('initial state', () => { - it('returns null from get before becoming ready', () => { - expect(manager.get()).toEqual(''); - }); - }); - - describe('started state', () => { - it('passes the default datafile URL to the makeGetRequest method', async () => { - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/123.json'); - await manager.onReady(); - }); - - it('after being started, fetches the datafile and resolves onReady', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - }); - - describe('live updates', () => { - it('sets a timeout to update again after the update interval', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"foo3": "bar3"}', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo4": "bar4"}', - headers: {}, - } - ); - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - await manager.responsePromises[0]; - await advanceTimersByTime(1000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - }); - - it('emits update events after live updates', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"foo3": "bar3"}', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo2": "bar2"}', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - } - ); - - const updateFn = vi.fn(); - manager.on('update', updateFn); - - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(updateFn).toBeCalledTimes(0); - - await advanceTimersByTime(1000); - await manager.responsePromises[1]; - expect(updateFn).toBeCalledTimes(1); - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo2": "bar2"}' }); - expect(JSON.parse(manager.get())).toEqual({ foo2: 'bar2' }); - - updateFn.mockReset(); - - await advanceTimersByTime(1000); - await manager.responsePromises[2]; - expect(updateFn).toBeCalledTimes(1); - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo3": "bar3"}' }); - expect(JSON.parse(manager.get())).toEqual({ foo3: 'bar3' }); - }); - - describe('when the update interval time fires before the request is complete', () => { - it('waits until the request is complete before making the next request', async () => { - let resolveResponsePromise: (resp: Response) => void; - const responsePromise: Promise = new Promise(res => { - resolveResponsePromise = res; - }); - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest').mockReturnValueOnce({ - abort() {}, - responsePromise, - }); - - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - - await advanceTimersByTime(1000); - expect(makeGetRequestSpy).toBeCalledTimes(1); - - resolveResponsePromise!({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - await responsePromise; - await advanceTimersByTime(0); - expect(makeGetRequestSpy).toBeCalledTimes(2); - }); - }); - - it('cancels a pending timeout when stop is called', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - - manager.start(); - await manager.onReady(); - - expect(getTimerCount()).toBe(1); - manager.stop(); - expect(getTimerCount()).toBe(0); - }); - - it('cancels reactions to a pending fetch when stop is called', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"foo2": "bar2"}', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - } - ); - - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - - await advanceTimersByTime(1000); - - expect(manager.responsePromises.length).toBe(2); - manager.stop(); - await manager.responsePromises[1]; - // Should not have updated datafile since manager was stopped - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - }); - - it('calls abort on the current request if there is a current request when stop is called', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo2": "bar2"}', - headers: {}, - }); - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - manager.start(); - const currentRequest = makeGetRequestSpy.mock.results[0]; - // @ts-ignore - expect(currentRequest.type).toBe('return'); - expect(currentRequest.value.abort).toBeCalledTimes(0); - manager.stop(); - expect(currentRequest.value.abort).toBeCalledTimes(1); - }); - - it('can fail to become ready on the initial request, but succeed after a later polling update', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }, - { - statusCode: 404, - body: '', - headers: {}, - } - ); - - manager.start(); - expect(manager.responsePromises.length).toBe(1); - await manager.responsePromises[0]; - // Not ready yet due to first request failed, but should have queued a live update - expect(getTimerCount()).toBe(1); - // Trigger the update, should fetch the next response which should succeed, then we get ready - advanceTimersByTime(1000); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - }); - - describe('newness checking', () => { - it('does not update if the response status is 304', async () => { - manager.queuedResponses.push( - { - statusCode: 304, - body: '', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: { - 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - } - ); - - const updateFn = vi.fn(); - manager.on('update', updateFn); - - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - // First response promise was for the initial 200 response - expect(manager.responsePromises.length).toBe(1); - // Trigger the queued update - await advanceTimersByTime(1000); - // Second response promise is for the 304 response - expect(manager.responsePromises.length).toBe(2); - await manager.responsePromises[1]; - // Since the response was 304, updateFn should not have been called - expect(updateFn).toBeCalledTimes(0); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - }); - - it('sends if-modified-since using the last observed response last-modified', async () => { - manager.queuedResponses.push( - { - statusCode: 304, - body: '', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: { - 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - } - ); - manager.start(); - await manager.onReady(); - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - await advanceTimersByTime(1000); - expect(makeGetRequestSpy).toBeCalledTimes(1); - const firstCall = makeGetRequestSpy.mock.calls[0]; - const headers = firstCall[1]; - expect(headers).toEqual({ - 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT', - }); - }); - }); - - describe('backoff', () => { - it('uses the delay from the backoff controller getDelay method when greater than updateInterval', async () => { - const BackoffControllerMock = (BackoffController as unknown) as MockInstance<() => BackoffController>; - const getDelayMock = BackoffControllerMock.mock.results[0].value.getDelay; - getDelayMock.mockImplementationOnce(() => 5432); - - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - - manager.queuedResponses.push({ - statusCode: 404, - body: '', - headers: {}, - }); - manager.start(); - await manager.responsePromises[0]; - expect(makeGetRequestSpy).toBeCalledTimes(1); - - // Should not make another request after 1 second because the error should have triggered backoff - advanceTimersByTime(1000); - expect(makeGetRequestSpy).toBeCalledTimes(1); - - // But after another 5 seconds, another request should be made - await advanceTimersByTime(5000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - }); - - it('calls countError on the backoff controller when a non-success status code response is received', async () => { - manager.queuedResponses.push({ - statusCode: 404, - body: '', - headers: {}, - }); - manager.start(); - await manager.responsePromises[0]; - const BackoffControllerMock = (BackoffController as unknown) as MockInstance<() => BackoffController>; - expect(BackoffControllerMock.mock.results[0].value.countError).toBeCalledTimes(1); - }); - - it('calls countError on the backoff controller when the response promise rejects', async () => { - manager.queuedResponses.push(new Error('Connection failed')); - manager.start(); - try { - await manager.responsePromises[0]; - } catch (e) { - //empty - } - const BackoffControllerMock = (BackoffController as unknown) as MockInstance<() => BackoffController>; - expect(BackoffControllerMock.mock.results[0].value.countError).toBeCalledTimes(1); - }); - - it('calls reset on the backoff controller when a success status code response is received', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: { - 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - }); - manager.start(); - const BackoffControllerMock = (BackoffController as unknown) as MockInstance<() => BackoffController>; - // Reset is called in start - we want to check that it is also called after the response, so reset the mock here - BackoffControllerMock.mock.results[0].value.reset.mockReset(); - await manager.onReady(); - expect(BackoffControllerMock.mock.results[0].value.reset).toBeCalledTimes(1); - }); - - it('resets the backoff controller when start is called', async () => { - const BackoffControllerMock = (BackoffController as unknown) as MockInstance<() => BackoffController>; - manager.start(); - expect(BackoffControllerMock.mock.results[0].value.reset).toBeCalledTimes(1); - try { - await manager.responsePromises[0]; - } catch (e) { - // empty - } - }); - }); - }); - }); - }); - - describe('when constructed with sdkKey and autoUpdate: false', () => { - beforeEach(() => { - manager = new TestDatafileManager({ sdkKey: '123', autoUpdate: false }); - }); - - it('after being started, fetches the datafile and resolves onReady', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - }); - - it('does not schedule a live update after ready', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - const updateFn = vi.fn(); - manager.on('update', updateFn); - manager.start(); - await manager.onReady(); - expect(getTimerCount()).toBe(0); - }); - - // TODO: figure out what's wrong with this test - it.skip('rejects the onReady promise if the initial request promise rejects', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.makeGetRequest = (): AbortableRequest => ({ - abort(): void {}, - responsePromise: Promise.reject(new Error('Could not connect')), - }); - manager.start(); - let didReject = false; - try { - await manager.onReady(); - } catch (e) { - didReject = true; - } - expect(didReject).toBe(true); - }); - }); - - describe('when constructed with sdkKey and a valid urlTemplate', () => { - beforeEach(() => { - manager = new TestDatafileManager({ - sdkKey: '456', - updateInterval: 1000, - urlTemplate: 'https://localhost:5556/datafiles/%s', - }); - }); - - it('uses the urlTemplate to create the url passed to the makeGetRequest method', async () => { - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://localhost:5556/datafiles/456'); - await manager.onReady(); - }); - }); - - describe('when constructed with an update interval below the minimum', () => { - beforeEach(() => { - manager = new TestDatafileManager({ sdkKey: '123', updateInterval: 500, autoUpdate: true }); - }); - - it('uses the default update interval', async () => { - const makeGetRequestSpy = vi.spyOn(manager, 'makeGetRequest'); - - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo3": "bar3"}', - headers: {}, - }); - - manager.start(); - await manager.onReady(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - await advanceTimersByTime(300000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - }); - }); - - describe('when constructed with a cache implementation having an already cached datafile', () => { - beforeEach(() => { - manager = new TestDatafileManager({ - sdkKey: 'keyThatExists', - updateInterval: 500, - autoUpdate: true, - cache: testCache, - }); - manager.simulateResponseDelay = true; - }); - - it('uses cached version of datafile first and resolves the promise while network throws error and no update event is triggered', async () => { - manager.queuedResponses.push(new Error('Connection Error')); - const updateFn = vi.fn(); - manager.on('update', updateFn); - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); - await advanceTimersByTime(50); - expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); - expect(updateFn).toBeCalledTimes(0); - }); - - it('uses cached datafile, resolves ready promise, fetches new datafile from network and triggers update event', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - - const updateFn = vi.fn(); - manager.on('update', updateFn); - manager.start(); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); - expect(updateFn).toBeCalledTimes(0); - await advanceTimersByTime(50); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(updateFn).toBeCalledTimes(1); - }); - - it('sets newly recieved datafile in to cache', async () => { - const cacheSetSpy = vi.spyOn(testCache, 'set'); - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - manager.start(); - await manager.onReady(); - await advanceTimersByTime(50); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(cacheSetSpy.mock.calls[0][0]).toEqual('opt-datafile-keyThatExists'); - expect(JSON.parse(cacheSetSpy.mock.calls[0][1])).toEqual({ foo: 'bar' }); - }); - }); - - describe('when constructed with a cache implementation without an already cached datafile', () => { - beforeEach(() => { - manager = new TestDatafileManager({ - sdkKey: 'keyThatDoesExists', - updateInterval: 500, - autoUpdate: true, - cache: testCache, - }); - manager.simulateResponseDelay = true; - }); - - it('does not find cached datafile, fetches new datafile from network, resolves promise and does not trigger update event', async () => { - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - - const updateFn = vi.fn(); - manager.on('update', updateFn); - manager.start(); - await advanceTimersByTime(50); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(updateFn).toBeCalledTimes(0); - }); - }); -}); diff --git a/tests/httpPollingDatafileManagerPolling.spec.ts b/tests/httpPollingDatafileManagerPolling.spec.ts deleted file mode 100644 index f1e57b864..000000000 --- a/tests/httpPollingDatafileManagerPolling.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { describe, beforeEach, afterEach, beforeAll, it, expect, vi, MockInstance } from 'vitest'; - -import { resetCalls, spy, verify } from 'ts-mockito'; -import { LogLevel, LoggerFacade, getLogger, setLogLevel } from '../lib/modules/logging'; -import { UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE } from '../lib/modules/datafile-manager/config'; -import { TestDatafileManager } from './httpPollingDatafileManager.spec'; - -describe('HttpPollingDatafileManager polling', () => { - let spiedLogger: LoggerFacade; - - const loggerName = 'DatafileManager'; - const sdkKey = 'not-real-sdk'; - - beforeAll(() => { - setLogLevel(LogLevel.DEBUG); - const actualLogger = getLogger(loggerName); - spiedLogger = spy(actualLogger); - }); - - beforeEach(() => { - resetCalls(spiedLogger); - }); - - - it('should log polling interval below 30 seconds', () => { - const below30Seconds = 29 * 1000; - - new TestDatafileManager({ - sdkKey, - updateInterval: below30Seconds, - }); - - - verify(spiedLogger.warn(UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE)).once(); - }); - - it('should not log when polling interval above 30s', () => { - const oneMinute = 60 * 1000; - - new TestDatafileManager({ - sdkKey, - updateInterval: oneMinute, - }); - - verify(spiedLogger.warn(UPDATE_INTERVAL_BELOW_MINIMUM_MESSAGE)).never(); - }); -}); diff --git a/tests/index.react_native.spec.ts b/tests/index.react_native.spec.ts index 425b4d1cb..32408ee6f 100644 --- a/tests/index.react_native.spec.ts +++ b/tests/index.react_native.spec.ts @@ -24,6 +24,8 @@ import packageJSON from '../package.json'; import optimizelyFactory from '../lib/index.react_native'; import configValidator from '../lib/utils/config_validator'; import eventProcessorConfigValidator from '../lib/utils/event_processor_config_validator'; +import { getMockProjectConfigManager } from '../lib/tests/mock/mock_project_config_manager'; +import { createProjectConfig } from '../lib/project_config/project_config'; vi.mock('@react-native-community/netinfo'); vi.mock('react-native-get-random-values') @@ -71,27 +73,21 @@ describe('javascript-sdk/react-native', () => { it('should not throw if the provided config is not valid', () => { expect(function() { const optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), // @ts-ignore logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - // @ts-ignore - optlyInstance.onReady().catch(function() {}); }).not.toThrow(); }); it('should create an instance of optimizely', () => { const optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - // @ts-ignore - optlyInstance.onReady().catch(function() {}); expect(optlyInstance).toBeInstanceOf(Optimizely); // @ts-ignore @@ -100,15 +96,13 @@ describe('javascript-sdk/react-native', () => { it('should set the React Native JS client engine and javascript SDK version', () => { const optlyInstance = optimizelyFactory.createInstance({ - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - // @ts-ignore - optlyInstance.onReady().catch(function() {}); + // @ts-ignore expect('react-native-js-sdk').toEqual(optlyInstance.clientEngine); // @ts-ignore @@ -118,15 +112,12 @@ describe('javascript-sdk/react-native', () => { it('should allow passing of "react-sdk" as the clientEngine and convert it to "react-native-sdk"', () => { const optlyInstance = optimizelyFactory.createInstance({ clientEngine: 'react-sdk', - datafile: {}, + projectConfigManager: getMockProjectConfigManager(), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore logger: silentLogger, }); - // Invalid datafile causes onReady Promise rejection - catch this error - // @ts-ignore - optlyInstance.onReady().catch(function() {}); // @ts-ignore expect('react-native-sdk').toEqual(optlyInstance.clientEngine); }); @@ -142,7 +133,9 @@ describe('javascript-sdk/react-native', () => { it('should call logging.setLogLevel', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), logLevel: optimizelyFactory.enums.LOG_LEVEL.ERROR, }); expect(logging.setLogLevel).toBeCalledTimes(1); @@ -162,7 +155,9 @@ describe('javascript-sdk/react-native', () => { it('should call logging.setLogHandler with the supplied logger', () => { const fakeLogger = { log: function() {} }; optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfig(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfig()), + }), // @ts-ignore logger: fakeLogger, }); @@ -184,7 +179,9 @@ describe('javascript-sdk/react-native', () => { it('should use default event flush interval when none is provided', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore @@ -212,7 +209,9 @@ describe('javascript-sdk/react-native', () => { it('should ignore the event flush interval and use the default instead', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore @@ -242,7 +241,9 @@ describe('javascript-sdk/react-native', () => { it('should use the provided event flush interval', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore @@ -262,7 +263,9 @@ describe('javascript-sdk/react-native', () => { it('should use default event batch size when none is provided', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore @@ -319,7 +322,9 @@ describe('javascript-sdk/react-native', () => { it('should use the provided event batch size', () => { optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), + projectConfigManager: getMockProjectConfigManager({ + initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + }), errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, // @ts-ignore diff --git a/tests/nodeDatafileManager.spec.ts b/tests/nodeDatafileManager.spec.ts deleted file mode 100644 index 11217663c..000000000 --- a/tests/nodeDatafileManager.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * 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. - * 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 { describe, beforeEach, afterEach, beforeAll, it, expect, vi, MockInstance } from 'vitest'; - -import NodeDatafileManager from '../lib/modules/datafile-manager/nodeDatafileManager'; -import * as nodeRequest from '../lib/modules/datafile-manager/nodeRequest'; -import { Headers, AbortableRequest } from '../lib/modules/datafile-manager/http'; -import { advanceTimersByTime, getTimerCount } from './testUtils'; - -describe('nodeDatafileManager', () => { - let makeGetRequestSpy: MockInstance<(reqUrl: string, headers: Headers) => AbortableRequest>; - beforeEach(() => { - vi.useFakeTimers(); - makeGetRequestSpy = vi.spyOn(nodeRequest, 'makeGetRequest'); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.clearAllTimers(); - }); - - it('calls nodeEnvironment.makeGetRequest when started', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - - const manager = new NodeDatafileManager({ - sdkKey: '1234', - autoUpdate: false, - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json'); - expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({}); - - await manager.onReady(); - await manager.stop(); - }); - - it('calls nodeEnvironment.makeGetRequest for live update requests', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - autoUpdate: true, - }); - manager.start(); - await manager.onReady(); - await advanceTimersByTime(300000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json'); - expect(makeGetRequestSpy.mock.calls[1][1]).toEqual({ - 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT', - }); - - await manager.stop(); - }); - - it('defaults to true for autoUpdate', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', - }, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - }); - manager.start(); - await manager.onReady(); - // Should set a timeout for a later update - expect(getTimerCount()).toBe(1); - await advanceTimersByTime(300000); - expect(makeGetRequestSpy).toBeCalledTimes(2); - - await manager.stop(); - }); - - it('uses authenticated default datafile url when auth token is provided', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - datafileAccessToken: 'abcdefgh', - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy).toBeCalledWith( - 'https://config.optimizely.com/datafiles/auth/1234.json', - expect.anything() - ); - await manager.stop(); - }); - - it('uses public default datafile url when auth token is not provided', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy).toBeCalledWith('https://cdn.optimizely.com/datafiles/1234.json', expect.anything()); - await manager.stop(); - }); - - it('adds authorization header with bearer token when auth token is provided', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - datafileAccessToken: 'abcdefgh', - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy).toBeCalledWith(expect.anything(), { Authorization: 'Bearer abcdefgh' }); - await manager.stop(); - }); - - it('prefers user provided url template over defaults', async () => { - makeGetRequestSpy.mockReturnValue({ - abort: vi.fn(), - responsePromise: Promise.resolve({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }), - }); - const manager = new NodeDatafileManager({ - sdkKey: '1234', - datafileAccessToken: 'abcdefgh', - urlTemplate: 'https://myawesomeurl/', - }); - manager.start(); - expect(makeGetRequestSpy).toBeCalledTimes(1); - expect(makeGetRequestSpy).toBeCalledWith('https://myawesomeurl/', expect.anything()); - await manager.stop(); - }); -}); diff --git a/tests/nodeRequest.spec.ts b/tests/nodeRequest.spec.ts deleted file mode 100644 index 8f1c66c8e..000000000 --- a/tests/nodeRequest.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * 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. - * 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 { describe, beforeEach, afterEach, beforeAll, afterAll, it, vi, expect } from 'vitest'; - -import nock from 'nock'; -import zlib from 'zlib'; -import { makeGetRequest } from '../lib/modules/datafile-manager/nodeRequest'; -import { advanceTimersByTime } from './testUtils'; - -beforeAll(() => { - nock.disableNetConnect(); -}); - -afterAll(() => { - nock.enableNetConnect(); -}); - -describe('nodeEnvironment', () => { - const host = 'https://cdn.optimizely.com'; - const path = '/datafiles/123.json'; - - afterEach(async () => { - nock.cleanAll(); - }); - - describe('makeGetRequest', () => { - it('returns a 200 response back to its superclass', async () => { - const scope = nock(host) - .get(path) - .reply(200, '{"foo":"bar"}'); - const req = makeGetRequest(`${host}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }); - scope.done(); - }); - - it('returns a 404 response back to its superclass', async () => { - const scope = nock(host) - .get(path) - .reply(404, ''); - const req = makeGetRequest(`${host}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 404, - body: '', - headers: {}, - }); - scope.done(); - }); - - it('includes headers from the headers argument in the request', async () => { - const scope = nock(host) - .matchHeader('if-modified-since', 'Fri, 08 Mar 2019 18:57:18 GMT') - .get(path) - .reply(304, ''); - const req = makeGetRequest(`${host}${path}`, { - 'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT', - }); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 304, - body: '', - headers: {}, - }); - scope.done(); - }); - - it('adds an Accept-Encoding request header and unzips a gzipped response body', async () => { - const scope = nock(host) - .matchHeader('accept-encoding', 'gzip,deflate') - .get(path) - .reply(200, () => zlib.gzipSync('{"foo":"bar"}'), { 'content-encoding': 'gzip' }); - const req = makeGetRequest(`${host}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toMatchObject({ - statusCode: 200, - body: '{"foo":"bar"}', - }); - scope.done(); - }); - - it('includes headers from the response in the eventual response in the return value', async () => { - const scope = await nock(host) - .get(path) - .reply( - 200, - { foo: 'bar' }, - { - 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', - } - ); - const req = await makeGetRequest(`${host}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: { - 'content-type': 'application/json', - 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', - }, - }); - scope.done(); - }); - - it('handles a URL with a query string', async () => { - const pathWithQuery = '/datafiles/123.json?from_my_app=true'; - const scope = nock(host) - .get(pathWithQuery) - .reply(200, { foo: 'bar' }); - const req = makeGetRequest(`${host}${pathWithQuery}`, {}); - await req.responsePromise; - scope.done(); - }); - - it('handles a URL with http protocol (not https)', async () => { - const httpHost = 'http://cdn.optimizely.com'; - const scope = nock(httpHost) - .get(path) - .reply(200, '{"foo":"bar"}'); - const req = makeGetRequest(`${httpHost}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }); - scope.done(); - }); - - it('returns a rejected response promise when the URL protocol is unsupported', async () => { - const invalidProtocolUrl = 'ftp://something/datafiles/123.json'; - const req = makeGetRequest(invalidProtocolUrl, {}); - await expect(req.responsePromise).rejects.toThrow(); - }); - - it('returns a rejected promise when there is a request error', async () => { - const scope = nock(host) - .get(path) - .replyWithError({ - message: 'Connection error', - code: 'CONNECTION_ERROR', - }); - const req = makeGetRequest(`${host}${path}`, {}); - await expect(req.responsePromise).rejects.toThrow(); - scope.done(); - }); - - it('handles a url with a host and a port', async () => { - const hostWithPort = 'http://datafiles:3000'; - const path = '/12/345.json'; - const scope = nock(hostWithPort) - .get(path) - .reply(200, '{"foo":"bar"}'); - const req = makeGetRequest(`${hostWithPort}${path}`, {}); - const resp = await req.responsePromise; - expect(resp).toEqual({ - statusCode: 200, - body: '{"foo":"bar"}', - headers: {}, - }); - scope.done(); - }); - - describe('timeout', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.clearAllTimers(); - }); - - it('rejects the response promise and aborts the request when the response is not received before the timeout', async () => { - const scope = nock(host) - .get(path) - .delay(61000) - .reply(200, '{"foo":"bar"}'); - - const abortEventListener = vi.fn(); - let emittedReq: any; - const requestListener = (request: any): void => { - emittedReq = request; - emittedReq.once('abort', abortEventListener); - }; - scope.on('request', requestListener); - - const req = makeGetRequest(`${host}${path}`, {}); - await advanceTimersByTime(60000); - await expect(req.responsePromise).rejects.toThrow(); - expect(abortEventListener).toBeCalledTimes(1); - - scope.done(); - if (emittedReq) { - emittedReq.off('abort', abortEventListener); - } - scope.off('request', requestListener); - }); - }); - }); -}); diff --git a/tests/nodeRequestHandler.spec.ts b/tests/nodeRequestHandler.spec.ts index 06c2e2bac..9bcc0d813 100644 --- a/tests/nodeRequestHandler.spec.ts +++ b/tests/nodeRequestHandler.spec.ts @@ -37,7 +37,7 @@ describe('NodeRequestHandler', () => { let nodeRequestHandler: NodeRequestHandler; beforeEach(() => { - nodeRequestHandler = new NodeRequestHandler(new NoOpLogger()); + nodeRequestHandler = new NodeRequestHandler({ logger: new NoOpLogger() }); }); afterEach(async () => { @@ -218,7 +218,7 @@ describe('NodeRequestHandler', () => { }; scope.on('request', requestListener); - const request = new NodeRequestHandler(new NoOpLogger(), 100).makeRequest(`${host}${path}`, {}, 'get'); + const request = new NodeRequestHandler({ logger: new NoOpLogger(), timeout: 100 }).makeRequest(`${host}${path}`, {}, 'get'); vi.advanceTimersByTime(60000); vi.runAllTimers(); // <- explicitly tell vi to run all setTimeout, setInterval diff --git a/tests/odpManager.browser.spec.ts b/tests/odpManager.browser.spec.ts index 2622b5c4d..89ecc8030 100644 --- a/tests/odpManager.browser.spec.ts +++ b/tests/odpManager.browser.spec.ts @@ -196,7 +196,7 @@ describe('OdpManager', () => { it('Custom odpOptions.segmentsRequestHandler overrides default Segment API Request Handler', () => { const odpOptions: OdpOptions = { - segmentsRequestHandler: new BrowserRequestHandler(fakeLogger, 4000), + segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 4000 }), }; const browserOdpManager = BrowserOdpManager.createInstance({ @@ -210,7 +210,7 @@ describe('OdpManager', () => { it('Custom odpOptions.segmentRequestHandler override takes precedence over odpOptions.eventApiTimeout', () => { const odpOptions: OdpOptions = { segmentsApiTimeout: 2, - segmentsRequestHandler: new BrowserRequestHandler(fakeLogger, 1), + segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), }; const browserOdpManager = BrowserOdpManager.createInstance({ @@ -247,7 +247,7 @@ describe('OdpManager', () => { maxSize: 1, timeout: 1, }), - new OdpSegmentApiManager(new BrowserRequestHandler(fakeLogger, 1), fakeLogger), + new OdpSegmentApiManager(new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), fakeLogger), fakeLogger, odpConfig, ); @@ -257,7 +257,7 @@ describe('OdpManager', () => { segmentsCacheTimeout: 2, segmentsCache: new BrowserLRUCache({ maxSize: 2, timeout: 2 }), segmentsApiTimeout: 2, - segmentsRequestHandler: new BrowserRequestHandler(fakeLogger, 2), + segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 2 }), segmentManager: customSegmentManager, }; @@ -370,7 +370,7 @@ describe('OdpManager', () => { it('Custom odpOptions.eventRequestHandler overrides default Event Manager request handler', () => { const odpOptions: OdpOptions = { - eventRequestHandler: new BrowserRequestHandler(fakeLogger, 4000), + eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 4000 }), }; const browserOdpManager = BrowserOdpManager.createInstance({ @@ -387,7 +387,7 @@ describe('OdpManager', () => { eventBatchSize: 2, eventFlushInterval: 2, eventQueueSize: 2, - eventRequestHandler: new BrowserRequestHandler(fakeLogger, 1), + eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), }; const browserOdpManager = BrowserOdpManager.createInstance({ @@ -434,7 +434,7 @@ describe('OdpManager', () => { const customEventManager = new BrowserOdpEventManager({ odpConfig, - apiManager: new BrowserOdpEventApiManager(new BrowserRequestHandler(fakeLogger, 1), fakeLogger), + apiManager: new BrowserOdpEventApiManager(new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), fakeLogger), logger: fakeLogger, clientEngine: fakeClientEngine, clientVersion: fakeClientVersion, @@ -448,7 +448,7 @@ describe('OdpManager', () => { eventBatchSize: 2, eventFlushInterval: 2, eventQueueSize: 2, - eventRequestHandler: new BrowserRequestHandler(fakeLogger, 3), + eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 3 }), eventManager: customEventManager, }; diff --git a/tests/reactNativeDatafileManager.spec.ts b/tests/reactNativeDatafileManager.spec.ts deleted file mode 100644 index 2a3c354f4..000000000 --- a/tests/reactNativeDatafileManager.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * 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 - * - * 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 { describe, beforeEach, afterEach, it, vi, expect, MockedObject } from 'vitest'; - -const { mockMap, mockGet, mockSet, mockRemove, mockContains } = vi.hoisted(() => { - const mockMap = new Map(); - - const mockGet = vi.fn().mockImplementation((key) => { - return Promise.resolve(mockMap.get(key)); - }); - - const mockSet = vi.fn().mockImplementation((key, value) => { - mockMap.set(key, value); - return Promise.resolve(); - }); - - const mockRemove = vi.fn().mockImplementation((key) => { - if (mockMap.has(key)) { - mockMap.delete(key); - return Promise.resolve(true); - } - return Promise.resolve(false); - }); - - const mockContains = vi.fn().mockImplementation((key) => { - return Promise.resolve(mockMap.has(key)); - }); - - return { mockMap, mockGet, mockSet, mockRemove, mockContains }; -}); - -vi.mock('../lib/plugins/key_value_cache/reactNativeAsyncStorageCache', () => { - const MockReactNativeAsyncStorageCache = vi.fn(); - MockReactNativeAsyncStorageCache.prototype.get = mockGet; - MockReactNativeAsyncStorageCache.prototype.set = mockSet; - MockReactNativeAsyncStorageCache.prototype.contains = mockContains; - MockReactNativeAsyncStorageCache.prototype.remove = mockRemove; - return { 'default': MockReactNativeAsyncStorageCache }; -}); - - -import { advanceTimersByTime } from './testUtils'; -import ReactNativeDatafileManager from '../lib/modules/datafile-manager/reactNativeDatafileManager'; -import { Headers, AbortableRequest, Response } from '../lib/modules/datafile-manager/http'; -import PersistentKeyValueCache from '../lib/plugins/key_value_cache/persistentKeyValueCache'; -import ReactNativeAsyncStorageCache from '../lib/plugins/key_value_cache/reactNativeAsyncStorageCache'; - -class MockRequestReactNativeDatafileManager extends ReactNativeDatafileManager { - queuedResponses: (Response | Error)[] = []; - - responsePromises: Promise[] = []; - - simulateResponseDelay = false; - - makeGetRequest(url: string, headers: Headers): AbortableRequest { - const nextResponse: Error | Response | undefined = this.queuedResponses.pop(); - let responsePromise: Promise; - if (nextResponse === undefined) { - responsePromise = Promise.reject('No responses queued'); - } else if (nextResponse instanceof Error) { - responsePromise = Promise.reject(nextResponse); - } else { - if (this.simulateResponseDelay) { - // Actual response will have some delay. This is required to get expected behavior for caching. - responsePromise = new Promise(resolve => setTimeout(() => resolve(nextResponse), 50)); - } else { - responsePromise = Promise.resolve(nextResponse); - } - } - this.responsePromises.push(responsePromise); - return { responsePromise, abort: vi.fn() }; - } -} - -describe('reactNativeDatafileManager', () => { - const MockedReactNativeAsyncStorageCache = vi.mocked(ReactNativeAsyncStorageCache); - - const testCache: PersistentKeyValueCache = { - get(key: string): Promise { - let val = undefined; - switch (key) { - case 'opt-datafile-keyThatExists': - val = JSON.stringify({ name: 'keyThatExists' }); - break; - } - return Promise.resolve(val); - }, - - set(): Promise { - return Promise.resolve(); - }, - - contains(): Promise { - return Promise.resolve(false); - }, - - remove(): Promise { - return Promise.resolve(false); - }, - }; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - MockedReactNativeAsyncStorageCache.mockClear(); - mockGet.mockClear(); - mockSet.mockClear(); - mockContains.mockClear(); - mockRemove.mockClear(); - }); - - it('uses the user provided cache', async () => { - const setSpy = vi.spyOn(testCache, 'set'); - - const manager = new MockRequestReactNativeDatafileManager({ - sdkKey: 'keyThatExists', - updateInterval: 500, - autoUpdate: true, - cache: testCache, - }); - - manager.simulateResponseDelay = true; - - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - - manager.start(); - vi.advanceTimersByTime(50); - await manager.onReady(); - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(setSpy.mock.calls[0][0]).toEqual('opt-datafile-keyThatExists'); - expect(JSON.parse(setSpy.mock.calls[0][1])).toEqual({ foo: 'bar' }); - }); - - it('uses ReactNativeAsyncStorageCache if no cache is provided', async () => { - const manager = new MockRequestReactNativeDatafileManager({ - sdkKey: 'keyThatExists', - updateInterval: 500, - autoUpdate: true - }); - manager.simulateResponseDelay = true; - - manager.queuedResponses.push({ - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - }); - - manager.start(); - vi.advanceTimersByTime(50); - await manager.onReady(); - - expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); - expect(mockSet.mock.calls[0][0]).toEqual('opt-datafile-keyThatExists'); - expect(JSON.parse(mockSet.mock.calls[0][1] as string)).toEqual({ foo: 'bar' }); - }); -}); diff --git a/tests/reactNativeHttpPollingDatafileManager.spec.ts b/tests/reactNativeHttpPollingDatafileManager.spec.ts deleted file mode 100644 index 466efdb43..000000000 --- a/tests/reactNativeHttpPollingDatafileManager.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * 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 - * - * 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 { describe, beforeEach, afterEach, it, vi, expect } from 'vitest'; - -vi.mock('../lib/modules/datafile-manager/index.react_native', () => { - return { - HttpPollingDatafileManager: vi.fn().mockImplementation(() => { - return { - get(): string { - return '{}'; - }, - on(): (() => void) { - return () => {}; - }, - onReady(): Promise { - return Promise.resolve(); - }, - }; - }), - } -}); - -import { HttpPollingDatafileManager } from '../lib/modules/datafile-manager/index.react_native'; -import { createHttpPollingDatafileManager } from '../lib/plugins/datafile_manager/react_native_http_polling_datafile_manager'; -import PersistentKeyValueCache from '../lib/plugins/key_value_cache/persistentKeyValueCache'; -import { PersistentCacheProvider } from '../lib/shared_types'; - -describe('createHttpPollingDatafileManager', () => { - const MockedHttpPollingDatafileManager = vi.mocked(HttpPollingDatafileManager); - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.clearAllTimers(); - MockedHttpPollingDatafileManager.mockClear(); - }); - - it('calls the provided persistentCacheFactory and passes it to the HttpPollingDatafileManagerConstructor', async () => { - const fakePersistentCache : PersistentKeyValueCache = { - contains(k: string): Promise { - return Promise.resolve(false); - }, - get(key: string): Promise { - return Promise.resolve(undefined); - }, - remove(key: string): Promise { - return Promise.resolve(false); - }, - set(key: string, val: string): Promise { - return Promise.resolve() - } - } - - const fakePersistentCacheProvider = vi.fn().mockImplementation(() => { - return fakePersistentCache; - }); - - const noop = () => {}; - - createHttpPollingDatafileManager( - 'test-key', - { log: noop, info: noop, debug: noop, error: noop, warn: noop }, - undefined, - {}, - fakePersistentCacheProvider, - ) - - expect(MockedHttpPollingDatafileManager).toHaveBeenCalledTimes(1); - - const { cache } = MockedHttpPollingDatafileManager.mock.calls[0][0]; - expect(cache === fakePersistentCache).toBeTruthy(); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index cd3b58451..ef8012773 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "./dist", "./lib/**/*.tests.js", "./lib/**/*.tests.ts", + "./lib/**/*.spec.ts", "./lib/**/*.umdtests.js", "./lib/tests", "node_modules" diff --git a/vitest.config.mts b/vitest.config.mts index d74a1e1fd..673f7d1c6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,3 +1,19 @@ +/** + * 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 + * + * 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 { defineConfig } from 'vitest/config' export default defineConfig({