diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js index 57bcab548..0828d9209 100644 --- a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js +++ b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.tests.js @@ -760,4 +760,483 @@ describe('lib/core/custom_attribute_condition_evaluator', function() { ); }); }); + describe('less than or equal to match type', function() { + var leCondition = { + match: 'le', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + + it('should return false if the user-provided value is greater than the condition value', function() { + var result = customAttributeEvaluator.evaluate( + leCondition, + { + meters_travelled: 48.3, + } + ); + assert.isFalse(result); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', function() { + var versions = [48, 48.2]; + for (let userValue of versions) { + var result = customAttributeEvaluator.evaluate( + leCondition, + { + meters_travelled: userValue, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for condition value: ${leCondition.value} and user value: ${userValue}`); + } + }); + }); + + + describe('greater than and equal to match type', function() { + var geCondition = { + match: 'ge', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + + it('should return false if the user-provided value is less than the condition value', function() { + var result = customAttributeEvaluator.evaluate( + geCondition, + { + meters_travelled: 48, + } + ); + assert.isFalse(result); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', function() { + var versions = [100, 48.2]; + for (let userValue of versions) { + var result = customAttributeEvaluator.evaluate( + geCondition, + { + meters_travelled: userValue, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for condition value: ${geCondition.value} and user value: ${userValue}`); + } + }); + }); + + describe('semver greater than match type', function() { + var semvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + + it('should return true if the user-provided version is greater than the condition version', function() { + var versions = [ + ['1.8.1', '1.9'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvergtCondition, + { + app_version: userVersion, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return false if the user-provided version is not greater than the condition version', function() { + var versions = [ + ['2.0.1', '2.0.1'], + ['2.0', '2.0.0'], + ['2.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvergtCondition, + { + app_version: userVersion, + } + ); + assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should log and return null if the user-provided version is not a string', function() { + var result = customAttributeEvaluator.evaluate( + semvergtCondition, + { + app_version: 22, + } + ); + assert.isNull(result); + + result = customAttributeEvaluator.evaluate( + semvergtCondition, + { + app_version: false, + } + ); + assert.isNull(result); + + assert.strictEqual(2, stubLogHandler.log.callCount); + console.log(stubLogHandler.log.args); + assert.strictEqual( + stubLogHandler.log.args[0][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".' + ); + assert.strictEqual( + stubLogHandler.log.args[1][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".' + ); + }); + + it('should log and return null if the user-provided value is null', function() { + var result = customAttributeEvaluator.evaluate(semvergtCondition, { app_version: null }); + assert.isNull(result); + sinon.assert.calledOnce(stubLogHandler.log); + sinon.assert.calledWithExactly( + stubLogHandler.log, + LOG_LEVEL.DEBUG, + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_gt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".' + ); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(semvergtCondition, {}); + assert.isNull(result); + }); + }); + + describe('semver less than match type', function() { + var semverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + + it('should return false if the user-provided version is greater than the condition version', function() { + var versions = [ + ['2.0.0', '2.0.1'], + ['1.9', '2.0.0'], + ['2.0.0', '2.0.0'], + + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemverltCondition, + { + app_version: userVersion, + } + ); + assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return true if the user-provided version is less than the condition version', function() { + var versions = [ + ['2.0.1', '2.0.0'], + ['2.0.0', '1.9'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemverltCondition, + { + app_version: userVersion, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should log and return null if the user-provided version is not a string', function() { + var result = customAttributeEvaluator.evaluate( + semverltCondition, + { + app_version: 22, + } + ); + assert.isNull(result); + + result = customAttributeEvaluator.evaluate( + semverltCondition, + { + app_version: false, + } + ); + assert.isNull(result); + + assert.strictEqual(2, stubLogHandler.log.callCount); + console.log(stubLogHandler.log.args); + assert.strictEqual( + stubLogHandler.log.args[0][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".' + ); + assert.strictEqual( + stubLogHandler.log.args[1][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".' + ); + }); + + it('should log and return null if the user-provided value is null', function() { + var result = customAttributeEvaluator.evaluate(semverltCondition, { app_version: null }); + assert.isNull(result); + sinon.assert.calledOnce(stubLogHandler.log); + sinon.assert.calledWithExactly( + stubLogHandler.log, + LOG_LEVEL.DEBUG, + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_lt","name":"app_version","type":"custom_attribute","value":"2.0.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".' + ); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(semverltCondition, {}); + assert.isNull(result); + }); + }); + + describe('semver equal to match type', function() { + var semvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: '2.0', + }; + + it('should return false if the user-provided version is greater than the condition version', function() { + var versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return true if the user-provided version is equal to the condition version', function() { + var versions = [ + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should log and return null if the user-provided version is not a string', function() { + var result = customAttributeEvaluator.evaluate( + semvereqCondition, + { + app_version: 22, + } + ); + assert.isNull(result); + + result = customAttributeEvaluator.evaluate( + semvereqCondition, + { + app_version: false, + } + ); + assert.isNull(result); + + assert.strictEqual(2, stubLogHandler.log.callCount); + console.log(stubLogHandler.log.args); + assert.strictEqual( + stubLogHandler.log.args[0][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a value of type "number" was passed for user attribute "app_version".' + ); + assert.strictEqual( + stubLogHandler.log.args[1][1], + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a value of type "boolean" was passed for user attribute "app_version".' + ); + }); + + it('should log and return null if the user-provided value is null', function() { + var result = customAttributeEvaluator.evaluate(semvereqCondition, { app_version: null }); + assert.isNull(result); + sinon.assert.calledOnce(stubLogHandler.log); + sinon.assert.calledWithExactly( + stubLogHandler.log, + LOG_LEVEL.DEBUG, + 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"semver_eq","name":"app_version","type":"custom_attribute","value":"2.0"} evaluated to UNKNOWN because a null value was passed for user attribute "app_version".' + ); + }); + + it('should return null if there is no user-provided value', function() { + var result = customAttributeEvaluator.evaluate(semvereqCondition, {}); + assert.isNull(result); + }); + }); + + describe('semver less than or equal to match type', function() { + var semverleCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + + it('should return false if the user-provided version is greater than the condition version', function() { + var versions = [ + ['2.0.0', '2.0.1'] + ] + for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return true if the user-provided version is less than or equal to the condition version', function() { + var versions = [ + ['2.0.1', '2.0.0'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ['1.9.1', '1.9'], + ]; for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return true if the user-provided version is equal to the condition version', function() { + var result = customAttributeEvaluator.evaluate( + semverleCondition, + { + app_version: '2.0', + } + ); + assert.isTrue(result); + }); + }); + + describe('semver greater than or equal to match type', function() { + var semvergeCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: '2.0', + }; + + it('should return true if the user-provided version is greater than or equal to the condition version', function() { + var versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isTrue(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return false if the user-provided version is less than the condition version', function() { + var versions = [ + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'] + ]; + for (let [targetVersion, userVersion] of versions) { + var customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + var result = customAttributeEvaluator.evaluate( + customSemvereqCondition, + { + app_version: userVersion, + } + ); + assert.isFalse(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + }); }); diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts index 59cda5efc..94d5f2a6c 100644 --- a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts +++ b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.ts @@ -18,6 +18,7 @@ import { UserAttributes } from '../../shared_types'; import { isNumber, isSafeInteger } from '../../utils/fns'; import { LOG_MESSAGES } from '../../utils/enums'; +import { compareVersion } from '../../utils/semantic_version'; const MODULE_NAME = 'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR'; @@ -25,16 +26,30 @@ const logger = getLogger(); const EXACT_MATCH_TYPE = 'exact'; const EXISTS_MATCH_TYPE = 'exists'; +const GREATER_OR_EQUAL_THAN_MATCH_TYPE = 'ge'; const GREATER_THAN_MATCH_TYPE = 'gt'; +const LESS_OR_EQUAL_THAN_MATCH_TYPE = 'le'; const LESS_THAN_MATCH_TYPE = 'lt'; +const SEMVER_EXACT_MATCH_TYPE = 'semver_eq'; +const SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE = 'semver_ge'; +const SEMVER_GREATER_THAN_MATCH_TYPE = 'semver_gt'; +const SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE = 'semver_le'; +const SEMVER_LESS_THAN_MATCH_TYPE = 'semver_lt'; const SUBSTRING_MATCH_TYPE = 'substring'; const MATCH_TYPES = [ EXACT_MATCH_TYPE, EXISTS_MATCH_TYPE, GREATER_THAN_MATCH_TYPE, + GREATER_OR_EQUAL_THAN_MATCH_TYPE, LESS_THAN_MATCH_TYPE, + LESS_OR_EQUAL_THAN_MATCH_TYPE, SUBSTRING_MATCH_TYPE, + SEMVER_EXACT_MATCH_TYPE, + SEMVER_LESS_THAN_MATCH_TYPE, + SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE, + SEMVER_GREATER_THAN_MATCH_TYPE, + SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE ]; type Condition = { @@ -50,8 +65,15 @@ const EVALUATORS_BY_MATCH_TYPE: { [conditionType: string]: ConditionEvaluator | EVALUATORS_BY_MATCH_TYPE[EXACT_MATCH_TYPE] = exactEvaluator; EVALUATORS_BY_MATCH_TYPE[EXISTS_MATCH_TYPE] = existsEvaluator; EVALUATORS_BY_MATCH_TYPE[GREATER_THAN_MATCH_TYPE] = greaterThanEvaluator; +EVALUATORS_BY_MATCH_TYPE[GREATER_OR_EQUAL_THAN_MATCH_TYPE] = greaterThanOrEqualEvaluator; EVALUATORS_BY_MATCH_TYPE[LESS_THAN_MATCH_TYPE] = lessThanEvaluator; +EVALUATORS_BY_MATCH_TYPE[LESS_OR_EQUAL_THAN_MATCH_TYPE] = lessThanOrEqualEvaluator; EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator; +EVALUATORS_BY_MATCH_TYPE[SEMVER_EXACT_MATCH_TYPE] = semverEqualEvaluator; +EVALUATORS_BY_MATCH_TYPE[SEMVER_GREATER_THAN_MATCH_TYPE] = semverGreaterThanEvaluator; +EVALUATORS_BY_MATCH_TYPE[SEMVER_GREATER_OR_EQUAL_THAN_MATCH_TYPE] = semverGreaterThanOrEqualEvaluator; +EVALUATORS_BY_MATCH_TYPE[SEMVER_LESS_THAN_MATCH_TYPE] = semverLessThanEvaluator; +EVALUATORS_BY_MATCH_TYPE[SEMVER_LESS_OR_EQUAL_THAN_MATCH_TYPE] = semverLessThanOrEqualEvaluator; /** * Given a custom attribute audience condition and user attributes, evaluate the @@ -211,6 +233,53 @@ function greaterThanEvaluator(condition: Condition, userAttributes: UserAttribut return userValue > conditionValue; } +/** + * Evaluate the given greater or equal than match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute value is greater or equal than the condition value, + * false if the user attribute value is less than to the condition value, + * null if the condition value isn't a number or the user attribute value isn't a + * number + */ +function greaterThanOrEqualEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const conditionName = condition.name; + const userValue = userAttributes[conditionName]; + const userValueType = typeof userValue; + const conditionValue = condition.value; + + if (conditionValue === null || !isSafeInteger(conditionValue)) { + logger.warn( + LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition) + ); + return null; + } + + if (userValue === null) { + logger.debug( + LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName + ); + return null; + } + + if (!isNumber(userValue)) { + logger.warn( + LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName + ); + return null; + } + + if (!isSafeInteger(userValue)) { + logger.warn( + LOG_MESSAGES.OUT_OF_BOUNDS, MODULE_NAME, JSON.stringify(condition), conditionName + ); + return null; + } + + return userValue >= conditionValue; +} + /** * Evaluate the given less than match condition for the given user attributes * @param {Condition} condition @@ -258,6 +327,53 @@ function lessThanEvaluator(condition: Condition, userAttributes: UserAttributes) return userValue < conditionValue; } +/** + * Evaluate the given less or equal than match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute value is less or equal than the condition value, + * false if the user attribute value is greater than to the condition value, + * null if the condition value isn't a number or the user attribute value isn't a + * number + */ +function lessThanOrEqualEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const conditionName = condition.name; + const userValue = userAttributes[condition.name]; + const userValueType = typeof userValue; + const conditionValue = condition.value; + + if (conditionValue === null || !isSafeInteger(conditionValue)) { + logger.warn( + LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition) + ); + return null; + } + + if (userValue === null) { + logger.debug( + LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName + ); + return null; + } + + if (!isNumber(userValue)) { + logger.warn( + LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName + ); + return null; + } + + if (!isSafeInteger(userValue)) { + logger.warn( + LOG_MESSAGES.OUT_OF_BOUNDS, MODULE_NAME, JSON.stringify(condition), conditionName + ); + return null; + } + + return userValue <= conditionValue; +} + /** * Evaluate the given substring match condition for the given user attributes * @param {Condition} condition @@ -297,3 +413,127 @@ function substringEvaluator(condition: Condition, userAttributes: UserAttributes return userValue.indexOf(conditionValue) !== -1; } + +/** + * Evaluate the given semantic version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?number} returns compareVersion result + * null if the user attribute version has an invalid type + */ +function evaluateSemanticVersion(condition: Condition, userAttributes: UserAttributes): number | null { + const conditionName = condition.name; + const userValue = userAttributes[conditionName]; + const userValueType = typeof userValue; + const conditionValue = condition.value; + + if (typeof conditionValue !== 'string') { + logger.warn( + LOG_MESSAGES.UNEXPECTED_CONDITION_VALUE, MODULE_NAME, JSON.stringify(condition) + ); + return null; + } + + if (userValue === null) { + logger.debug( + LOG_MESSAGES.UNEXPECTED_TYPE_NULL, MODULE_NAME, JSON.stringify(condition), conditionName + ); + return null; + } + + if (typeof userValue !== 'string') { + logger.warn( + LOG_MESSAGES.UNEXPECTED_TYPE, MODULE_NAME, JSON.stringify(condition), userValueType, conditionName + ); + return null; + } + + return compareVersion(conditionValue, userValue); +} + +/** + * Evaluate the given version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute version is equal (===) to the condition version, + * false if the user attribute version is not equal (!==) to the condition version, + * null if the user attribute version has an invalid type + */ +function semverEqualEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const result = evaluateSemanticVersion(condition, userAttributes); + if (result === null ) { + return null; + } + return result === 0; +} + +/** + * Evaluate the given version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute version is greater (>) than the condition version, + * false if the user attribute version is not greater than the condition version, + * null if the user attribute version has an invalid type + */ +function semverGreaterThanEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const result = evaluateSemanticVersion(condition, userAttributes); + if (result === null ) { + return null; + } + return result > 0; +} + +/** + * Evaluate the given version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute version is less (<) than the condition version, + * false if the user attribute version is not less than the condition version, + * null if the user attribute version has an invalid type + */ +function semverLessThanEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const result = evaluateSemanticVersion(condition, userAttributes); + if (result === null ) { + return null; + } + return result < 0; +} + +/** + * Evaluate the given version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute version is greater than or equal (>=) to the condition version, + * false if the user attribute version is not greater than or equal to the condition version, + * null if the user attribute version has an invalid type + */ +function semverGreaterThanOrEqualEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const result = evaluateSemanticVersion(condition, userAttributes); + if (result === null ) { + return null; + } + return result >= 0; +} + +/** + * Evaluate the given version match condition for the given user attributes + * @param {Condition} condition + * @param {UserAttributes} userAttributes + * @param {LoggerFacade} logger + * @returns {?Boolean} true if the user attribute version is less than or equal (<=) to the condition version, + * false if the user attribute version is not less than or equal to the condition version, + * null if the user attribute version has an invalid type + */ +function semverLessThanOrEqualEvaluator(condition: Condition, userAttributes: UserAttributes): boolean | null { + const result = evaluateSemanticVersion(condition, userAttributes); + if (result === null ) { + return null; + } + return result <= 0; + +} diff --git a/packages/optimizely-sdk/lib/utils/enums/index.ts b/packages/optimizely-sdk/lib/utils/enums/index.ts index e50008ad5..31c8296ae 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.ts +++ b/packages/optimizely-sdk/lib/utils/enums/index.ts @@ -226,3 +226,11 @@ export const DATAFILE_VERSIONS = { V3: '3', V4: '4', }; + +/* +* Pre-Release and Build symbols +*/ +export const VERSION_TYPE = { + IS_PRE_RELEASE: '-', + IS_BUILD: '+' +} diff --git a/packages/optimizely-sdk/lib/utils/semantic_version/index.tests.js b/packages/optimizely-sdk/lib/utils/semantic_version/index.tests.js new file mode 100644 index 000000000..d26651275 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/semantic_version/index.tests.js @@ -0,0 +1,93 @@ +/** + * Copyright 2020, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { assert } from 'chai'; +import * as semanticVersion from './'; + +describe('lib/utils/sematic_version', function() { + describe('APIs', function() { + describe('compareVersion', function() { + it('should return 0 if user version and target version are equal', function() { + const versions = [ + ['2.0.1', '2.0.1'], + ['2.9.9-beta', '2.9.9-beta'], + ['2.1', '2.1.0'], + ['2', '2.12'], + ['2.9', '2.9.1'], + ['2.9+beta', '2.9+beta'], + ['2.9.9+beta', '2.9.9+beta'], + ['2.9.9+beta-alpha', '2.9.9+beta-alpha'], + ['2.2.3', '2.2.3+beta'], + ['2.1.3', '2.1.3-beta'] + ]; + for (const [targetVersion, userVersion] of versions) { + const result = semanticVersion.compareVersion(targetVersion, userVersion) + assert.equal(result, 0, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + it('should return 1 when user version is greater than target version', function() { + const versions = [ + ['2.0.0', '2.0.1'], + ['2.0', '3.0.1'], + ['2.0.0', '2.1'], + ['2.1.2-beta', '2.1.2-release'], + ['2.1.3-beta1', '2.1.3-beta2'], + ['2.9.9-beta', '2.9.9'], + ['2.9.9+beta', '2.9.9'], + ['2.0.0', '2.1'], + ['3.7.0-prerelease+build', '3.7.0-prerelease+rc'], + ['2.2.3-beta-beta1', '2.2.3-beta-beta2'], + ['2.2.3-beta+beta1', '2.2.3-beta+beta2'], + ['2.2.3+beta2-beta1', '2.2.3+beta3-beta2'], + ['2.2.3+beta', '2.2.3'] + ]; + for (const [targetVersion, userVersion] of versions) { + const result = semanticVersion.compareVersion(targetVersion, userVersion) + assert.equal(result, 1, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return -1 when user version is less than target version', function() { + const versions = [ + ['2.0.1', '2.0.0'], + ['3.0', '2.0.1'], + ['2.3', '2.0.1'], + ['2.3.5', '2.3.1'], + ['2.9.8', '2.9'], + ['3.1', '3'], + ['2.1.2-release', '2.1.2-beta'], + ['2.9.9+beta', '2.9.9-beta'], + ['3.7.0+build3.7.0-prerelease+build', '3.7.0-prerelease'], + ['2.1.3-beta-beta2', '2.1.3-beta'], + ['2.1.3-beta1+beta3', '2.1.3-beta1+beta2'] + ]; + for (const [targetVersion, userVersion] of versions) { + const result = semanticVersion.compareVersion(targetVersion, userVersion) + assert.equal(result, -1, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + it('should return null when user version is invalid', function() { + const versions = ['-', '.', '..', '+', '+test', ' ', '2 .3. 0', '2.', '.2.2', '3.7.2.2', '3.x', ',', '+build-prerelease', '2..2'] + const targetVersion = '2.1.0'; + for (const userVersion of versions) { + const result = semanticVersion.compareVersion(targetVersion, userVersion); + assert.isNull(result, `Got result ${result}. Failed for target version: ${targetVersion} and user version: ${userVersion}`); + } + }); + + }); + }); +}); diff --git a/packages/optimizely-sdk/lib/utils/semantic_version/index.ts b/packages/optimizely-sdk/lib/utils/semantic_version/index.ts new file mode 100644 index 000000000..d7878cb89 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/semantic_version/index.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2020, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getLogger } from '@optimizely/js-sdk-logging'; +import { VERSION_TYPE, LOG_MESSAGES } from '../enums'; + +const MODULE_NAME = 'SEMANTIC VERSION'; +const logger = getLogger(); + +/** + * Evaluate if provided string is number only + * @param {unknown} content + * @return {boolean} true if the string is number only + * + */ + function isNumber(content: string): boolean { + return content.match(/^[0-9]+$/) != null ? true : false; + } + + /** + * Evaluate if provided version contains pre-release "-" + * @param {unknown} version + * @return {boolean} true if the version contains "-" and meets condition + * + */ + function isPreReleaseVersion(version: string): boolean { + const preReleaseIndex = version.indexOf(VERSION_TYPE.IS_PRE_RELEASE); + const buildIndex = version.indexOf(VERSION_TYPE.IS_BUILD); + + if (preReleaseIndex < 0) { + return false; + } + + if (buildIndex < 0 ) { + return true; + } + + return preReleaseIndex < buildIndex; + } + + /** + * Evaluate if provided version contains build "+" + * @param {unknown} version + * @return {boolean} true if the version contains "+" and meets condition + * + */ + function isBuildVersion(version: string): boolean { + const preReleaseIndex = version.indexOf(VERSION_TYPE.IS_PRE_RELEASE); + const buildIndex = version.indexOf(VERSION_TYPE.IS_BUILD); + + if (buildIndex < 0) { + return false; + } + + if (preReleaseIndex < 0 ) { + return true; + } + + return buildIndex < preReleaseIndex; + } + + /** + * check if there is any white spaces " " in version + * @param {unknown} version + * @return {boolean} true if the version contains " " + * + */ + function hasWhiteSpaces(version: string): boolean { + return version.includes(' '); + } + + /** + * split version in parts + * @param {unknown} version + * @return {boolean} The array of version split into smaller parts i.e major, minor, patch etc + * null if given version is in invalid format + */ + function splitVersion(version: string): string[] | null { + let targetPrefix = version; + let targetSuffix = ''; + + // check that version shouldn't have white space + if (hasWhiteSpaces(version)) { + logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, version); + return null; + } + //check for pre release e.g. 1.0.0-alpha where 'alpha' is a pre release + //otherwise check for build e.g. 1.0.0+001 where 001 is a build metadata + if (isPreReleaseVersion(version)) { + targetPrefix = version.substring(0, version.indexOf(VERSION_TYPE.IS_PRE_RELEASE)); + targetSuffix = version.substring(version.indexOf(VERSION_TYPE.IS_PRE_RELEASE) + 1); + } + else if (isBuildVersion(version)) { + targetPrefix = version.substring(0, version.indexOf(VERSION_TYPE.IS_BUILD)); + targetSuffix = version.substring(version.indexOf(VERSION_TYPE.IS_BUILD) + 1); + } + + // check dot counts in target_prefix + if (typeof targetPrefix !== 'string' || typeof targetSuffix !== 'string') { + return null; + } + + const dotCount = targetPrefix.split(".").length - 1; + if (dotCount > 2){ + logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, version); + return null; + } + + const targetVersionParts = targetPrefix.split(".") + if (targetVersionParts.length != dotCount + 1) { + logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, version); + return null; + } + for (const part of targetVersionParts){ + if (!isNumber(part)) { + logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, version); + return null; + } + } + + if (targetSuffix) { + targetVersionParts.push(targetSuffix) + } + + return targetVersionParts + + } + + export function compareVersion(conditionsVersion: string, userProvidedVersion: string): number | null { + const isPreReleaseInconditionsVersion = isPreReleaseVersion(conditionsVersion) + const isPreReleaseInuserProvidedVersion = isPreReleaseVersion(userProvidedVersion) + const isBuildInconditionsVersion = isBuildVersion(conditionsVersion) + + const userVersionParts = splitVersion(userProvidedVersion); + const conditionsVersionParts = splitVersion(conditionsVersion); + + if (!userVersionParts || !conditionsVersionParts) + return null; + + const userVersionPartsLen = userVersionParts.length; + + for (let idx = 0; idx < conditionsVersionParts.length; idx++) { + if (userVersionPartsLen <= idx) + return isPreReleaseInconditionsVersion || isBuildInconditionsVersion ? 1 : -1 + else if (!isNumber(userVersionParts[idx])) { + if (userVersionParts[idx] < conditionsVersionParts[idx]) { + return isPreReleaseInconditionsVersion && !isPreReleaseInuserProvidedVersion ? 1 : -1; + } + else if (userVersionParts[idx] > conditionsVersionParts[idx]) { + return !isPreReleaseInconditionsVersion && isPreReleaseInuserProvidedVersion ? -1 : 1; + } + } + else { + const userVersionPart = parseInt(userVersionParts[idx]) + const conditionsVersionPart = parseInt(conditionsVersionParts[idx]) + if (userVersionPart > conditionsVersionPart) + return 1; + else if (userVersionPart < conditionsVersionPart) + return -1; + } + } + + // check if user version contains release and target version contains build + if ((isPreReleaseInuserProvidedVersion && isBuildInconditionsVersion)) + return -1; + + return 0; + } +