From 4cd128a26b29a12d5203641828e0c7356b6d7a72 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:39:58 +0600 Subject: [PATCH 1/7] [FSSDK-1119] decision test addition --- lib/core/decision/index.spec.ts | 128 ++++++++++++++++++++++++++++++++ lib/tests/test_data.ts | 6 ++ 2 files changed, 134 insertions(+) create mode 100644 lib/core/decision/index.spec.ts diff --git a/lib/core/decision/index.spec.ts b/lib/core/decision/index.spec.ts new file mode 100644 index 000000000..dc2fbe76a --- /dev/null +++ b/lib/core/decision/index.spec.ts @@ -0,0 +1,128 @@ +/** + * 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 } from 'vitest'; +import { rolloutDecisionObj, featureTestDecisionObj } from '../../tests/test_data'; +import * as decision from './'; + +describe('getExperimentKey method', () => { + it('should return empty string when experiment is null', () => { + const experimentKey = decision.getExperimentKey(rolloutDecisionObj); + + expect(experimentKey).toEqual(''); + }); + + it('should return empty string when experiment is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const experimentKey = decision.getExperimentKey({}); + + expect(experimentKey).toEqual(''); + }); + + it('should return experiment key when experiment is defined', () => { + const experimentKey = decision.getExperimentKey(featureTestDecisionObj); + + expect(experimentKey).toEqual('testing_my_feature'); + }); +}); + +describe('getExperimentId method', function() { + it('should return null when experiment is null', function() { + const experimentId = decision.getExperimentId(rolloutDecisionObj); + + expect(experimentId).toEqual(null); + }); + + it('should return null when experiment is not defined', function() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const experimentId = decision.getExperimentId({}); + + expect(experimentId).toEqual(null); + }); + + it('should return experiment id when experiment is defined', function() { + const experimentId = decision.getExperimentId(featureTestDecisionObj); + + expect(experimentId).toEqual('594098'); + }); + + describe('getVariationKey method', function() { + it('should return empty string when variation is null', function() { + const variationKey = decision.getVariationKey(rolloutDecisionObj); + + expect(variationKey).toEqual(''); + }); + + it('should return empty string when variation is not defined', function() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const variationKey = decision.getVariationKey({}); + + expect(variationKey).toEqual(''); + }); + + it('should return variation key when variation is defined', function() { + const variationKey = decision.getVariationKey(featureTestDecisionObj); + + expect(variationKey).toEqual('variation'); + }); + }); + + describe('getVariationId method', function() { + it('should return null when variation is null', function() { + const variationId = decision.getVariationId(rolloutDecisionObj); + + expect(variationId).toEqual(null); + }); + + it('should return null when variation is not defined', function() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const variationId = decision.getVariationId({}); + + expect(variationId).toEqual(null); + }); + + it('should return variation id when variation is defined', function() { + const variationId = decision.getVariationId(featureTestDecisionObj); + + expect(variationId).toEqual('594096'); + }); + }); + + describe('getFeatureEnabledFromVariation method', function() { + it('should return false when variation is null', function() { + const featureEnabled = decision.getFeatureEnabledFromVariation(rolloutDecisionObj); + + expect(featureEnabled).toEqual(false); + }); + + it('should return false when variation is not defined', function() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const featureEnabled = decision.getFeatureEnabledFromVariation({}); + + expect(featureEnabled).toEqual(false); + }); + + it('should return featureEnabled boolean when variation is defined', function() { + const featureEnabled = decision.getFeatureEnabledFromVariation(featureTestDecisionObj); + + expect(featureEnabled).toEqual(true); + }); + }); +}); diff --git a/lib/tests/test_data.ts b/lib/tests/test_data.ts index d792188fa..990096f7b 100644 --- a/lib/tests/test_data.ts +++ b/lib/tests/test_data.ts @@ -3573,12 +3573,14 @@ export var featureTestDecisionObj = { id: '594096', featureEnabled: true, variables: [], + variablesMap: {}, }, { key: 'control', id: '594097', featureEnabled: true, variables: [], + variablesMap: {} }, ], status: 'Running', @@ -3590,20 +3592,24 @@ export var featureTestDecisionObj = { id: '594096', featureEnabled: true, variables: [], + variablesMap: {} }, control: { key: 'control', id: '594097', featureEnabled: true, variables: [], + variablesMap: {} }, }, + audienceConditions: [] }, variation: { key: 'variation', id: '594096', featureEnabled: true, variables: [], + variablesMap: {} }, decisionSource: 'feature-test', }; From 15e27e25ccdfede3e9d06aceac3e64ab8bf13bfe Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:26:50 +0600 Subject: [PATCH 2/7] [FSSDK-1119] custom_attribute_condition_evaluator test js to ts --- .../index.spec.ts | 1446 +++++++++++++++++ 1 file changed, 1446 insertions(+) create mode 100644 lib/core/custom_attribute_condition_evaluator/index.spec.ts diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts new file mode 100644 index 000000000..130276137 --- /dev/null +++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts @@ -0,0 +1,1446 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as customAttributeEvaluator from './'; +import { MISSING_ATTRIBUTE_VALUE, UNEXPECTED_TYPE_NULL } from 'log_message'; +import { UNKNOWN_MATCH_TYPE, UNEXPECTED_TYPE, OUT_OF_BOUNDS, UNEXPECTED_CONDITION_VALUE } from 'error_message'; +import exp from 'constants'; +import { Condition } from '../../shared_types'; + +const browserConditionSafari = { + name: 'browser_type', + value: 'safari', + type: 'custom_attribute', +}; +const booleanCondition = { + name: 'is_firefox', + value: true, + type: 'custom_attribute', +}; +const integerCondition = { + name: 'num_users', + value: 10, + type: 'custom_attribute', +}; +const doubleCondition = { + name: 'pi_value', + value: 3.14, + type: 'custom_attribute', +}; + +const getMockUserContext: any = (attributes: any) => ({ + getAttributes: () => ({ ...(attributes || {}) }), +}); + +const createLogger = () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => createLogger(), +}); + +describe('custom_attribute_condition_evaluator', () => { + const mockLogger = createLogger(); + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true when the attributes pass the audience conditions and no match type is provided', () => { + const userAttributes = { + browser_type: 'safari', + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(true); + }); + + it('should return false when the attributes do not pass the audience conditions and no match type is provided', () => { + const userAttributes = { + browser_type: 'firefox', + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(false); + }); + + it('should evaluate different typed attributes', () => { + const userAttributes = { + browser_type: 'safari', + is_firefox: true, + num_users: 10, + pi_value: 3.14, + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(true); + expect(customAttributeEvaluator.getEvaluator().evaluate(booleanCondition, getMockUserContext(userAttributes))).toBe( + true + ); + expect(customAttributeEvaluator.getEvaluator().evaluate(integerCondition, getMockUserContext(userAttributes))).toBe( + true + ); + expect(customAttributeEvaluator.getEvaluator().evaluate(doubleCondition, getMockUserContext(userAttributes))).toBe( + true + ); + }); + + it('should log and return null when condition has an invalid match property', () => { + const invalidMatchCondition = { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidMatchCondition, getMockUserContext({ weird_condition: 'bye' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNKNOWN_MATCH_TYPE, JSON.stringify(invalidMatchCondition)); + }); +}); + +describe('exists match type', () => { + const existsCondition = { + match: 'exists', + name: 'input_value', + type: 'custom_attribute', + value: '', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(existsCondition, getMockUserContext({})); + + expect(result).toBe(false); + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('should return false if the user-provided value is undefined', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: undefined })); + + expect(result).toBe(false); + }); + + it('should return false if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: null })); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is a string', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: 'hi' })); + + expect(result).toBe(true); + }); + + it('should return true if the user-provided value is a number', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: 10 })); + + expect(result).toBe(true); + }); + + it('should return true if the user-provided value is a boolean', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: true })); + + expect(result).toBe(true); + }); +}); + +describe('exact match type - with a string condition value', () => { + const exactStringCondition = { + match: 'exact', + name: 'favorite_constellation', + type: 'custom_attribute', + value: 'Lacerta', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'Lacerta' })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'The Big Dipper' })); + + expect(result).toBe(false); + }); + + it('should log and return null if condition value is of an unexpected type', () => { + const invalidExactCondition = { + match: 'exact', + name: 'favorite_constellation', + type: 'custom_attribute', + value: null, + }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidExactCondition, getMockUserContext({ favorite_constellation: 'Lacerta' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidExactCondition)); + }); + + it('should log and return null if the user-provided value is of a different type than the condition value', () => { + const unexpectedTypeUserAttributes: Record = { favorite_constellation: false }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[exactStringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactStringCondition), + userValueType, + exactStringCondition.name + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(exactStringCondition), + exactStringCondition.name + ); + }); + + it('should log and return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext({})); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + MISSING_ATTRIBUTE_VALUE, + JSON.stringify(exactStringCondition), + exactStringCondition.name + ); + }); + + it('should log and return null if the user-provided value is of an unexpected type', () => { + const unexpectedTypeUserAttributes: Record = { favorite_constellation: [] }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[exactStringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactStringCondition), + userValueType, + exactStringCondition.name + ); + }); +}); + +describe('exact match type - with a number condition value', () => { + const exactNumberCondition = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: 9000, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 8000 })); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is of a different type than the condition value', () => { + const unexpectedTypeUserAttributes1: Record = { lasers_count: 'yes' }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBe(null); + + const unexpectedTypeUserAttributes2: Record = { lasers_count: '1000' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBe(null); + + const userValue1 = unexpectedTypeUserAttributes1[exactNumberCondition.name]; + const userValueType1 = typeof userValue1; + const userValue2 = unexpectedTypeUserAttributes2[exactNumberCondition.name]; + const userValueType2 = typeof userValue2; + + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactNumberCondition), + userValueType1, + exactNumberCondition.name + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactNumberCondition), + userValueType2, + exactNumberCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity })); + + expect(result).toBe(null); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Math.pow(2, 53) - 2 })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + OUT_OF_BOUNDS, + JSON.stringify(exactNumberCondition), + exactNumberCondition.name + ); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); + + it('should log and return null if the condition value is not finite', () => { + const invalidValueCondition1 = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: Infinity, + }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(null); + + const invalidValueCondition2 = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: Math.pow(2, 53) + 2, + }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition2, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition1)); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition2)); + }); +}); + +describe('exact match type - with a boolean condition value', () => { + const exactBoolCondition = { + match: 'exact', + name: 'did_register_user', + type: 'custom_attribute', + value: false, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: false })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: true })); + + expect(result).toBe(false); + }); + + it('should return null if the user-provided value is of a different type than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: 10 })); + + expect(result).toBe(null); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('substring match type', () => { + const mockLogger = createLogger(); + const substringCondition = { + match: 'substring', + name: 'headline_text', + type: 'custom_attribute', + value: 'buy now', + }; + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the condition value is a substring of the user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + substringCondition, + getMockUserContext({ + headline_text: 'Limited time, buy now!', + }) + ); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not a substring of the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + substringCondition, + getMockUserContext({ + headline_text: 'Breaking news!', + }) + ); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a string', () => { + const unexpectedTypeUserAttributes: Record = { headline_text: 10 }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[substringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(substringCondition), + userValueType, + substringCondition.name + ); + }); + + it('should log and return null if the condition value is not a string', () => { + const nonStringCondition = { + match: 'substring', + name: 'headline_text', + type: 'custom_attribute', + value: 10, + }; + + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(nonStringCondition)); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext({ headline_text: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(substringCondition), + substringCondition.name + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('greater than match type', () => { + const gtCondition = { + match: 'gt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is greater than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: 58.4 })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not greater than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: 20 })); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a number', () => { + const unexpectedTypeUserAttributes1 = { meters_travelled: 'a long way' }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBeNull(); + + const unexpectedTypeUserAttributes2 = { meters_travelled: '1000' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(gtCondition), + 'string', + gtCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: -Infinity })); + + expect(result).toBeNull(); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(gtCondition), gtCondition.name); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: null })); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(gtCondition), gtCondition.name); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({})); + + expect(result).toBeNull(); + }); + + it('should return null if the condition value is not a finite number', () => { + const userAttributes = { meters_travelled: 58.4 }; + const invalidValueCondition: Condition = { + match: 'gt', + name: 'meters_travelled', + type: 'custom_attribute', + value: Infinity, + }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = null; + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = Math.pow(2, 53) + 2; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)); + }); +}); + +describe('less than match type', () => { + const ltCondition = { + match: 'lt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + ltCondition, + getMockUserContext({ + meters_travelled: 10, + }) + ); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + ltCondition, + getMockUserContext({ + meters_travelled: 64.64, + }) + ); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a number', () => { + const unexpectedTypeUserAttributes1: Record = { meters_travelled: true }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBeNull(); + + const unexpectedTypeUserAttributes2: Record = { meters_travelled: '48.2' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBeNull(); + + const userValue1 = unexpectedTypeUserAttributes1[ltCondition.name]; + const userValueType1 = typeof userValue1; + const userValue2 = unexpectedTypeUserAttributes2[ltCondition.name]; + const userValueType2 = typeof userValue2; + + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(ltCondition), + userValueType1, + ltCondition.name + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(ltCondition), + userValueType2, + ltCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: Infinity })); + + expect(result).toBeNull(); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: null })); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(ltCondition), ltCondition.name); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({})); + + expect(result).toBeNull(); + }); + + it('should return null if the condition value is not a finite number', () => { + const userAttributes = { meters_travelled: 10 }; + const invalidValueCondition: Condition = { + match: 'lt', + name: 'meters_travelled', + type: 'custom_attribute', + value: Infinity, + }; + + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = null; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = Math.pow(2, 53) + 2; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)); + }); +}); + +describe('less than or equal match type', () => { + const leCondition = { + match: 'le', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided value is greater than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + leCondition, + getMockUserContext({ + meters_travelled: 48.3, + }) + ); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', () => { + const versions = [48, 48.2]; + for (const userValue of versions) { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + leCondition, + getMockUserContext({ + meters_travelled: userValue, + }) + ); + + expect(result).toBe(true); + } + }); +}); + +describe('greater than and equal to match type', () => { + const geCondition = { + match: 'ge', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided value is less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + geCondition, + getMockUserContext({ + meters_travelled: 48, + }) + ); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', () => { + const versions = [100, 48.2]; + versions.forEach(userValue => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + geCondition, + getMockUserContext({ + meters_travelled: userValue, + }) + ); + + expect(result).toBe(true); + }); + }); +}); + +describe('semver greater than match type', () => { + const semvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided version is greater than the condition version', () => { + const versions = [['1.8.1', '1.9']]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvergtCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should return false if the user-provided version is not greater than the condition version', function() { + const versions = [ + ['2.0.1', '2.0.1'], + ['2.0', '2.0.0'], + ['2.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvergtCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvergtCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvergtCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvergtCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvergtCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvergtCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semvergtCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvergtCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('semver less than match type', () => { + const semverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['1.9', '2.0.0'], + ['2.0.0', '2.0.0'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemverltCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is less than the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['2.0.0', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemverltCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverltCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverltCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semverltCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semverltCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semverltCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semverltCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semverltCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); +describe('semver equal to match type', () => { + const semvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: '2.0', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is equal to the condition version', () => { + const versions = [ + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvereqCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvereqCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvereqCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvereqCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvereqCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semvereqCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvereqCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('semver less than or equal to match type', () => { + const semverleCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [['2.0.0', '2.0.1']]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is less than or equal to the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should return true if the user-provided version is equal to the condition version', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverleCondition, + getMockUserContext({ + app_version: '2.0', + }) + ); + + expect(result).toBe(true); + }); +}); + +describe('semver greater than or equal to match type', () => { + const semvergeCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: '2.0', + }; + const mockLogger = createLogger(); + + beforeEach(() => { + vi.spyOn(mockLogger, 'error'); + vi.spyOn(mockLogger, 'debug'); + vi.spyOn(mockLogger, 'info'); + vi.spyOn(mockLogger, 'warn'); + }) + + afterEach(() => { + vi.restoreAllMocks(); + }) + + it('should return true if the user-provided version is greater than or equal to the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }) + }); + + it('should return false if the user-provided version is less than the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }) + }); +}); From 22770603a09623e5d766bf8d8ba189a5285061ca Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:29:32 +0600 Subject: [PATCH 3/7] [FSSDK-1119] custom_attribute_condition_evaluator copyright test addition --- .../index.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts index 130276137..cb0ab3363 100644 --- a/lib/core/custom_attribute_condition_evaluator/index.spec.ts +++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts @@ -1,3 +1,18 @@ +/** + * 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, beforeEach, afterEach } from 'vitest'; import * as customAttributeEvaluator from './'; import { MISSING_ATTRIBUTE_VALUE, UNEXPECTED_TYPE_NULL } from 'log_message'; From ac1c4d7d2bd24c1072fd84743ec38ecbfb6aa147 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:31:04 +0600 Subject: [PATCH 4/7] [FSSDK-11119] copyright year fix --- lib/core/custom_attribute_condition_evaluator/index.spec.ts | 2 +- lib/core/decision/index.spec.ts | 2 +- lib/project_config/optimizely_config.spec.ts | 2 +- lib/project_config/project_config.spec.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts index cb0ab3363..c5e9473a2 100644 --- a/lib/core/custom_attribute_condition_evaluator/index.spec.ts +++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * 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. diff --git a/lib/core/decision/index.spec.ts b/lib/core/decision/index.spec.ts index dc2fbe76a..d5ea27ac0 100644 --- a/lib/core/decision/index.spec.ts +++ b/lib/core/decision/index.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * 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. diff --git a/lib/project_config/optimizely_config.spec.ts b/lib/project_config/optimizely_config.spec.ts index 3e7288a8e..ab8d3ab5d 100644 --- a/lib/project_config/optimizely_config.spec.ts +++ b/lib/project_config/optimizely_config.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * 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. diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index 2ab002bca..a955b725a 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * 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. From 716565e71a4474925c8c8870151da41ef0d04c0a Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 17 Feb 2025 19:45:03 +0600 Subject: [PATCH 5/7] [FSSDK-11119] test addition + adjustment --- lib/core/bucketer/index.spec.ts | 398 ++++++++++++++++++ .../index.spec.ts | 136 ++---- lib/core/decision/index.spec.ts | 32 +- 3 files changed, 457 insertions(+), 109 deletions(-) create mode 100644 lib/core/bucketer/index.spec.ts diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts new file mode 100644 index 000000000..a538bd9c7 --- /dev/null +++ b/lib/core/bucketer/index.spec.ts @@ -0,0 +1,398 @@ +/** + * 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 + * + * 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, afterEach } from 'vitest'; +import { sprintf } from '../../utils/fns'; +import projectConfig, { ProjectConfig } from '../../project_config/project_config'; +import { getTestProjectConfig } from '../../tests/test_data'; +import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message'; +import * as bucketer from './'; +import { + USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + USER_NOT_IN_ANY_EXPERIMENT, + USER_ASSIGNED_TO_EXPERIMENT_BUCKET, +} from '.'; +import { BucketerParams } from '../../shared_types'; +import { OptimizelyError } from '../../error/optimizly_error'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { LoggerFacade } from '../../logging/logger'; + +const testData = getTestProjectConfig(); + +function cloneDeep(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return (value.map(cloneDeep) as unknown) as T; + } + + const copy: Record = {}; + + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + copy[key] = cloneDeep((value as Record)[key]); + } + } + + return copy as T; +} + +const setLogSpy = (logger: LoggerFacade) => { + vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'debug'); + vi.spyOn(logger, 'warn'); + vi.spyOn(logger, 'error'); +}; + +describe('excluding groups', () => { + let configObj; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: configObj.experiments[0].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with correct variation ID when provided bucket value', async () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBe('111128'); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid1'); + + const bucketerParamsTest2 = cloneDeep(bucketerParams); + bucketerParamsTest2.userId = 'ppid2'; + bucketerParamsTest2.bucketingId = 'test_3166_1739796928766'; + const decisionResponse2 = bucketer.bucket(bucketerParamsTest2); + + expect(decisionResponse2.result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid2'); + }); +}); + +describe('including groups: random', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[4].id, + experimentKey: configObj.experiments[4].key, + trafficAllocationConfig: configObj.experiments[4].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + userId: 'testUser', + bucketingId: 'test_303_1739432593254', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with the proper variation for a user in a grouped experiment', () => { + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe('551'); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledTimes(2); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith( + USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + 'testUser', + 'groupExperiment1', + '666' + ); + }); + + it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => { + bucketerParams.bucketingId = '123456789'; + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith( + USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + 'testUser', + 'groupExperiment1', + '666' + ); + }); + + it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => { + bucketerParams.bucketingId = 'test_1228_1739468735344'; + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666'); + }); + + it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => { + bucketerParams.bucketingId = 'test_1228_1739468735344'; + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666'); + }); + + it('should throw an error if group ID is not in the datafile', () => { + const bucketerParamsWithInvalidGroupId = cloneDeep(bucketerParams); + bucketerParamsWithInvalidGroupId.experimentIdMap[configObj.experiments[4].id].groupId = '6969'; + + expect(() => bucketer.bucket(bucketerParamsWithInvalidGroupId)).toThrowError( + new OptimizelyError(INVALID_GROUP_ID, '6969') + ); + }); +}); + +describe('including groups: overlapping', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[6].id, + experimentKey: configObj.experiments[6].key, + trafficAllocationConfig: configObj.experiments[6].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + userId: 'testUser', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => { + bucketerParams.bucketingId = 'test_4283_1739793857480'; + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe('553'); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + }); + + it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => { + bucketerParams.bucketingId = 'test_9318_1739793997430'; + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe(null); + }); +}); + +describe('bucket value falls into empty traffic allocation ranges', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: [ + { + entityId: '', + endOfRange: 5000, + }, + { + entityId: '', + endOfRange: 10000, + }, + ], + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation null', () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBe(null); + }); + + it('should not log an invalid variation ID warning', () => { + bucketer.bucket(bucketerParams); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('traffic allocation has invalid variation ids', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: [ + { + entityId: '-1', + endOfRange: 5000, + }, + { + entityId: '-2', + endOfRange: 10000, + }, + ], + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation null', () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBe(null); + }); +}); + +describe('_generateBucketValue', () => { + it('should return a bucket value for different inputs', () => { + const experimentId = 1886780721; + const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); + const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId); + const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); + const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); + + expect(bucketer._generateBucketValue(bucketingKey1)).toBe(5254); + expect(bucketer._generateBucketValue(bucketingKey2)).toBe(4299); + expect(bucketer._generateBucketValue(bucketingKey3)).toBe(2434); + expect(bucketer._generateBucketValue(bucketingKey4)).toBe(5439); + }); + + it('should return an error if it cannot generate the hash value', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => bucketer._generateBucketValue(null)).toThrowError(new OptimizelyError(INVALID_BUCKETING_ID)); + }); +}); + +describe('testBucketWithBucketingId', () => { + let bucketerParams: BucketerParams; + + beforeEach(() => { + const configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + trafficAllocationConfig: configObj.experiments[0].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + }; + }); + + it('check that a non null bucketingId buckets a variation different than the one expected with userId', () => { + const bucketerParams1 = cloneDeep(bucketerParams); + bucketerParams1['userId'] = 'testBucketingIdControl'; + bucketerParams1['bucketingId'] = '123456789'; + bucketerParams1['experimentKey'] = 'testExperiment'; + bucketerParams1['experimentId'] = '111127'; + + expect(bucketer.bucket(bucketerParams1).result).toBe('111129'); + }); + + it('check that a null bucketing ID defaults to bucketing with the userId', () => { + const bucketerParams2 = cloneDeep(bucketerParams); + bucketerParams2['userId'] = 'testBucketingIdControl'; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams2['bucketingId'] = null; + bucketerParams2['experimentKey'] = 'testExperiment'; + bucketerParams2['experimentId'] = '111127'; + + expect(bucketer.bucket(bucketerParams2).result).toBe('111128'); + }); + + it('check that bucketing works with an experiment in group', () => { + const bucketerParams4 = cloneDeep(bucketerParams); + bucketerParams4['userId'] = 'testBucketingIdControl'; + bucketerParams4['bucketingId'] = '123456789'; + bucketerParams4['experimentKey'] = 'groupExperiment2'; + bucketerParams4['experimentId'] = '443'; + + expect(bucketer.bucket(bucketerParams4).result).toBe('111128'); + }); +}); diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts index c5e9473a2..66f8cae0d 100644 --- a/lib/core/custom_attribute_condition_evaluator/index.spec.ts +++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts @@ -17,8 +17,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as customAttributeEvaluator from './'; import { MISSING_ATTRIBUTE_VALUE, UNEXPECTED_TYPE_NULL } from 'log_message'; import { UNKNOWN_MATCH_TYPE, UNEXPECTED_TYPE, OUT_OF_BOUNDS, UNEXPECTED_CONDITION_VALUE } from 'error_message'; -import exp from 'constants'; import { Condition } from '../../shared_types'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { LoggerFacade } from '../../logging/logger'; const browserConditionSafari = { name: 'browser_type', @@ -45,21 +46,18 @@ const getMockUserContext: any = (attributes: any) => ({ getAttributes: () => ({ ...(attributes || {}) }), }); -const createLogger = () => ({ - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => createLogger(), -}); +const setLogSpy = (logger: LoggerFacade) => { + vi.spyOn(logger, 'error'); + vi.spyOn(logger, 'debug'); + vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'warn'); +}; describe('custom_attribute_condition_evaluator', () => { - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); + beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -127,13 +125,10 @@ describe('exists match type', () => { type: 'custom_attribute', value: '', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -198,13 +193,10 @@ describe('exact match type - with a string condition value', () => { type: 'custom_attribute', value: 'Lacerta', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -315,13 +307,10 @@ describe('exact match type - with a number condition value', () => { type: 'custom_attribute', value: 9000, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -444,13 +433,10 @@ describe('exact match type - with a boolean condition value', () => { type: 'custom_attribute', value: false, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -491,7 +477,7 @@ describe('exact match type - with a boolean condition value', () => { }); describe('substring match type', () => { - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); const substringCondition = { match: 'substring', name: 'headline_text', @@ -500,10 +486,7 @@ describe('substring match type', () => { }; beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -597,13 +580,10 @@ describe('greater than match type', () => { type: 'custom_attribute', value: 48.2, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -721,13 +701,10 @@ describe('less than match type', () => { type: 'custom_attribute', value: 48.2, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -864,13 +841,10 @@ describe('less than or equal match type', () => { type: 'custom_attribute', value: 48.2, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -910,13 +884,10 @@ describe('greater than and equal to match type', () => { type: 'custom_attribute', value: 48.2, }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -956,13 +927,10 @@ describe('semver greater than match type', () => { type: 'custom_attribute', value: '2.0.0', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -1077,13 +1045,10 @@ describe('semver less than match type', () => { type: 'custom_attribute', value: '2.0.0', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -1199,13 +1164,10 @@ describe('semver equal to match type', () => { type: 'custom_attribute', value: '2.0', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -1322,13 +1284,10 @@ describe('semver less than or equal to match type', () => { type: 'custom_attribute', value: '2.0.0', }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); + setLogSpy(mockLogger); }); afterEach(() => { @@ -1393,24 +1352,15 @@ describe('semver less than or equal to match type', () => { }); describe('semver greater than or equal to match type', () => { - const semvergeCondition = { - match: 'semver_ge', - name: 'app_version', - type: 'custom_attribute', - value: '2.0', - }; - const mockLogger = createLogger(); + const mockLogger = getMockLogger(); beforeEach(() => { - vi.spyOn(mockLogger, 'error'); - vi.spyOn(mockLogger, 'debug'); - vi.spyOn(mockLogger, 'info'); - vi.spyOn(mockLogger, 'warn'); - }) + setLogSpy(mockLogger); + }); afterEach(() => { vi.restoreAllMocks(); - }) + }); it('should return true if the user-provided version is greater than or equal to the condition version', () => { const versions = [ @@ -1433,7 +1383,7 @@ describe('semver greater than or equal to match type', () => { ); expect(result).toBe(true); - }) + }); }); it('should return false if the user-provided version is less than the condition version', () => { @@ -1456,6 +1406,6 @@ describe('semver greater than or equal to match type', () => { ); expect(result).toBe(false); - }) + }); }); }); diff --git a/lib/core/decision/index.spec.ts b/lib/core/decision/index.spec.ts index d5ea27ac0..ea98fba39 100644 --- a/lib/core/decision/index.spec.ts +++ b/lib/core/decision/index.spec.ts @@ -39,14 +39,14 @@ describe('getExperimentKey method', () => { }); }); -describe('getExperimentId method', function() { - it('should return null when experiment is null', function() { +describe('getExperimentId method', () => { + it('should return null when experiment is null', () => { const experimentId = decision.getExperimentId(rolloutDecisionObj); expect(experimentId).toEqual(null); }); - it('should return null when experiment is not defined', function() { + it('should return null when experiment is not defined', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const experimentId = decision.getExperimentId({}); @@ -54,20 +54,20 @@ describe('getExperimentId method', function() { expect(experimentId).toEqual(null); }); - it('should return experiment id when experiment is defined', function() { + it('should return experiment id when experiment is defined', () => { const experimentId = decision.getExperimentId(featureTestDecisionObj); expect(experimentId).toEqual('594098'); }); - describe('getVariationKey method', function() { - it('should return empty string when variation is null', function() { + describe('getVariationKey method', ()=> { + it('should return empty string when variation is null', () => { const variationKey = decision.getVariationKey(rolloutDecisionObj); expect(variationKey).toEqual(''); }); - it('should return empty string when variation is not defined', function() { + it('should return empty string when variation is not defined', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const variationKey = decision.getVariationKey({}); @@ -75,21 +75,21 @@ describe('getExperimentId method', function() { expect(variationKey).toEqual(''); }); - it('should return variation key when variation is defined', function() { + it('should return variation key when variation is defined', () => { const variationKey = decision.getVariationKey(featureTestDecisionObj); expect(variationKey).toEqual('variation'); }); }); - describe('getVariationId method', function() { - it('should return null when variation is null', function() { + describe('getVariationId method', () => { + it('should return null when variation is null', () => { const variationId = decision.getVariationId(rolloutDecisionObj); expect(variationId).toEqual(null); }); - it('should return null when variation is not defined', function() { + it('should return null when variation is not defined', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const variationId = decision.getVariationId({}); @@ -97,21 +97,21 @@ describe('getExperimentId method', function() { expect(variationId).toEqual(null); }); - it('should return variation id when variation is defined', function() { + it('should return variation id when variation is defined', () => { const variationId = decision.getVariationId(featureTestDecisionObj); expect(variationId).toEqual('594096'); }); }); - describe('getFeatureEnabledFromVariation method', function() { - it('should return false when variation is null', function() { + describe('getFeatureEnabledFromVariation method', () => { + it('should return false when variation is null', () => { const featureEnabled = decision.getFeatureEnabledFromVariation(rolloutDecisionObj); expect(featureEnabled).toEqual(false); }); - it('should return false when variation is not defined', function() { + it('should return false when variation is not defined', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const featureEnabled = decision.getFeatureEnabledFromVariation({}); @@ -119,7 +119,7 @@ describe('getExperimentId method', function() { expect(featureEnabled).toEqual(false); }); - it('should return featureEnabled boolean when variation is defined', function() { + it('should return featureEnabled boolean when variation is defined', () => { const featureEnabled = decision.getFeatureEnabledFromVariation(featureTestDecisionObj); expect(featureEnabled).toEqual(true); From f11bb04535b6c09668426d7f36f1bc5c10e534a6 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:16:44 +0600 Subject: [PATCH 6/7] [FSSDK-11119] bucketer adjustment --- lib/core/bucketer/bucket_value_generator.ts | 40 +++++++++++++++++ lib/core/bucketer/index.spec.ts | 50 +++++++++++++-------- lib/core/bucketer/index.tests.js | 20 ++++----- lib/core/bucketer/index.ts | 28 +----------- lib/core/decision_service/index.tests.js | 5 ++- 5 files changed, 87 insertions(+), 56 deletions(-) create mode 100644 lib/core/bucketer/bucket_value_generator.ts diff --git a/lib/core/bucketer/bucket_value_generator.ts b/lib/core/bucketer/bucket_value_generator.ts new file mode 100644 index 000000000..5581710c4 --- /dev/null +++ b/lib/core/bucketer/bucket_value_generator.ts @@ -0,0 +1,40 @@ +/** + * 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 + * + * 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 murmurhash from 'murmurhash'; +import { INVALID_BUCKETING_ID } from 'error_message'; +import { OptimizelyError } from '../../error/optimizly_error'; + +const HASH_SEED = 1; +const MAX_HASH_VALUE = Math.pow(2, 32); +const MAX_TRAFFIC_VALUE = 10000; + +/** + * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE) + * @param {string} bucketingKey String value for bucketing + * @return {number} The generated bucket value + * @throws If bucketing value is not a valid string + */ +export const _generateBucketValue = function(bucketingKey: string): number { + try { + // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int + // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115 + const hashValue = murmurhash.v3(bucketingKey, HASH_SEED); + const ratio = hashValue / MAX_HASH_VALUE; + return Math.floor(ratio * MAX_TRAFFIC_VALUE); + } catch (ex) { + throw new OptimizelyError(INVALID_BUCKETING_ID, bucketingKey, ex.message); + } +}; diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts index a538bd9c7..040afae18 100644 --- a/lib/core/bucketer/index.spec.ts +++ b/lib/core/bucketer/index.spec.ts @@ -19,6 +19,8 @@ import projectConfig, { ProjectConfig } from '../../project_config/project_confi import { getTestProjectConfig } from '../../tests/test_data'; import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message'; import * as bucketer from './'; +import * as bucketValueGenerator from './bucket_value_generator'; + import { USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, @@ -79,6 +81,10 @@ describe('excluding groups', () => { groupIdMap: configObj.groupIdMap, logger: mockLogger, }; + + vi.spyOn(bucketValueGenerator, '_generateBucketValue') + .mockReturnValueOnce(50) + .mockReturnValueOnce(50000); }); afterEach(() => { @@ -95,10 +101,9 @@ describe('excluding groups', () => { const bucketerParamsTest2 = cloneDeep(bucketerParams); bucketerParamsTest2.userId = 'ppid2'; - bucketerParamsTest2.bucketingId = 'test_3166_1739796928766'; const decisionResponse2 = bucketer.bucket(bucketerParamsTest2); - expect(decisionResponse2.result).toBe(null); + expect(decisionResponse2.result).toBeNull(); expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid2'); }); }); @@ -122,7 +127,6 @@ describe('including groups: random', () => { groupIdMap: configObj.groupIdMap, logger: mockLogger, userId: 'testUser', - bucketingId: 'test_303_1739432593254', }; }); @@ -131,6 +135,10 @@ describe('including groups: random', () => { }); it('should return decision response with the proper variation for a user in a grouped experiment', () => { + vi.spyOn(bucketValueGenerator, '_generateBucketValue') + .mockReturnValueOnce(50) + .mockReturnValueOnce(50); + const decisionResponse = bucketer.bucket(bucketerParams); expect(decisionResponse.result).toBe('551'); @@ -146,7 +154,8 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => { - bucketerParams.bucketingId = '123456789'; + vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValue(5000); + const decisionResponse = bucketer.bucket(bucketerParams); expect(decisionResponse.result).toBeNull(); @@ -162,10 +171,11 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => { - bucketerParams.bucketingId = 'test_1228_1739468735344'; + vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValue(50000); + const decisionResponse = bucketer.bucket(bucketerParams); - expect(decisionResponse.result).toBe(null); + expect(decisionResponse.result).toBeNull(); expect(mockLogger.debug).toHaveBeenCalledTimes(1); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); @@ -173,10 +183,11 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => { - bucketerParams.bucketingId = 'test_1228_1739468735344'; + vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(9000); + const decisionResponse = bucketer.bucket(bucketerParams); - expect(decisionResponse.result).toBe(null); + expect(decisionResponse.result).toBeNull(); expect(mockLogger.debug).toHaveBeenCalledTimes(1); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); @@ -220,7 +231,8 @@ describe('including groups: overlapping', () => { }); it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => { - bucketerParams.bucketingId = 'test_4283_1739793857480'; + vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(0); + const decisionResponse = bucketer.bucket(bucketerParams); expect(decisionResponse.result).toBe('553'); @@ -229,10 +241,10 @@ describe('including groups: overlapping', () => { }); it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => { - bucketerParams.bucketingId = 'test_9318_1739793997430'; + vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(3000); const decisionResponse = bucketer.bucket(bucketerParams); - expect(decisionResponse.result).toBe(null); + expect(decisionResponse.result).toBeNull(); }); }); @@ -275,7 +287,7 @@ describe('bucket value falls into empty traffic allocation ranges', () => { bucketerParamsTest1.userId = 'ppid1'; const decisionResponse = bucketer.bucket(bucketerParamsTest1); - expect(decisionResponse.result).toBe(null); + expect(decisionResponse.result).toBeNull(); }); it('should not log an invalid variation ID warning', () => { @@ -324,7 +336,7 @@ describe('traffic allocation has invalid variation ids', () => { bucketerParamsTest1.userId = 'ppid1'; const decisionResponse = bucketer.bucket(bucketerParamsTest1); - expect(decisionResponse.result).toBe(null); + expect(decisionResponse.result).toBeNull(); }); }); @@ -336,16 +348,18 @@ describe('_generateBucketValue', () => { const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); - expect(bucketer._generateBucketValue(bucketingKey1)).toBe(5254); - expect(bucketer._generateBucketValue(bucketingKey2)).toBe(4299); - expect(bucketer._generateBucketValue(bucketingKey3)).toBe(2434); - expect(bucketer._generateBucketValue(bucketingKey4)).toBe(5439); + expect(bucketValueGenerator._generateBucketValue(bucketingKey1)).toBe(5254); + expect(bucketValueGenerator._generateBucketValue(bucketingKey2)).toBe(4299); + expect(bucketValueGenerator._generateBucketValue(bucketingKey3)).toBe(2434); + expect(bucketValueGenerator._generateBucketValue(bucketingKey4)).toBe(5439); }); it('should return an error if it cannot generate the hash value', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - expect(() => bucketer._generateBucketValue(null)).toThrowError(new OptimizelyError(INVALID_BUCKETING_ID)); + expect(() => bucketValueGenerator._generateBucketValue(null)).toThrowError( + new OptimizelyError(INVALID_BUCKETING_ID) + ); }); }); diff --git a/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js index 023431af7..d2532266b 100644 --- a/lib/core/bucketer/index.tests.js +++ b/lib/core/bucketer/index.tests.js @@ -17,7 +17,7 @@ import sinon from 'sinon'; import { assert, expect } from 'chai'; import { cloneDeep, create } from 'lodash'; import { sprintf } from '../../utils/fns'; - +import * as bucketValueGenerator from './bucket_value_generator' import * as bucketer from './'; import { LOG_LEVEL } from '../../utils/enums'; import projectConfig from '../../project_config/project_config'; @@ -76,7 +76,7 @@ describe('lib/core/bucketer', function () { logger: createdLogger, }; sinon - .stub(bucketer, '_generateBucketValue') + .stub(bucketValueGenerator, '_generateBucketValue') .onFirstCall() .returns(50) .onSecondCall() @@ -84,7 +84,7 @@ describe('lib/core/bucketer', function () { }); afterEach(function () { - bucketer._generateBucketValue.restore(); + bucketValueGenerator._generateBucketValue.restore(); }); it('should return decision response with correct variation ID when provided bucket value', function () { @@ -116,11 +116,11 @@ describe('lib/core/bucketer', function () { groupIdMap: configObj.groupIdMap, logger: createdLogger, }; - bucketerStub = sinon.stub(bucketer, '_generateBucketValue'); + bucketerStub = sinon.stub(bucketValueGenerator, '_generateBucketValue'); }); afterEach(function () { - bucketer._generateBucketValue.restore(); + bucketValueGenerator._generateBucketValue.restore(); }); describe('random groups', function () { @@ -336,15 +336,15 @@ describe('lib/core/bucketer', function () { var bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); var bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); - expect(bucketer._generateBucketValue(bucketingKey1)).to.equal(5254); - expect(bucketer._generateBucketValue(bucketingKey2)).to.equal(4299); - expect(bucketer._generateBucketValue(bucketingKey3)).to.equal(2434); - expect(bucketer._generateBucketValue(bucketingKey4)).to.equal(5439); + expect(bucketValueGenerator._generateBucketValue(bucketingKey1)).to.equal(5254); + expect(bucketValueGenerator._generateBucketValue(bucketingKey2)).to.equal(4299); + expect(bucketValueGenerator._generateBucketValue(bucketingKey3)).to.equal(2434); + expect(bucketValueGenerator._generateBucketValue(bucketingKey4)).to.equal(5439); }); it('should return an error if it cannot generate the hash value', function() { const response = assert.throws(function() { - bucketer._generateBucketValue(null); + bucketValueGenerator._generateBucketValue(null); } ); expect(response.baseMessage).to.equal(INVALID_BUCKETING_ID); }); diff --git a/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts index d965e0217..f2aaed510 100644 --- a/lib/core/bucketer/index.ts +++ b/lib/core/bucketer/index.ts @@ -17,7 +17,6 @@ /** * Bucketer API for determining the variation id from the specified parameters */ -import murmurhash from 'murmurhash'; import { LoggerFacade } from '../../logging/logger'; import { DecisionResponse, @@ -25,19 +24,15 @@ import { TrafficAllocation, Group, } from '../../shared_types'; - -import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message'; +import { INVALID_GROUP_ID } from 'error_message'; import { OptimizelyError } from '../../error/optimizly_error'; +import { _generateBucketValue } from './bucket_value_generator'; export const USER_NOT_IN_ANY_EXPERIMENT = 'User %s is not in any experiment of group %s.'; export const USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is not in experiment %s of group %s.'; export const USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is in experiment %s of group %s.'; export const USER_ASSIGNED_TO_EXPERIMENT_BUCKET = 'Assigned bucket %s to user with bucketing ID %s.'; export const INVALID_VARIATION_ID = 'Bucketed into an invalid variation ID. Returning null.'; - -const HASH_SEED = 1; -const MAX_HASH_VALUE = Math.pow(2, 32); -const MAX_TRAFFIC_VALUE = 10000; const RANDOM_POLICY = 'random'; /** @@ -208,26 +203,7 @@ export const _findBucket = function( return null; }; -/** - * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE) - * @param {string} bucketingKey String value for bucketing - * @return {number} The generated bucket value - * @throws If bucketing value is not a valid string - */ -export const _generateBucketValue = function(bucketingKey: string): number { - try { - // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int - // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115 - const hashValue = murmurhash.v3(bucketingKey, HASH_SEED); - const ratio = hashValue / MAX_HASH_VALUE; - return Math.floor(ratio * MAX_TRAFFIC_VALUE); - } catch (ex: any) { - throw new OptimizelyError(INVALID_BUCKETING_ID, bucketingKey, ex.message); - } -}; - export default { bucket: bucket, bucketUserIntoExperiment: bucketUserIntoExperiment, - _generateBucketValue: _generateBucketValue, }; diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index 431b95efa..215c99f44 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -20,6 +20,7 @@ import { sprintf } from '../../utils/fns'; import { createDecisionService } from './'; import * as bucketer from '../bucketer'; +import * as bucketValueGenerator from '../bucketer/bucket_value_generator'; import { LOG_LEVEL, DECISION_SOURCES, @@ -2227,7 +2228,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_exclusion_group; - generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, '_generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() { @@ -2407,7 +2408,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_multiple_experiments; - generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, '_generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() { From 97bfd8d8216cd803ec1d185e8f55a8c0eaa0463c Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:51:39 +0600 Subject: [PATCH 7/7] [FSSDK-11119] function name adjustment --- .../bucketer/bucket_value_generator.spec.ts | 43 +++++++++++++++++++ lib/core/bucketer/bucket_value_generator.ts | 2 +- lib/core/bucketer/index.spec.ts | 35 +++------------ lib/core/bucketer/index.tests.js | 20 ++++----- lib/core/bucketer/index.ts | 6 +-- lib/core/decision_service/index.tests.js | 4 +- 6 files changed, 66 insertions(+), 44 deletions(-) create mode 100644 lib/core/bucketer/bucket_value_generator.spec.ts diff --git a/lib/core/bucketer/bucket_value_generator.spec.ts b/lib/core/bucketer/bucket_value_generator.spec.ts new file mode 100644 index 000000000..a7662e1f0 --- /dev/null +++ b/lib/core/bucketer/bucket_value_generator.spec.ts @@ -0,0 +1,43 @@ +/** + * 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 + * + * 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 { expect, describe, it } from 'vitest'; +import { sprintf } from '../../utils/fns'; +import { generateBucketValue } from './bucket_value_generator'; +import { OptimizelyError } from '../../error/optimizly_error'; +import { INVALID_BUCKETING_ID } from 'error_message'; + +describe('generateBucketValue', () => { + it('should return a bucket value for different inputs', () => { + const experimentId = 1886780721; + const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); + const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId); + const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); + const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); + + expect(generateBucketValue(bucketingKey1)).toBe(5254); + expect(generateBucketValue(bucketingKey2)).toBe(4299); + expect(generateBucketValue(bucketingKey3)).toBe(2434); + expect(generateBucketValue(bucketingKey4)).toBe(5439); + }); + + it('should return an error if it cannot generate the hash value', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => generateBucketValue(null)).toThrowError( + new OptimizelyError(INVALID_BUCKETING_ID) + ); + }); +}); diff --git a/lib/core/bucketer/bucket_value_generator.ts b/lib/core/bucketer/bucket_value_generator.ts index 5581710c4..c5f85303b 100644 --- a/lib/core/bucketer/bucket_value_generator.ts +++ b/lib/core/bucketer/bucket_value_generator.ts @@ -27,7 +27,7 @@ const MAX_TRAFFIC_VALUE = 10000; * @return {number} The generated bucket value * @throws If bucketing value is not a valid string */ -export const _generateBucketValue = function(bucketingKey: string): number { +export const generateBucketValue = function(bucketingKey: string): number { try { // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115 diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts index 040afae18..36f23b2eb 100644 --- a/lib/core/bucketer/index.spec.ts +++ b/lib/core/bucketer/index.spec.ts @@ -82,7 +82,7 @@ describe('excluding groups', () => { logger: mockLogger, }; - vi.spyOn(bucketValueGenerator, '_generateBucketValue') + vi.spyOn(bucketValueGenerator, 'generateBucketValue') .mockReturnValueOnce(50) .mockReturnValueOnce(50000); }); @@ -135,7 +135,7 @@ describe('including groups: random', () => { }); it('should return decision response with the proper variation for a user in a grouped experiment', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue') + vi.spyOn(bucketValueGenerator, 'generateBucketValue') .mockReturnValueOnce(50) .mockReturnValueOnce(50); @@ -154,7 +154,7 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValue(5000); + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(5000); const decisionResponse = bucketer.bucket(bucketerParams); @@ -171,7 +171,7 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValue(50000); + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(50000); const decisionResponse = bucketer.bucket(bucketerParams); @@ -183,7 +183,7 @@ describe('including groups: random', () => { }); it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(9000); + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(9000); const decisionResponse = bucketer.bucket(bucketerParams); @@ -231,7 +231,7 @@ describe('including groups: overlapping', () => { }); it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(0); + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(0); const decisionResponse = bucketer.bucket(bucketerParams); @@ -241,7 +241,7 @@ describe('including groups: overlapping', () => { }); it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => { - vi.spyOn(bucketValueGenerator, '_generateBucketValue').mockReturnValueOnce(3000); + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(3000); const decisionResponse = bucketer.bucket(bucketerParams); expect(decisionResponse.result).toBeNull(); @@ -340,28 +340,7 @@ describe('traffic allocation has invalid variation ids', () => { }); }); -describe('_generateBucketValue', () => { - it('should return a bucket value for different inputs', () => { - const experimentId = 1886780721; - const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); - const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId); - const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); - const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); - - expect(bucketValueGenerator._generateBucketValue(bucketingKey1)).toBe(5254); - expect(bucketValueGenerator._generateBucketValue(bucketingKey2)).toBe(4299); - expect(bucketValueGenerator._generateBucketValue(bucketingKey3)).toBe(2434); - expect(bucketValueGenerator._generateBucketValue(bucketingKey4)).toBe(5439); - }); - it('should return an error if it cannot generate the hash value', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(() => bucketValueGenerator._generateBucketValue(null)).toThrowError( - new OptimizelyError(INVALID_BUCKETING_ID) - ); - }); -}); describe('testBucketWithBucketingId', () => { let bucketerParams: BucketerParams; diff --git a/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js index d2532266b..0bdf62f4a 100644 --- a/lib/core/bucketer/index.tests.js +++ b/lib/core/bucketer/index.tests.js @@ -76,7 +76,7 @@ describe('lib/core/bucketer', function () { logger: createdLogger, }; sinon - .stub(bucketValueGenerator, '_generateBucketValue') + .stub(bucketValueGenerator, 'generateBucketValue') .onFirstCall() .returns(50) .onSecondCall() @@ -84,7 +84,7 @@ describe('lib/core/bucketer', function () { }); afterEach(function () { - bucketValueGenerator._generateBucketValue.restore(); + bucketValueGenerator.generateBucketValue.restore(); }); it('should return decision response with correct variation ID when provided bucket value', function () { @@ -116,11 +116,11 @@ describe('lib/core/bucketer', function () { groupIdMap: configObj.groupIdMap, logger: createdLogger, }; - bucketerStub = sinon.stub(bucketValueGenerator, '_generateBucketValue'); + bucketerStub = sinon.stub(bucketValueGenerator, 'generateBucketValue'); }); afterEach(function () { - bucketValueGenerator._generateBucketValue.restore(); + bucketValueGenerator.generateBucketValue.restore(); }); describe('random groups', function () { @@ -328,7 +328,7 @@ describe('lib/core/bucketer', function () { }); }); - describe('_generateBucketValue', function () { + describe('generateBucketValue', function () { it('should return a bucket value for different inputs', function () { var experimentId = 1886780721; var bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); @@ -336,15 +336,15 @@ describe('lib/core/bucketer', function () { var bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); var bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); - expect(bucketValueGenerator._generateBucketValue(bucketingKey1)).to.equal(5254); - expect(bucketValueGenerator._generateBucketValue(bucketingKey2)).to.equal(4299); - expect(bucketValueGenerator._generateBucketValue(bucketingKey3)).to.equal(2434); - expect(bucketValueGenerator._generateBucketValue(bucketingKey4)).to.equal(5439); + expect(bucketValueGenerator.generateBucketValue(bucketingKey1)).to.equal(5254); + expect(bucketValueGenerator.generateBucketValue(bucketingKey2)).to.equal(4299); + expect(bucketValueGenerator.generateBucketValue(bucketingKey3)).to.equal(2434); + expect(bucketValueGenerator.generateBucketValue(bucketingKey4)).to.equal(5439); }); it('should return an error if it cannot generate the hash value', function() { const response = assert.throws(function() { - bucketValueGenerator._generateBucketValue(null); + bucketValueGenerator.generateBucketValue(null); } ); expect(response.baseMessage).to.equal(INVALID_BUCKETING_ID); }); diff --git a/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts index f2aaed510..b2455b95a 100644 --- a/lib/core/bucketer/index.ts +++ b/lib/core/bucketer/index.ts @@ -26,7 +26,7 @@ import { } from '../../shared_types'; import { INVALID_GROUP_ID } from 'error_message'; import { OptimizelyError } from '../../error/optimizly_error'; -import { _generateBucketValue } from './bucket_value_generator'; +import { generateBucketValue } from './bucket_value_generator'; export const USER_NOT_IN_ANY_EXPERIMENT = 'User %s is not in any experiment of group %s.'; export const USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is not in experiment %s of group %s.'; @@ -123,7 +123,7 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse } } const bucketingId = `${bucketerParams.bucketingId}${bucketerParams.experimentId}`; - const bucketValue = _generateBucketValue(bucketingId); + const bucketValue = generateBucketValue(bucketingId); bucketerParams.logger?.debug( USER_ASSIGNED_TO_EXPERIMENT_BUCKET, @@ -171,7 +171,7 @@ export const bucketUserIntoExperiment = function( logger?: LoggerFacade ): string | null { const bucketingKey = `${bucketingId}${group.id}`; - const bucketValue = _generateBucketValue(bucketingKey); + const bucketValue = generateBucketValue(bucketingKey); logger?.debug( USER_ASSIGNED_TO_EXPERIMENT_BUCKET, bucketValue, diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index 215c99f44..b723d118b 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -2228,7 +2228,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_exclusion_group; - generateBucketValueStub = sandbox.stub(bucketValueGenerator, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() { @@ -2408,7 +2408,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_multiple_experiments; - generateBucketValueStub = sandbox.stub(bucketValueGenerator, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() {