diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts new file mode 100644 index 000000000..cbfbaf7be --- /dev/null +++ b/lib/core/decision_service/index.spec.ts @@ -0,0 +1,1740 @@ +/** + * Copyright 2025, 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, MockInstance, beforeEach } from 'vitest'; +import { DecisionService } from '.'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import OptimizelyUserContext from '../../optimizely_user_context'; +import { bucket } from '../bucketer'; +import { getTestProjectConfig, getTestProjectConfigWithFeatures } from '../../tests/test_data'; +import { createProjectConfig, ProjectConfig } from '../../project_config/project_config'; +import { BucketerParams, Experiment, UserProfile } from '../../shared_types'; +import { CONTROL_ATTRIBUTES, DECISION_SOURCES } from '../../utils/enums'; +import { getDecisionTestDatafile } from '../../tests/decision_test_datafile'; + +import { + USER_HAS_NO_FORCED_VARIATION, + VALID_BUCKETING_ID, + SAVED_USER_VARIATION, + SAVED_VARIATION_NOT_FOUND, +} from 'log_message'; + +import { + EXPERIMENT_NOT_RUNNING, + RETURNING_STORED_VARIATION, + USER_NOT_IN_EXPERIMENT, + USER_FORCED_IN_VARIATION, + EVALUATING_AUDIENCES_COMBINED, + AUDIENCE_EVALUATION_RESULT_COMBINED, + USER_IN_ROLLOUT, + USER_NOT_IN_ROLLOUT, + FEATURE_HAS_NO_EXPERIMENTS, + USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE, + USER_NOT_BUCKETED_INTO_TARGETING_RULE, + USER_BUCKETED_INTO_TARGETING_RULE, + NO_ROLLOUT_EXISTS, + USER_MEETS_CONDITIONS_FOR_TARGETING_RULE, +} from '../decision_service/index'; + +import { BUCKETING_ID_NOT_STRING, USER_PROFILE_LOOKUP_ERROR, USER_PROFILE_SAVE_ERROR } from 'error_message'; +import exp from 'constants'; + +type MockLogger = ReturnType; + +type MockUserProfileService = { + lookup: ReturnType; + save: ReturnType; +}; + +type DecisionServiceInstanceOpt = { + logger?: boolean; + userProfileService?: boolean; +} + +type DecisionServiceInstance = { + logger?: MockLogger; + userProfileService?: MockUserProfileService; + decisionService: DecisionService; +} + +const getDecisionService = (opt: DecisionServiceInstanceOpt = {}): DecisionServiceInstance => { + const logger = opt.logger ? getMockLogger() : undefined; + const userProfileService = opt.userProfileService ? { + lookup: vi.fn(), + save: vi.fn(), + } : undefined; + + const decisionService = new DecisionService({ + logger, + userProfileService, + UNSTABLE_conditionEvaluators: {}, + }); + + return { + logger, + userProfileService, + decisionService, + }; +}; + +const mockBucket: MockInstance = vi.hoisted(() => vi.fn()); + +vi.mock('../bucketer', () => ({ + bucket: mockBucket, +})); + +const cloneDeep = (d: any) => JSON.parse(JSON.stringify(d)); + +const testData = getTestProjectConfig(); +const testDataWithFeatures = getTestProjectConfigWithFeatures(); + +const verifyBucketCall = ( + call: number, + projectConfig: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + bucketIdFromAttribute = false, +) => { + const { + experimentId, + experimentKey, + userId, + trafficAllocationConfig, + experimentKeyMap, + experimentIdMap, + groupIdMap, + variationIdMap, + bucketingId, + } = mockBucket.mock.calls[call][0]; + expect(experimentId).toBe(experiment.id); + expect(experimentKey).toBe(experiment.key); + expect(userId).toBe(user.getUserId()); + expect(trafficAllocationConfig).toBe(experiment.trafficAllocation); + expect(experimentKeyMap).toBe(projectConfig.experimentKeyMap); + expect(experimentIdMap).toBe(projectConfig.experimentIdMap); + expect(groupIdMap).toBe(projectConfig.groupIdMap); + expect(variationIdMap).toBe(projectConfig.variationIdMap); + expect(bucketingId).toBe(bucketIdFromAttribute ? user.getAttributes()[CONTROL_ATTRIBUTES.BUCKETING_ID] : user.getUserId()); +}; + +describe('DecisionService', () => { + describe('getVariation', function() { + beforeEach(() => { + mockBucket.mockClear(); + }); + + it('should return the correct variation from bucketer for the given experiment key and user ID for a running experiment', () => { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester' + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const fakeDecisionResponse = { + result: '111128', + reasons: [], + }; + + const experiment = config.experimentIdMap['111127']; + + mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data` + + const { decisionService } = getDecisionService(); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe('control'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + }); + + it('should use $opt_bucketing_id attribute as bucketing id if provided', () => { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + $opt_bucketing_id: 'test_bucketing_id', + }, + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const fakeDecisionResponse = { + result: '111128', + reasons: [], + }; + + const experiment = config.experimentIdMap['111127']; + + mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data` + + const { decisionService } = getDecisionService(); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe('control'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user, true); + }); + + it('should return the whitelisted variation if the user is whitelisted', function() { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user2' + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const experiment = config.experimentIdMap['122227']; + + const { decisionService, logger } = getDecisionService({ logger: true }); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe('variationWithAudience'); + expect(mockBucket).not.toHaveBeenCalled(); + expect(logger?.debug).toHaveBeenCalledTimes(1); + expect(logger?.info).toHaveBeenCalledTimes(1); + + expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'user2'); + expect(logger?.info).toHaveBeenNthCalledWith(1, USER_FORCED_IN_VARIATION, 'user2', 'variationWithAudience'); + }); + + it('should return null if the user does not meet audience conditions', () => { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user3' + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const experiment = config.experimentIdMap['122227']; + + const { decisionService, logger } = getDecisionService({ logger: true }); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe(null); + expect(mockBucket).not.toHaveBeenCalled(); + + expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'user3'); + expect(logger?.debug).toHaveBeenNthCalledWith(2, EVALUATING_AUDIENCES_COMBINED, 'experiment', 'testExperimentWithAudiences', JSON.stringify(["11154"])); + + expect(logger?.info).toHaveBeenNthCalledWith(1, AUDIENCE_EVALUATION_RESULT_COMBINED, 'experiment', 'testExperimentWithAudiences', 'FALSE'); + expect(logger?.info).toHaveBeenNthCalledWith(2, USER_NOT_IN_EXPERIMENT, 'user3', 'testExperimentWithAudiences'); + }); + + it('should return the forced variation set using setForcedVariation \ + in presence of a whitelisted variation', function() { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user2' + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const experiment = config.experimentIdMap['122227']; + + const { decisionService } = getDecisionService(); + + const forcedVariation = 'controlWithAudience'; + + decisionService.setForcedVariation(config, experiment.key, user.getUserId(), forcedVariation); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe(forcedVariation); + + const whitelistedVariation = experiment.forcedVariations?.[user.getUserId()]; + expect(whitelistedVariation).toBeDefined(); + expect(whitelistedVariation).not.toEqual(forcedVariation); + }); + + it('should return the forced variation set using setForcedVariation \ + even if user does not satisfy audience condition', function() { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user3', // no attributes are set, should not satisfy audience condition 11154 + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const experiment = config.experimentIdMap['122227']; + + const { decisionService } = getDecisionService(); + + const forcedVariation = 'controlWithAudience'; + + decisionService.setForcedVariation(config, experiment.key, user.getUserId(), forcedVariation); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe(forcedVariation); + }); + + it('should return null if the experiment is not running', function() { + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user1' + }); + + const config = createProjectConfig(cloneDeep(testData)); + + const experiment = config.experimentIdMap['133337']; + + const { decisionService, logger } = getDecisionService({ logger: true }); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe(null); + expect(mockBucket).not.toHaveBeenCalled(); + expect(logger?.info).toHaveBeenCalledTimes(1); + expect(logger?.info).toHaveBeenNthCalledWith(1, EXPERIMENT_NOT_RUNNING, 'testExperimentNotRunning'); + }); + + it('should respect the sticky bucketing information for attributes when attributes.$opt_experiment_bucket_map is supplied', () => { + const fakeDecisionResponse = { + result: '111128', + reasons: [], + }; + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const attributes: any = { + $opt_experiment_bucket_map: { + '111127': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + attributes, + }); + + mockBucket.mockReturnValue(fakeDecisionResponse); // contains variation ID of the 'control' variation from `test_data` + + const { decisionService } = getDecisionService(); + + const variation = decisionService.getVariation(config, experiment, user); + + expect(variation.result).toBe('variation'); + expect(mockBucket).not.toHaveBeenCalled(); + }); + + describe('when a user profile service is provided', function() { + beforeEach(() => { + mockBucket.mockClear(); + }); + + it('should return the previously bucketed variation', () => { + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }); + + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('variation'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + expect(mockBucket).not.toHaveBeenCalled(); + + expect(logger?.debug).toHaveBeenCalledTimes(1); + expect(logger?.info).toHaveBeenCalledTimes(1); + + expect(logger?.debug).toHaveBeenNthCalledWith(1, USER_HAS_NO_FORCED_VARIATION, 'decision_service_user'); + expect(logger?.info).toHaveBeenNthCalledWith(1, RETURNING_STORED_VARIATION, 'variation', 'testExperiment', 'decision_service_user'); + }); + + it('should bucket and save user profile if there was no prevously bucketed variation', function() { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: {}, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + }); + + it('should bucket if the user profile service returns null', function() { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue(null); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + }); + + it('should re-bucket if the stored variation is no longer valid', function() { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: 'not valid variation', + }, + }, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(logger?.debug).toHaveBeenCalledWith(USER_HAS_NO_FORCED_VARIATION, 'decision_service_user'); + expect(logger?.info).toHaveBeenCalledWith(SAVED_VARIATION_NOT_FOUND, 'decision_service_user', 'not valid variation', 'testExperiment'); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + }); + + it('should store the bucketed variation for the user', function() { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: {}, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + }); + + + it('should log an error message and bucket if "lookup" throws an error', () => { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockImplementation(() => { + throw new Error('I am an error'); + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(logger?.debug).toHaveBeenCalledWith(USER_HAS_NO_FORCED_VARIATION, 'decision_service_user'); + expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_LOOKUP_ERROR, 'decision_service_user', 'I am an error'); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + }); + + it('should log an error message if "save" throws an error', () => { + mockBucket.mockReturnValue({ + result: '111128', // ID of the 'control' variation + reasons: [], + }); + + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue(null); + userProfileService?.save.mockImplementation(() => { + throw new Error('I am an error'); + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('decision_service_user'); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, experiment, user); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', + }, + }, + }); + + expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_SAVE_ERROR, 'decision_service_user', 'I am an error'); + }); + + it('should respect $opt_experiment_bucket_map attribute over the userProfileService for the matching experiment id', function() { + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', // ID of the 'control' variation + }, + }, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const attributes: any = { + $opt_experiment_bucket_map: { + '111127': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + attributes, + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('variation'); + }); + + it('should ignore attributes for a different experiment id', function() { + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '111127': { + variation_id: '111128', // ID of the 'control' variation + }, + }, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const attributes: any = { + $opt_experiment_bucket_map: { + '122227': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + attributes, + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('control'); + }); + + it('should use $ opt_experiment_bucket_map attribute when the userProfile contains variations for other experiments', function() { + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue({ + user_id: 'decision_service_user', + experiment_bucket_map: { + '122227': { + variation_id: '122229', // ID of the 'variationWithAudience' variation + }, + }, + }); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const attributes: any = { + $opt_experiment_bucket_map: { + '111127': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + attributes, + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('variation'); + }); + + it('should use attributes when the userProfileLookup returns null', function() { + const { decisionService, userProfileService, logger } = getDecisionService({ userProfileService: true, logger: true }); + + userProfileService?.lookup.mockReturnValue(null); + + const config = createProjectConfig(cloneDeep(testData)); + const experiment = config.experimentIdMap['111127']; + + const attributes: any = { + $opt_experiment_bucket_map: { + '111127': { + variation_id: '111129', // ID of the 'variation' variation + }, + }, + }; + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'decision_service_user', + attributes, + }); + + const variation = decisionService.getVariation(config, experiment, user); + expect(variation.result).toBe('variation'); + }); + }); + }); + + describe('getVariationForFeature', () => { + beforeEach(() => { + mockBucket.mockReset(); + }); + + it('should return variation from the first experiment for which a variation is available', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker + ) => { + if (experiment.key === 'exp_2') { + return { + result: 'variation_2', + reasons: [], + }; + } + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(2); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + }); + + it('should return the variation forced for an experiment in the userContext if available', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker + ) => { + if (experiment.key === 'exp_2') { + return { + result: 'variation_2', + reasons: [], + }; + } + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + user.setForcedDecision( + { flagKey: 'flag_1', ruleKey: 'exp_2' }, + { variationKey: 'variation_5' } + ); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5005'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should save the variation found for an experiment in the user profile', () => { + const { decisionService, userProfileService } = getDecisionService({ userProfileService: true }); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_2') { + const variation = 'variation_2'; + + userProfileTracker.userProfile[experiment.id] = { + variation_id: '5002', + }; + userProfileTracker.isProfileUpdated = true; + + return { + result: 'variation_2', + reasons: [], + }; + } + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + userProfileService?.lookup.mockImplementation((userId: string) => { + if (userId === 'tester') { + return { + user_id: 'tester', + experiment_bucket_map: { + '2001': { + variation_id: '5001', + }, + }, + }; + } + return null; + }); + + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('tester'); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'tester', + experiment_bucket_map: { + '2001': { + variation_id: '5001', + }, + '2002': { + variation_id: '5002', + }, + }, + }); + }); + + describe('when no variation is found for any experiment and a targeted delivery \ + audience condition is satisfied', () => { + beforeEach(() => { + mockBucket.mockReset(); + }); + + it('should return variation from the target delivery for which audience condition \ + is satisfied if the user is bucketed into it', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockReturnValue({ + result: null, + reasons: [], + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3 + }, + }); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'delivery_2') { + return { + result: '5005', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['delivery_2'], + variation: config.variationIdMap['5005'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(3); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(3, + config, config.experimentKeyMap['exp_3'], user, false, expect.anything()); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, config.experimentIdMap['3002'], user); + }); + + it('should return variation from the target delivery and use $opt_bucketing_id attribute as bucketing id', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockReturnValue({ + result: null, + reasons: [], + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3 + $opt_bucketing_id: 'test_bucketing_id', + }, + }); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'delivery_2') { + return { + result: '5005', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['delivery_2'], + variation: config.variationIdMap['5005'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(3); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(3, + config, config.experimentKeyMap['exp_3'], user, false, expect.anything()); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, config.experimentIdMap['3002'], user, true); + }); + + it('should skip to everyone else targeting rule if the user is not bucketed \ + into the targeted delivery for which audience condition is satisfied', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockReturnValue({ + result: null, + reasons: [], + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 55, // this should satisfy the audience condition for the targeted delivery with key delivery_2 and delivery_3 + }, + }); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'default-rollout-key') { + return { + result: '5007', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentIdMap['default-rollout-id'], + variation: config.variationIdMap['5007'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(3); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(3, + config, config.experimentKeyMap['exp_3'], user, false, expect.anything()); + + expect(mockBucket).toHaveBeenCalledTimes(2); + verifyBucketCall(0, config, config.experimentIdMap['3002'], user); + verifyBucketCall(1, config, config.experimentIdMap['default-rollout-id'], user); + }); + }); + + it('should return the forced variation for targeted delivery rule when no variation \ + is found for any experiment and a there is a forced decision \ + for a targeted delivery in the userContext', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker + ) => { + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + }); + + user.setForcedDecision( + { flagKey: 'flag_1', ruleKey: 'delivery_2' }, + { variationKey: 'variation_1' } + ); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['delivery_2'], + variation: config.variationIdMap['5001'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + }); + + it('should return variation from the everyone else targeting rule if no variation \ + is found for any experiment or targeted delivery', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockReturnValue({ + result: null, + reasons: [], + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 100, // this should not satisfy any audience condition for any targeted delivery + }, + }); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'default-rollout-key') { + return { + result: '5007', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: config.experimentIdMap['default-rollout-id'], + variation: config.variationIdMap['5007'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(3); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(3, + config, config.experimentKeyMap['exp_3'], user, false, expect.anything()); + + expect(mockBucket).toHaveBeenCalledTimes(1); + verifyBucketCall(0, config, config.experimentIdMap['default-rollout-id'], user); + }); + + it('should return null if no variation is found for any experiment, targeted delivery, or everyone else targeting rule', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockReturnValue({ + result: null, + reasons: [], + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + const rolloutId = config.featureKeyMap['flag_1'].rolloutId; + config.rolloutIdMap[rolloutId].experiments = []; // remove the experiments from the rollout + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 10, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const variation = decisionService.getVariationForFeature(config, feature, user); + + expect(variation.result).toEqual({ + experiment: null, + variation: null, + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + + expect(resolveVariationSpy).toHaveBeenCalledTimes(3); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(1, + config, config.experimentKeyMap['exp_1'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(2, + config, config.experimentKeyMap['exp_2'], user, false, expect.anything()); + expect(resolveVariationSpy).toHaveBeenNthCalledWith(3, + config, config.experimentKeyMap['exp_3'], user, false, expect.anything()); + + expect(mockBucket).toHaveBeenCalledTimes(0); + }); + }); + + describe('getVariationsForFeatureList', () => { + beforeEach(() => { + mockBucket.mockReset(); + }); + + it('should return correct results for all features in the feature list', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker + ) => { + if (experiment.key === 'exp_2') { + return { + result: 'variation_2', + reasons: [], + }; + } else if (experiment.key === 'exp_4') { + return { + result: 'variation_flag_2', + reasons: [], + }; + } + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const featureList = [ + config.featureKeyMap['flag_1'], + config.featureKeyMap['flag_2'], + ]; + + const variations = decisionService.getVariationsForFeatureList(config, featureList, user); + + expect(variations[0].result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(variations[1].result).toEqual({ + experiment: config.experimentKeyMap['exp_4'], + variation: config.variationIdMap['5100'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + const variations2 = decisionService.getVariationsForFeatureList(config, featureList.reverse(), user); + + expect(variations2[0].result).toEqual({ + experiment: config.experimentKeyMap['exp_4'], + variation: config.variationIdMap['5100'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(variations2[1].result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should batch user profile lookup and save', () => { + const { decisionService, userProfileService } = getDecisionService({ userProfileService: true }); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + config, + experiment: any, + user, + shouldIgnoreUPS, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_2') { + userProfileTracker.userProfile[experiment.id] = { + variation_id: '5002', + }; + userProfileTracker.isProfileUpdated = true; + + return { + result: 'variation_2', + reasons: [], + }; + } else if (experiment.key === 'exp_4') { + userProfileTracker.userProfile[experiment.id] = { + variation_id: '5100', + }; + userProfileTracker.isProfileUpdated = true; + + return { + result: 'variation_flag_2', + reasons: [], + }; + } + return { + result: null, + reasons: [], + } + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const featureList = [ + config.featureKeyMap['flag_1'], + config.featureKeyMap['flag_2'], + ]; + + const variations = decisionService.getVariationsForFeatureList(config, featureList, user); + + expect(variations[0].result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(variations[1].result).toEqual({ + experiment: config.experimentKeyMap['exp_4'], + variation: config.variationIdMap['5100'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('tester'); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'tester', + experiment_bucket_map: { + '2002': { + variation_id: '5002', + }, + '2004': { + variation_id: '5100', + }, + }, + }); + }); + }); + + + describe('forced variation management', () => { + it('should return true for a valid forcedVariation in setForcedVariation', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation).toBe(true); + }); + + it('should return the same variation from getVariation as was set in setVariation', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + + const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + expect(variation).toBe('control'); + }); + + it('should return null from getVariation if no forced variation was set for a valid experimentKey', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + expect(config.experimentKeyMap['testExperiment']).toBeDefined(); + const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + + expect(variation).toBe(null); + }); + + it('should return null from getVariation for an invalid experimentKey', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + expect(config.experimentKeyMap['definitely_not_valid_exp_key']).not.toBeDefined(); + const variation = decisionService.getForcedVariation(config, 'definitely_not_valid_exp_key', 'user1').result; + + expect(variation).toBe(null); + }); + + it('should return null when a forced decision is set on another experiment key', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + decisionService.setForcedVariation(config, 'testExperiment', 'user1', 'control'); + const variation = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + expect(variation).toBe(null); + }); + + it('should not set forced variation for an invalid variation key and return false', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const wasSet = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'definitely_not_valid_variation_key' + ); + + expect(wasSet).toBe(false); + const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + expect(variation).toBe(null); + }); + + it('should reset the forcedVariation if null is passed to setForcedVariation', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + + expect(didSetVariation).toBe(true); + + let variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + expect(variation).toBe('control'); + + const didSetVariationAgain = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + null + ); + + expect(didSetVariationAgain).toBe(true); + + variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + expect(variation).toBe(null); + }); + + it('should be able to add variations for multiple experiments for one user', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation1 = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation1).toBe(true); + + const didSetVariation2 = decisionService.setForcedVariation( + config, + 'testExperimentLaunched', + 'user1', + 'controlLaunched' + ); + expect(didSetVariation2).toBe(true); + + const variation = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + const variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + expect(variation).toBe('control'); + expect(variation2).toBe('controlLaunched'); + }); + + it('should be able to forced variation to same experiment for multiple users', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation1 = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation1).toBe(true); + + const didSetVariation2 = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user2', + 'variation' + ); + expect(didSetVariation2).toBe(true); + + const variationControl = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + const variationVariation = decisionService.getForcedVariation(config, 'testExperiment', 'user2').result; + + expect(variationControl).toBe('control'); + expect(variationVariation).toBe('variation'); + }); + + it('should be able to reset a variation for a user with multiple experiments', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + // Set the first time + const didSetVariation1 = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation1).toBe(true); + + const didSetVariation2 = decisionService.setForcedVariation( + config, + 'testExperimentLaunched', + 'user1', + 'controlLaunched' + ); + expect(didSetVariation2).toBe(true); + + let variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + let variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + + expect(variation1).toBe('control'); + expect(variation2).toBe('controlLaunched'); + + // Reset for one of the experiments + const didSetVariationAgain = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'variation' + ); + expect(didSetVariationAgain).toBe(true); + + variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + + expect(variation1).toBe('variation'); + expect(variation2).toBe('controlLaunched'); + }); + + it('should be able to unset a variation for a user with multiple experiments', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + // Set the first time + const didSetVariation1 = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation1).toBe(true); + + const didSetVariation2 = decisionService.setForcedVariation( + config, + 'testExperimentLaunched', + 'user1', + 'controlLaunched' + ); + expect(didSetVariation2).toBe(true); + + let variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + let variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + + expect(variation1).toBe('control'); + expect(variation2).toBe('controlLaunched'); + + // Unset for one of the experiments + decisionService.setForcedVariation(config, 'testExperiment', 'user1', null); + + variation1 = decisionService.getForcedVariation(config, 'testExperiment', 'user1').result; + variation2 = decisionService.getForcedVariation(config, 'testExperimentLaunched', 'user1').result; + + expect(variation1).toBe(null); + expect(variation2).toBe('controlLaunched'); + }); + + it('should return false for an empty variation key', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation(config, 'testExperiment', 'user1', ''); + expect(didSetVariation).toBe(false); + }); + + it('should return null when a variation was previously set, and that variation no longer exists on the config object', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation).toBe(true); + + const newDatafile = cloneDeep(testData); + // Remove 'control' variation from variations, traffic allocation, and datafile forcedVariations. + newDatafile.experiments[0].variations = [ + { + key: 'variation', + id: '111129', + }, + ]; + newDatafile.experiments[0].trafficAllocation = [ + { + entityId: '111129', + endOfRange: 9000, + }, + ]; + newDatafile.experiments[0].forcedVariations = { + user1: 'variation', + user2: 'variation', + }; + // Now the only variation in testExperiment is 'variation' + const newConfigObj = createProjectConfig(newDatafile); + const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; + expect(forcedVar).toBe(null); + }); + + it("should return null when a variation was previously set, and that variation's experiment no longer exists on the config object", function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation( + config, + 'testExperiment', + 'user1', + 'control' + ); + expect(didSetVariation).toBe(true); + + const newConfigObj = createProjectConfig(cloneDeep(testDataWithFeatures)); + const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; + expect(forcedVar).toBe(null); + }); + + it('should return false from setForcedVariation and not set for invalid experiment key', function() { + const config = createProjectConfig(cloneDeep(testData)); + const { decisionService } = getDecisionService(); + + const didSetVariation = decisionService.setForcedVariation( + config, + 'definitelyNotAValidExperimentKey', + 'user1', + 'control' + ); + expect(didSetVariation).toBe(false); + + const variation = decisionService.getForcedVariation( + config, + 'definitelyNotAValidExperimentKey', + 'user1' + ).result; + expect(variation).toBe(null); + }); + }); +}); diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index b723d118b..8dd68aa88 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2017-2022, 2024, Optimizely + * Copyright 2017-2022, 2024-2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -658,16 +658,6 @@ describe('lib/core/decision_service', function() { }); }); - describe('checkIfExperimentIsActive', function() { - it('should return true if experiment is running', function() { - assert.isTrue(decisionServiceInstance.checkIfExperimentIsActive(configObj, 'testExperiment')); - }); - - it('should return false when experiment is not running', function() { - assert.isFalse(decisionServiceInstance.checkIfExperimentIsActive(configObj, 'testExperimentNotRunning')); - }); - }); - describe('checkIfUserIsInAudience', function() { var __audienceEvaluateSpy; @@ -1269,214 +1259,12 @@ describe('lib/core/decision_service', function() { it('returns a decision with a variation in the experiment the feature is attached to', function() { var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; - var expectedDecision = { - experiment: { - forcedVariations: {}, - status: 'Running', - key: 'testing_my_feature', - id: '594098', - variations: [ - { - id: '594096', - variables: [ - { - id: '4792309476491264', - value: '2', - }, - { - id: '5073784453201920', - value: 'true', - }, - { - id: '5636734406623232', - value: 'Buy me NOW', - }, - { - id: '6199684360044544', - value: '20.25', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 1, "text": "first variation"}', - }, - ], - featureEnabled: true, - key: 'variation', - }, - { - id: '594097', - variables: [ - { - id: '4792309476491264', - value: '10', - }, - { - id: '5073784453201920', - value: 'false', - }, - { - id: '5636734406623232', - value: 'Buy me', - }, - { - id: '6199684360044544', - value: '50.55', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 2, "text": "second variation"}', - }, - ], - featureEnabled: true, - key: 'control', - }, - { - id: '594099', - variables: [ - { - id: '4792309476491264', - value: '40', - }, - { - id: '5073784453201920', - value: 'true', - }, - { - id: '5636734406623232', - value: 'Buy me Later', - }, - { - id: '6199684360044544', - value: '99.99', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 3, "text": "third variation"}', - }, - ], - featureEnabled: false, - key: 'variation2', - }, - ], - audienceIds: [], - trafficAllocation: [ - { endOfRange: 5000, entityId: '594096' }, - { endOfRange: 10000, entityId: '594097' }, - ], - layerId: '594093', - variationKeyMap: { - control: { - id: '594097', - variables: [ - { - id: '4792309476491264', - value: '10', - }, - { - id: '5073784453201920', - value: 'false', - }, - { - id: '5636734406623232', - value: 'Buy me', - }, - { - id: '6199684360044544', - value: '50.55', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 2, "text": "second variation"}', - }, - ], - featureEnabled: true, - key: 'control', - }, - variation: { - id: '594096', - variables: [ - { - id: '4792309476491264', - value: '2', - }, - { - id: '5073784453201920', - value: 'true', - }, - { - id: '5636734406623232', - value: 'Buy me NOW', - }, - { - id: '6199684360044544', - value: '20.25', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 1, "text": "first variation"}', - }, - ], - featureEnabled: true, - key: 'variation', - }, - variation2: { - id: '594099', - variables: [ - { - id: '4792309476491264', - value: '40', - }, - { - id: '5073784453201920', - value: 'true', - }, - { - id: '5636734406623232', - value: 'Buy me Later', - }, - { - id: '6199684360044544', - value: '99.99', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 3, "text": "third variation"}', - }, - ], - featureEnabled: false, - key: 'variation2', - }, - }, - }, - variation: { - id: '594096', - variables: [ - { - id: '4792309476491264', - value: '2', - }, - { - id: '5073784453201920', - value: 'true', - }, - { - id: '5636734406623232', - value: 'Buy me NOW', - }, - { - id: '6199684360044544', - value: '20.25', - }, - { - id: '1547854156498475', - value: '{ "num_buttons": 1, "text": "first variation"}', - }, - ], - featureEnabled: true, - key: 'variation', - }, + const expectedDecision = { + experiment: configObj.experimentIdMap['594098'], + variation: configObj.variationIdMap['594096'], decisionSource: DECISION_SOURCES.FEATURE_TEST, }; + assert.deepEqual(decision, expectedDecision); sinon.assert.calledWith( getVariationStub, @@ -1540,42 +1328,11 @@ describe('lib/core/decision_service', function() { it('returns a decision with a variation in an experiment in a group', function() { var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; var expectedDecision = { - experiment: { - forcedVariations: {}, - status: 'Running', - key: 'exp_with_group', - id: '595010', - variations: [ - { id: '595008', variables: [], key: 'var' }, - { id: '595009', variables: [], key: 'con' }, - ], - audienceIds: [], - trafficAllocation: [ - { endOfRange: 5000, entityId: '595008' }, - { endOfRange: 10000, entityId: '595009' }, - ], - layerId: '595005', - groupId: '595024', - variationKeyMap: { - con: { - id: '595009', - variables: [], - key: 'con', - }, - var: { - id: '595008', - variables: [], - key: 'var', - }, - }, - }, - variation: { - id: '595008', - variables: [], - key: 'var', - }, + experiment: configObj.experimentIdMap['595010'], + variation: configObj.variationIdMap['595008'], decisionSource: DECISION_SOURCES.FEATURE_TEST, - }; + }; + assert.deepEqual(decision, expectedDecision); }); }); @@ -1649,103 +1406,11 @@ describe('lib/core/decision_service', function() { }); var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; var expectedDecision = { - experiment: { - forcedVariations: {}, - status: 'Not started', - key: '594031', - id: '594031', - isRollout: true, - variations: [ - { - id: '594032', - variables: [ - { - id: '4919852825313280', - value: 'true', - }, - { - id: '5482802778734592', - value: '395', - }, - { - id: '6045752732155904', - value: '4.99', - }, - { - id: '6327227708866560', - value: 'Hello audience', - }, - { - id: "8765345281230956", - value: '{ "count": 2, "message": "Hello audience" }', - }, - ], - featureEnabled: true, - key: '594032', - }, - ], - variationKeyMap: { - 594032: { - id: '594032', - variables: [ - { - id: '4919852825313280', - value: 'true', - }, - { - id: '5482802778734592', - value: '395', - }, - { - id: '6045752732155904', - value: '4.99', - }, - { - id: '6327227708866560', - value: 'Hello audience', - }, - { - id: "8765345281230956", - value: '{ "count": 2, "message": "Hello audience" }', - }, - ], - featureEnabled: true, - key: '594032', - }, - }, - audienceIds: ['594017'], - trafficAllocation: [{ endOfRange: 5000, entityId: '594032' }], - layerId: '594030', - }, - variation: { - id: '594032', - variables: [ - { - id: '4919852825313280', - value: 'true', - }, - { - id: '5482802778734592', - value: '395', - }, - { - id: '6045752732155904', - value: '4.99', - }, - { - id: '6327227708866560', - value: 'Hello audience', - }, - { - id: "8765345281230956", - value: '{ "count": 2, "message": "Hello audience" }', - }, - ], - featureEnabled: true, - key: '594032', - }, + experiment: configObj.experimentIdMap['594031'], + variation: configObj.variationIdMap['594032'], decisionSource: DECISION_SOURCES.ROLLOUT, }; + assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly( mockLogger.debug, @@ -1784,103 +1449,11 @@ describe('lib/core/decision_service', function() { }); var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; var expectedDecision = { - experiment: { - forcedVariations: {}, - status: 'Not started', - key: '594037', - id: '594037', - isRollout: true, - variations: [ - { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, - ], - audienceIds: [], - trafficAllocation: [{ endOfRange: 0, entityId: '594038' }], - layerId: '594030', - variationKeyMap: { - 594038: { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, - }, - }, - variation: { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, + experiment: configObj.experimentIdMap['594037'], + variation: configObj.variationIdMap['594038'], decisionSource: DECISION_SOURCES.ROLLOUT, - }; + }; + assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly( mockLogger.debug, @@ -1964,103 +1537,11 @@ describe('lib/core/decision_service', function() { }); var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; var expectedDecision = { - experiment: { - forcedVariations: {}, - status: 'Not started', - key: '594037', - id: '594037', - isRollout: true, - variations: [ - { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, - ], - audienceIds: [], - trafficAllocation: [{ endOfRange: 0, entityId: '594038' }], - layerId: '594030', - variationKeyMap: { - 594038: { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, - }, - }, - variation: { - id: '594038', - variables: [ - { - id: '4919852825313280', - value: 'false', - }, - { - id: '5482802778734592', - value: '400', - }, - { - id: '6045752732155904', - value: '14.99', - }, - { - id: '6327227708866560', - value: 'Hello', - }, - { - id: '8765345281230956', - value: '{ "count": 1, "message": "Hello" }', - }, - ], - featureEnabled: false, - key: '594038', - }, + experiment: configObj.experimentIdMap['594037'], + variation: configObj.variationIdMap['594038'], decisionSource: DECISION_SOURCES.ROLLOUT, }; + assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly( mockLogger.debug, @@ -2111,72 +1592,11 @@ describe('lib/core/decision_service', function() { }); var decision = decisionServiceInstance.getVariationForFeature(configObj, feature, user).result; var expectedDecision = { - experiment: { - trafficAllocation: [ - { - endOfRange: 10000, - entityId: '599057', - }, - ], - layerId: '599055', - forcedVariations: {}, - audienceIds: [], - isRollout: true, - variations: [ - { - key: '599057', - id: '599057', - featureEnabled: true, - variables: [ - { - id: '4937719889264640', - value: '200', - }, - { - id: '6345094772817920', - value: "i'm a rollout", - }, - ], - }, - ], - status: 'Not started', - key: '599056', - id: '599056', - variationKeyMap: { - 599057: { - key: '599057', - id: '599057', - featureEnabled: true, - variables: [ - { - id: '4937719889264640', - value: '200', - }, - { - id: '6345094772817920', - value: "i'm a rollout", - }, - ], - }, - }, - }, - variation: { - key: '599057', - id: '599057', - featureEnabled: true, - variables: [ - { - id: '4937719889264640', - value: '200', - }, - { - id: '6345094772817920', - value: "i'm a rollout", - }, - ], - }, + experiment: configObj.experimentIdMap['599056'], + variation: configObj.variationIdMap['599057'], decisionSource: DECISION_SOURCES.ROLLOUT, }; + assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly( mockLogger.debug, @@ -2324,29 +1744,7 @@ describe('lib/core/decision_service', function() { var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066'); var expectedDecision = { experiment: expectedExperiment, - variation: { - id: '594067', - key: '594067', - featureEnabled: true, - variables: [ - { - id: '5060590313668608', - value: '30.34' - }, - { - id: '5342065290379264', - value: 'Winter is coming definitely' - }, - { - id: '6186490220511232', - value: '500' - }, - { - id: '6467965197221888', - value: 'true' - }, - ], - }, + variation: configObj.variationIdMap['594067'], decisionSource: DECISION_SOURCES.ROLLOUT, } assert.deepEqual(decision, expectedDecision); @@ -2369,29 +1767,7 @@ describe('lib/core/decision_service', function() { var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger); var expectedDecision = { experiment: expectedExperiment, - variation: { - id: '594067', - key: '594067', - featureEnabled: true, - variables: [ - { - id: '5060590313668608', - value: '30.34' - }, - { - id: '5342065290379264', - value: 'Winter is coming definitely' - }, - { - id: '6186490220511232', - value: '500' - }, - { - id: '6467965197221888', - value: 'true' - }, - ], - }, + variation: configObj.variationIdMap['594067'], decisionSource: DECISION_SOURCES.ROLLOUT, } assert.deepEqual(decision, expectedDecision); @@ -2507,29 +1883,7 @@ describe('lib/core/decision_service', function() { var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066'); var expectedDecision = { experiment: expectedExperiment, - variation: { - id: '594067', - key: '594067', - featureEnabled: true, - variables: [ - { - id: '5060590313668608', - value: '30.34' - }, - { - id: '5342065290379264', - value: 'Winter is coming definitely' - }, - { - id: '6186490220511232', - value: '500' - }, - { - id: '6467965197221888', - value: 'true' - }, - ], - }, + variation: configObj.variationIdMap['594067'], decisionSource: DECISION_SOURCES.ROLLOUT, } assert.deepEqual(decision, expectedDecision); @@ -2552,29 +1906,7 @@ describe('lib/core/decision_service', function() { var expectedExperiment = projectConfig.getExperimentFromId(configObj, '594066', mockLogger); var expectedDecision = { experiment: expectedExperiment, - variation: { - id: '594067', - key: '594067', - featureEnabled: true, - variables: [ - { - id: '5060590313668608', - value: '30.34' - }, - { - id: '5342065290379264', - value: 'Winter is coming definitely' - }, - { - id: '6186490220511232', - value: '500' - }, - { - id: '6467965197221888', - value: 'true' - }, - ], - }, + variation: configObj.variationIdMap['594067'], decisionSource: DECISION_SOURCES.ROLLOUT, } assert.deepEqual(decision, expectedDecision); diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 21a63b763..386606cc9 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2017-2022, 2024, Optimizely + * Copyright 2017-2022, 2024-2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -110,7 +110,7 @@ export interface DecisionObj { } interface DecisionServiceOptions { - userProfileService: UserProfileService | null; + userProfileService?: UserProfileService; logger?: LoggerFacade; UNSTABLE_conditionEvaluators: unknown; } @@ -143,13 +143,13 @@ export class DecisionService { private logger?: LoggerFacade; private audienceEvaluator: AudienceEvaluator; private forcedVariationMap: { [key: string]: { [id: string]: string } }; - private userProfileService: UserProfileService | null; + private userProfileService?: UserProfileService; constructor(options: DecisionServiceOptions) { this.logger = options.logger; this.audienceEvaluator = createAudienceEvaluator(options.UNSTABLE_conditionEvaluators, this.logger); this.forcedVariationMap = {}; - this.userProfileService = options.userProfileService || null; + this.userProfileService = options.userProfileService; } /** @@ -170,11 +170,14 @@ export class DecisionService { ): DecisionResponse { const userId = user.getUserId(); const attributes = user.getAttributes(); + // by default, the bucketing ID should be the user ID const bucketingId = this.getBucketingId(userId, attributes); - const decideReasons: (string | number)[][] = []; const experimentKey = experiment.key; - if (!this.checkIfExperimentIsActive(configObj, experimentKey)) { + + const decideReasons: (string | number)[][] = []; + + if (!isActive(configObj, experimentKey)) { this.logger?.info(EXPERIMENT_NOT_RUNNING, experimentKey); decideReasons.push([EXPERIMENT_NOT_RUNNING, experimentKey]); return { @@ -182,6 +185,7 @@ export class DecisionService { reasons: decideReasons, }; } + const decisionForcedVariation = this.getForcedVariation(configObj, experimentKey, userId); decideReasons.push(...decisionForcedVariation.reasons); const forcedVariationKey = decisionForcedVariation.result; @@ -192,6 +196,7 @@ export class DecisionService { reasons: decideReasons, }; } + const decisionWhitelistedVariation = this.getWhitelistedVariation(experiment, userId); decideReasons.push(...decisionWhitelistedVariation.reasons); let variation = decisionWhitelistedVariation.result; @@ -202,7 +207,6 @@ export class DecisionService { }; } - // check for sticky bucketing if decide options do not include shouldIgnoreUPS if (!shouldIgnoreUPS) { variation = this.getStoredVariation(configObj, experiment, userId, userProfileTracker.userProfile); @@ -349,16 +353,6 @@ export class DecisionService { return { ...userProfile.experiment_bucket_map, ...attributeExperimentBucketMap as any }; } - /** - * Checks whether the experiment is running - * @param {ProjectConfig} configObj The parsed project configuration object - * @param {string} experimentKey Key of experiment being validated - * @return {boolean} True if experiment is running - */ - private checkIfExperimentIsActive(configObj: ProjectConfig, experimentKey: string): boolean { - return isActive(configObj, experimentKey); - } - /** * Checks if user is whitelisted into any variation and return that variation if so * @param {Experiment} experiment @@ -621,7 +615,7 @@ export class DecisionService { isProfileUpdated: false, userProfile: null, } - const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; + const shouldIgnoreUPS = !!options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; if(!shouldIgnoreUPS) { userProfileTracker.userProfile = this.resolveExperimentBucketMap(userId, attributes); @@ -661,10 +655,10 @@ export class DecisionService { } if(!shouldIgnoreUPS) { - this.saveUserProfile(userId, userProfileTracker) + this.saveUserProfile(userId, userProfileTracker); } - return decisions + return decisions; } @@ -968,7 +962,7 @@ export class DecisionService { * @param {string} experimentKey Key representing the experiment id * @throws If the user id is not valid or not in the forced variation map */ - removeForcedVariation(userId: string, experimentId: string, experimentKey: string): void { + private removeForcedVariation(userId: string, experimentId: string, experimentKey: string): void { if (!userId) { throw new OptimizelyError(INVALID_USER_ID); } @@ -1176,7 +1170,7 @@ export class DecisionService { } } - getVariationFromExperimentRule( + private getVariationFromExperimentRule( configObj: ProjectConfig, flagKey: string, rule: Experiment, @@ -1207,7 +1201,7 @@ export class DecisionService { }; } - getVariationFromDeliveryRule( + private getVariationFromDeliveryRule( configObj: ProjectConfig, flagKey: string, rules: Experiment[], diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index c4efb2c67..cd74a2d00 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -262,7 +262,7 @@ describe('lib/optimizely', function() { }); sinon.assert.calledWith(decisionService.createDecisionService, { - userProfileService: null, + userProfileService: undefined, logger: createdLogger, UNSTABLE_conditionEvaluators: undefined, }); diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 9ca0c6089..bf8e6c717 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2020-2024, Optimizely + * Copyright 2020-2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ import { NODE_CLIENT_ENGINE, CLIENT_VERSION, } from '../utils/enums'; -import { Fn } from '../utils/type'; +import { Fn, Maybe } from '../utils/type'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; import { NOTIFICATION_TYPES, DecisionNotificationType, DECISION_NOTIFICATION_TYPES } from '../notification_center/type'; @@ -211,8 +211,7 @@ export default class Optimizely extends BaseService implements Client { this.odpManager = config.odpManager; - - let userProfileService: UserProfileService | null = null; + let userProfileService: Maybe = undefined; if (config.userProfileService) { try { if (userProfileServiceValidator.validate(config.userProfileService)) { diff --git a/lib/tests/decision_test_datafile.ts b/lib/tests/decision_test_datafile.ts new file mode 100644 index 000000000..84c72de90 --- /dev/null +++ b/lib/tests/decision_test_datafile.ts @@ -0,0 +1,468 @@ +/** + * Copyright 2025, 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. + */ + +// flag id starts from 1000 +// experiment id starts from 2000 +// rollout experiment id starts from 3000 +// audience id starts from 4000 +// variation id starts from 5000 +// variable id starts from 6000 +// attribute id starts from 7000 + +const testDatafile = { + accountId: "24535200037", + projectId: "5088239376138240", + revision: "21", + attributes: [ + { + id: "7001", + key: "age" + } + ], + audiences: [ + { + name: "age_22", + conditions: "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + id: "4001" + }, + { + name: "age_60", + conditions: "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + id: "4002" + }, + { + name: "age_90", + conditions: "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + id: "4003" + }, + { + id: "$opt_dummy_audience", + name: "Optimizely-Generated Audience for Backwards Compatibility", + conditions: "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]" + } + ], + version: "4", + events: [], + integrations: [], + anonymizeIP: true, + botFiltering: false, + typedAudiences: [ + { + name: "age_22", + conditions: [ + "and", + [ + "or", + [ + "or", + { + "match": "le", + name: "age", + "type": "custom_attribute", + value: 22 + } + ] + ] + ], + id: "4001" + }, + { + name: "age_60", + conditions: [ + "and", + [ + "or", + [ + "or", + { + "match": "le", + name: "age", + "type": "custom_attribute", + value: 60 + } + ] + ] + ], + id: "4002" + }, + { + name: "age_90", + conditions: [ + "and", + [ + "or", + [ + "or", + { + "match": "le", + name: "age", + "type": "custom_attribute", + value: 90 + } + ] + ] + ], + id: "4003" + }, + ], + variables: [], + environmentKey: "production", + sdkKey: "sdk_key", + featureFlags: [ + { + id: "1001", + key: "flag_1", + rolloutId: "rollout-371334-671741182375276", + experimentIds: [ + "2001", + "2002", + "2003" + ], + variables: [ + { + id: "6001", + key: "integer_variable", + "type": "integer", + "defaultValue": "0" + } + ] + }, + { + id: "1002", + key: "flag_2", + "rolloutId": "rollout-374517-931741182375293", + experimentIds: [ + "2004" + ], + "variables": [] + } + ], + "rollouts": [ + { + id: "rollout-371334-671741182375276", + experiments: [ + { + id: "3001", + key: "delivery_1", + status: "Running", + layerId: "9300001480454", + variations: [ + { + id: "5004", + key: "variation_4", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "4" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5004", + endOfRange: 1500 + } + ], + forcedVariations: { + + }, + audienceIds: [ + "4001" + ], + audienceConditions: [ + "or", + "4001" + ] + }, + { + id: "3002", + key: "delivery_2", + status: "Running", + layerId: "9300001480455", + variations: [ + { + id: "5005", + key: "variation_5", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "5" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5005", + endOfRange: 4000 + } + ], + forcedVariations: { + + }, + audienceIds: [ + "4002" + ], + audienceConditions: [ + "or", + "4002" + ] + }, + { + id: "3003", + key: "delivery_3", + status: "Running", + layerId: "9300001495996", + variations: [ + { + id: "5006", + key: "variation_6", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "6" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5006", + endOfRange: 8000 + } + ], + forcedVariations: { + + }, + audienceIds: [ + "4003" + ], + audienceConditions: [ + "or", + "4003" + ] + }, + { + id: "default-rollout-id", + key: "default-rollout-key", + status: "Running", + layerId: "rollout-371334-671741182375276", + variations: [ + { + id: "5007", + key: "variation_7", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "7" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5007", + endOfRange: 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [], + audienceConditions: [] + }, + ] + }, + { + id: "rollout-374517-931741182375293", + experiments: [ + { + id: "default-rollout-374517-931741182375293", + key: "default-rollout-374517-931741182375293", + status: "Running", + layerId: "rollout-374517-931741182375293", + variations: [ + { + id: "1177722", + key: "off", + featureEnabled: false, + variables: [] + } + ], + trafficAllocation: [ + { + "entityId": "1177722", + "endOfRange": 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [], + audienceConditions: [] + } + ] + }, + ], + experiments: [ + { + id: "2001", + key: "exp_1", + status: "Running", + layerId: "9300001480444", + variations: [ + { + id: "5001", + key: "variation_1", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "1" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5001", + endOfRange: 5000 + }, + { + entityId: "5001", + endOfRange: 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [ + "4001" + ], + audienceConditions: [ + "or", + "4001" + ] + }, + { + id: "2002", + key: "exp_2", + status: "Running", + layerId: "9300001480448", + variations: [ + { + id: "5002", + key: "variation_2", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "2" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5002", + endOfRange: 5000 + }, + { + entityId: "5002", + endOfRange: 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [], + audienceConditions: [] + }, + { + id: "2003", + key: "exp_3", + status: "Running", + layerId: "9300001480451", + variations: [ + { + id: "5003", + key: "variation_3", + featureEnabled: true, + variables: [ + { + id: "6001", + value: "3" + } + ] + } + ], + trafficAllocation: [ + { + entityId: "5003", + endOfRange: 5000 + }, + { + entityId: "5003", + endOfRange: 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [], + audienceConditions: [] + }, + { + id: "2004", + key: "exp_4", + status: "Running", + layerId: "9300001497754", + variations: [ + { + id: "5100", + key: "variation_flag_2", + featureEnabled: true, + variables: [] + } + ], + trafficAllocation: [ + { + entityId: "5100", + endOfRange: 5000 + }, + { + entityId: "5100", + endOfRange: 10000 + } + ], + forcedVariations: { + + }, + audienceIds: [], + audienceConditions: [] + } + ], + groups: [] +} + +export const getDecisionTestDatafile = (): any => { + return JSON.parse(JSON.stringify(testDatafile)); +}