diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 87cc8019..b3709879 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -20,7 +20,7 @@ jobs: python setup.py install - name: Linting run: | - mypy UnleashClient + mypy UnleashClient --install-types --non-interactive pylint UnleashClient - name: Unit tests run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0078adc2..89786a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next version +* (Major) Support new constraint operators. * (Minor) Refactor `unleash-client-python` to modernize tooling (`setuptools_scm` and centralizing tool config in `pyproject.toml`). * (Minor) Migrate documentation to Sphinx. diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index e8038a24..61e41a7c 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -1,8 +1,7 @@ # pylint: disable=invalid-name import warnings from datetime import datetime, timezone -from typing import Dict, Callable, Any, Optional -import copy +from typing import Callable, Optional from fcache.cache import FileCache from apscheduler.job import Job from apscheduler.schedulers.background import BackgroundScheduler @@ -227,8 +226,14 @@ def is_enabled(self, :return: Feature flag result """ context = context or {} + + # Update context with static values context.update(self.unleash_static_context) + # Update context with optional values + if 'currentTime' not in context.keys(): + context.update({'currentTime': datetime.now()}) + if self.is_initialized: try: return self.features[feature_name].is_enabled(context) diff --git a/UnleashClient/constraints/Constraint.py b/UnleashClient/constraints/Constraint.py index 50f37424..1816401d 100644 --- a/UnleashClient/constraints/Constraint.py +++ b/UnleashClient/constraints/Constraint.py @@ -1,7 +1,39 @@ -# pylint: disable=invalid-name, too-few-public-methods +# pylint: disable=invalid-name, too-few-public-methods, use-a-generator +from typing import Optional, Union +from datetime import datetime +from enum import Enum +from dateutil.parser import parse, ParserError +import semver from UnleashClient.utils import LOGGER, get_identifier +class ConstraintOperators(Enum): + # Logical operators + IN = "IN" + NOT_IN = "NOT_IN" + + # String operators + STR_ENDS_WITH = "STR_ENDS_WITH" + STR_STARTS_WITH = "STR_STARTS_WITH" + STR_CONTAINS = "STR_CONTAINS" + + # Numeric oeprators + NUM_EQ = "NUM_EQ" + NUM_GT = "NUM_GT" + NUM_GTE = "NUM_GTE" + NUM_LT = "NUM_LT" + NUM_LTE = "NUM_LTE" + + # Date operators + DATE_AFTER = "DATE_AFTER" + DATE_BEFORE = "DATE_BEFORE" + + # Semver operators + SEMVER_EQ = "SEMVER_EQ" + SEMVER_GT = "SEMVER_GT" + SEMVER_LT = "SEMVER_LT" + + class Constraint: def __init__(self, constraint_dict: dict) -> None: """ @@ -9,9 +41,110 @@ def __init__(self, constraint_dict: dict) -> None: :param constraint_dict: From the strategy document. """ - self.context_name = constraint_dict['contextName'] - self.operator = constraint_dict['operator'] - self.values = constraint_dict['values'] + self.context_name: str = constraint_dict['contextName'] + self.operator: ConstraintOperators = ConstraintOperators(constraint_dict['operator'].upper()) + self.values = constraint_dict['values'] if 'values' in constraint_dict.keys() else [] + self.value = constraint_dict['value'] if 'value' in constraint_dict.keys() else [] + + self.case_insensitive = constraint_dict['caseInsensitive'] if 'caseInsensitive' in constraint_dict.keys() else False + self.inverted = constraint_dict['inverted'] if 'inverted' in constraint_dict.keys() else False + + + # Methods to handle each operator type. + def check_list_operators(self, context_value: str) -> bool: + return_value = False + + if self.operator == ConstraintOperators.IN: + return_value = context_value in self.values + elif self.operator == ConstraintOperators.NOT_IN: + return_value = context_value not in self.values + + return return_value + + def check_string_operators(self, context_value: str) -> bool: + if self.case_insensitive: + normalized_values = [x.upper() for x in self.values] + normalized_context_value = context_value.upper() + else: + normalized_values = self.values + normalized_context_value = context_value + + return_value = False + + if self.operator == ConstraintOperators.STR_CONTAINS: + return_value = any([x in normalized_context_value for x in normalized_values]) + elif self.operator == ConstraintOperators.STR_ENDS_WITH: + return_value = any([normalized_context_value.endswith(x) for x in normalized_values]) + elif self.operator == ConstraintOperators.STR_STARTS_WITH: + return_value = any([normalized_context_value.startswith(x) for x in normalized_values]) + + return return_value + + def check_numeric_operators(self, context_value: Union[float, int]) -> bool: + return_value = False + parsed_value = float(self.value) + + if self.operator == ConstraintOperators.NUM_EQ: + return_value = context_value == parsed_value + elif self.operator == ConstraintOperators.NUM_GT: + return_value = context_value > parsed_value + elif self.operator == ConstraintOperators.NUM_GTE: + return_value = context_value >= parsed_value + elif self.operator == ConstraintOperators.NUM_LT: + return_value = context_value < parsed_value + elif self.operator == ConstraintOperators.NUM_LTE: + return_value = context_value <= parsed_value + + return return_value + + + def check_date_operators(self, context_value: datetime) -> bool: + return_value = False + parsing_exception = False + + try: + parsed_date = parse(self.value, ignoretz=True) + except ParserError: + LOGGER.error(f"Unable to parse date: {self.value}") + parsing_exception = True + + if not parsing_exception: + if self.operator == ConstraintOperators.DATE_AFTER: + return_value = context_value > parsed_date + elif self.operator == ConstraintOperators.DATE_BEFORE: + return_value = context_value < parsed_date + + return return_value + + + def check_semver_operators(self, context_value: str) -> bool: + return_value = False + parsing_exception = False + target_version: Optional[semver.VersionInfo] = None + context_version: Optional[semver.VersionInfo] = None + + try: + target_version = semver.VersionInfo.parse(self.value) + except ValueError: + LOGGER.error(f"Unable to parse server semver: {self.value}") + parsing_exception = True + + try: + context_version = semver.VersionInfo.parse(context_value) + except ValueError: + LOGGER.error(f"Unable to parse context semver: {context_value}") + parsing_exception = True + + if not parsing_exception: + if self.operator == ConstraintOperators.SEMVER_EQ: + return_value = context_version == target_version + elif self.operator == ConstraintOperators.SEMVER_GT: + return_value = context_version > target_version + elif self.operator == ConstraintOperators.SEMVER_LT: + return_value = context_version < target_version + + return return_value + def apply(self, context: dict = None) -> bool: """ @@ -23,14 +156,21 @@ def apply(self, context: dict = None) -> bool: constraint_check = False try: - value = get_identifier(self.context_name, context) + context_value = get_identifier(self.context_name, context) + + if context_value is not None: + if self.operator in [ConstraintOperators.IN, ConstraintOperators.NOT_IN]: + constraint_check = self.check_list_operators(context_value=context_value) + elif self.operator in [ConstraintOperators.STR_CONTAINS, ConstraintOperators.STR_ENDS_WITH, ConstraintOperators.STR_STARTS_WITH]: + constraint_check = self.check_string_operators(context_value=context_value) + elif self.operator in [ConstraintOperators.NUM_EQ, ConstraintOperators.NUM_GT, ConstraintOperators.NUM_GTE, ConstraintOperators.NUM_LT, ConstraintOperators.NUM_LTE]: + constraint_check = self.check_numeric_operators(context_value=context_value) + elif self.operator in [ConstraintOperators.DATE_AFTER, ConstraintOperators.DATE_BEFORE]: + constraint_check = self.check_date_operators(context_value=context_value) + elif self.operator in [ConstraintOperators.SEMVER_EQ, ConstraintOperators.SEMVER_GT, ConstraintOperators.SEMVER_LT]: + constraint_check = self.check_semver_operators(context_value=context_value) - if value: - if self.operator.upper() == "IN": - constraint_check = value in self.values - elif self.operator.upper() == "NOT_IN": - constraint_check = value not in self.values except Exception as excep: # pylint: disable=broad-except LOGGER.info("Could not evaluate context %s! Error: %s", self.context_name, excep) - return constraint_check + return not constraint_check if self.inverted else constraint_check diff --git a/UnleashClient/utils.py b/UnleashClient/utils.py index 33a913fd..50d33991 100644 --- a/UnleashClient/utils.py +++ b/UnleashClient/utils.py @@ -1,4 +1,5 @@ import logging +from typing import Any import mmh3 # pylint: disable=import-error from requests import Response @@ -14,7 +15,7 @@ def normalized_hash(identifier: str, return mmh3.hash(f"{activation_group}:{identifier}", signed=False) % normalizer + 1 -def get_identifier(context_key_name: str, context: dict) -> str: +def get_identifier(context_key_name: str, context: dict) -> Any: if context_key_name in context.keys(): value = context[context_key_name] elif 'properties' in context.keys() and context_key_name in context['properties'].keys(): diff --git a/pyproject.toml b/pyproject.toml index cec3eadc..ca83e7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ disable = [ "line-too-long", "missing-class-docstring", "missing-module-docstring", - "missing-function-docstring" + "missing-function-docstring", + "logging-fstring-interpolation" ] max-attributes = 25 max-args = 25 diff --git a/requirements.txt b/requirements.txt index 4e033917..9fb99cc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ requests fcache mmh3 APScheduler +python-dateutil +semver # Development packages bumpversion diff --git a/setup.py b/setup.py index 9ef9c705..0ef84adc 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,15 @@ def readme(): url='https://github.com/Unleash/unleash-client-python', packages=find_packages(exclude=["tests*"]), package_data={"UnleashClient": ["py.typed"]}, - install_requires=["requests", "fcache", "mmh3", "apscheduler", "importlib_metadata"], + install_requires=[ + "requests", + "fcache", + "mmh3", + "apscheduler", + "importlib_metadata", + "python-dateutil", + "semver < 3.0.0" + ], setup_requires=['setuptools_scm'], zip_safe=False, include_package_data=True, diff --git a/tests/specification_tests/test_13_constraint_operators.py b/tests/specification_tests/test_13_constraint_operators.py new file mode 100644 index 00000000..9b74356f --- /dev/null +++ b/tests/specification_tests/test_13_constraint_operators.py @@ -0,0 +1,846 @@ +import uuid +from datetime import datetime +import json +import pytest +import responses +from UnleashClient import UnleashClient +from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL +from tests.utilities.testing_constants import URL, APP_NAME + + +MOCK_JSON = """ +{ + "version": 1, + "features": [ + { + "name": "F1.startsWith", + "description": "startsWith", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "customField", + "operator": "STR_STARTS_WITH", + "values": ["some-string"] + + } + ] + } + ] + }, + { + "name": "F2.startsWith.multiple", + "description": "endsWith", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "customField", + "operator": "STR_STARTS_WITH", + "values": ["e1", "e2"] + + } + ] + } + ] + }, + { + "name": "F3.endsWith", + "description": "endsWith", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "email", + "operator": "STR_ENDS_WITH", + "values": ["@some-email.com"] + } + ] + } + ] + }, + { + "name": "F3.endsWith.ignoringCase", + "description": "endsWith", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "email", + "operator": "STR_ENDS_WITH", + "values": ["@some-email.com"], + "caseInsensitive": true + } + ] + } + ] + }, + { + "name": "F4.contains", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "email", + "operator": "STR_CONTAINS", + "values": ["email"] + + } + ] + } + ] + }, + { + "name": "F4.contains.inverted", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "email", + "operator": "STR_CONTAINS", + "values": ["email"], + "inverted": true + + } + ] + } + ] + }, + { + "name": "F5.numEq", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_EQ", + "value": "12" + } + ] + } + ] + }, + { + "name": "F5.numEq.float", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_EQ", + "value": "12.0" + } + ] + } + ] + }, + { + "name": "F5.numEq.inverted", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_EQ", + "value": "12", + "inverted": true + } + ] + } + ] + }, + { + "name": "F5.numGT", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_GT", + "value": "12" + } + ] + } + ] + }, + { + "name": "F5.numGTE", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_GTE", + "value": "12" + } + ] + } + ] + }, + { + "name": "F5.numLT", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_LT", + "value": "12" + } + ] + } + ] + }, + { + "name": "F5.numLTE", + "description": "contains", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_LTE", + "value": "12" + } + ] + } + ] + }, + { + "name": "F6.number-range", + "description": "range of numbers", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "someValue", + "operator": "NUM_GT", + "value": "12" + }, + { + "contextName": "someValue", + "operator": "NUM_LT", + "value": "16" + } + ] + } + ] + }, + { + "name": "F7.dateAfter", + "description": "dates", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "currentTime", + "operator": "DATE_AFTER", + "value": "2022-01-29T13:00:00.000Z" + } + ] + } + ] + }, + { + "name": "F7.dateBefore", + "description": "dates", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "currentTime", + "operator": "DATE_BEFORE", + "value": "2022-01-29T13:00:00.000Z" + } + ] + } + ] + }, + { + "name": "F7.date-range", + "description": "dates", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "currentTime", + "operator": "DATE_AFTER", + "value": "2022-01-22T13:00:00.000Z" + }, + { + "contextName": "currentTime", + "operator": "DATE_BEFORE", + "value": "2022-01-29T13:00:00.000Z" + } + ] + } + ] + } + + ] + } +""" + + +@pytest.fixture() +def unleash_client(): + unleash_client = UnleashClient(url=URL, + app_name=APP_NAME, + instance_id='pytest_%s' % uuid.uuid4()) + yield unleash_client + unleash_client.destroy() + + +@responses.activate +def test_feature_constraintoperators_startswith_enabled(unleash_client): + """ + F1.startsWith should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F1.startsWith", {'customField': 'some-string-is-cool'}) + + +@responses.activate +def test_feature_constraintoperators_startswith_disabled(unleash_client): + """ + F1.startsWith should be disabled" + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F1.startsWith", {'customField': 'some2-string-is-cool'}) + + +@responses.activate +def test_feature_constraintoperators_startswith_enabled_multiple(unleash_client): + """ + F2.startsWith.multiple should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F2.startsWith.multiple", {'customField': 'e2 cool'}) + + +@responses.activate +def test_feature_constraintoperators_startswith_disabled_multiple(unleash_client): + """ + F2.startsWith.multiple should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F2.startsWith.multiple", {'customField': 'cool e2'}) + + +@responses.activate +def test_feature_constraintoperators_endswith_enabled(unleash_client): + """ + F3.endsWith should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F3.endsWith", {'email': '@some-email.com'}) + + +@responses.activate +def test_feature_constraintoperators_endswith_incorrectcasing(unleash_client): + """ + F3.endsWith should be disabled when casing is incorrect + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F3.endsWith", {'email': '@some-EMAIL.com'}) + + +@responses.activate +def test_feature_constraintoperators_endswith_ignorecasing(unleash_client): + """ + F3.endsWith should be disabled when casing is incorrect + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F3.endsWith.ignoringCase", {'email': '@SOME-EMAIL.com'}) + + +@responses.activate +def test_feature_constraintoperators_endswith_disabled(unleash_client): + """ + F3.endsWith should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F3.endsWith", {'email': '@another-email.com'}) + + +@responses.activate +def test_feature_constraintoperators_contains_enabled(unleash_client): + """ + F4.contains should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F4.contains", {'email': '@some-email.com'}) + + +@responses.activate +def test_feature_constraintoperators_contains_disabled(unleash_client): + """ + F4.contains should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F4.contains", {'email': '@another.com'}) + + +@responses.activate +def test_feature_constraintoperators_contains_inverted(unleash_client): + """ + F4.contains.inverted should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F4.contains.inverted", {'email': '@another.com'}) + + +@responses.activate +def test_feature_constraintoperators_numeq_enabled(unleash_client): + """ + F5.numEq + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numEq", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numeq_enabled_floats(unleash_client): + """ + F5.numEq works for floats + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numEq", {'someValue': 12.0}) + + +@responses.activate +def test_feature_constraintoperators_numeq_inverted(unleash_client): + """ + F5.numEq.inverted should be true + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F5.numEq.inverted", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numeqfloat_enabled_floats(unleash_client): + """ + F5.numEq.float works for floats + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numEq.float", {'someValue': 12.0}) + + +@responses.activate +def test_feature_constraintoperators_numeqfloat_enabled_int(unleash_client): + """ + F5.numEq.float works for integers + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numEq.float", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numgt_enabled(unleash_client): + """ + F5.numGT should be true + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numGT", {'someValue': 13}) + + +@responses.activate +def test_feature_constraintoperators_numgt_disabled(unleash_client): + """ + F5.numGT should be false + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F5.numGT", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numgte_enabled_equal(unleash_client): + """ + F5.numGTE should be true when equal + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numGTE", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numgte_enabled_greater(unleash_client): + """ + F5.numGTE should be true when larger + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numGTE", {'someValue': 13}) + + +@responses.activate +def test_feature_constraintoperators_numgte_disabled(unleash_client): + """ + F5.numGTE should be false when lower + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F5.numGTE", {'someValue': 11}) + + +@responses.activate +def test_feature_constraintoperators_numlte_enabled_equal(unleash_client): + """ + F5.numLTE should be true when equal + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numLTE", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numlte_enabled_less(unleash_client): + """ + F5.numLTE should be true when lower + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numLTE", {'someValue': 0}) + + +@responses.activate +def test_feature_constraintoperators_numlt_enabled_less(unleash_client): + """ + F5.numLT should be true when lower + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F5.numLT", {'someValue': 0}) + + +@responses.activate +def test_feature_constraintoperators_numlt_disabled_equal(unleash_client): + """ + F5.numLT should be false when equal + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F5.numLT", {'someValue': 12}) + + +@responses.activate +def test_feature_constraintoperators_numberranger_disabled(unleash_client): + """ + F6.number-range should be false when not in range + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F6.number-range", {'someValue': 11}) + + +@responses.activate +def test_feature_constraintoperators_numberranger_enabled(unleash_client): + """ + F6.number-range should be true when in range + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F6.number-range", {'someValue': 14}) + + +@responses.activate +def test_feature_constraintoperators_dateafter_enabled(unleash_client): + """ + F7.dateAfter should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F7.dateAfter", {'currentTime': datetime(2022, 1, 30, 13)}) + + +@responses.activate +def test_feature_constraintoperators_dateafter_disabled(unleash_client): + """ + F7.dateAfter should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F7.dateAfter", {'currentTime': datetime(2022, 1, 28, 13)}) + + +@responses.activate +def test_feature_constraintoperators_dateafter_exclusive(unleash_client): + """ + F7.dateAfter should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F7.dateAfter", {'currentTime': datetime(2022, 1, 29, 13)}) + + +@responses.activate +def test_feature_constraintoperators_datebefore_enabled(unleash_client): + """ + F7.dateBefore should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F7.dateBefore", {'currentTime': datetime(2022, 1, 28, 13)}) + + +@responses.activate +def test_feature_constraintoperators_datebefore_disabled(unleash_client): + """ + F7.dateBefore should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F7.dateBefore", {'currentTime': datetime(2022, 1, 30, 13)}) + + +@responses.activate +def test_feature_constraintoperators_daterange(unleash_client): + """ + "F7.data-range should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F7.date-range", {'currentTime': datetime(2022, 1, 25, 13)}) + \ No newline at end of file diff --git a/tests/specification_tests/test_14_constraint_semver_operators.py b/tests/specification_tests/test_14_constraint_semver_operators.py new file mode 100644 index 00000000..bd1ad1a6 --- /dev/null +++ b/tests/specification_tests/test_14_constraint_semver_operators.py @@ -0,0 +1,420 @@ +import uuid +import json +import pytest +import responses +from UnleashClient import UnleashClient +from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL +from tests.utilities.testing_constants import URL, APP_NAME + + +MOCK_JSON = """ +{ + "version": 2, + "features": [ + { + "name": "F8.semverEQ", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_EQ", + "value": "1.2.2" + } + ] + } + ] + }, + { + "name": "F8.semverGT", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_GT", + "value": "1.2.2" + } + ] + } + ] + }, + { + "name": "F8.semverLT", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_LT", + "value": "1.2.2" + } + ] + } + ] + }, + { + "name": "F8.semverAlphaGT", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_GT", + "value": "2.0.0-alpha.1" + } + ] + } + ] + }, + { + "name": "F8.semverAlphaLT", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_LT", + "value": "2.0.0-alpha.1" + } + ] + } + ] + }, + { + "name": "F8.semverAlphaVersioning", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_LT", + "value": "2.0.0-alpha.3" + } + ] + } + ] + }, + { + "name": "F8.alphaUnnumbered", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_GT", + "value": "2.0.0-alpha" + } + ] + } + ] + }, + { + "name": "F8.releaseCandidate", + "description": "semver", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "version", + "operator": "SEMVER_GT", + "value": "2.0.0-rc" + } + ] + } + ] + } + ] +} +""" + + +@pytest.fixture() +def unleash_client(): + unleash_client = UnleashClient(url=URL, + app_name=APP_NAME, + instance_id='pytest_%s' % uuid.uuid4()) + yield unleash_client + unleash_client.destroy() + + +@responses.activate +def test_feature_constraintoperators_semvereq_enabled(unleash_client): + """ + F8.semverEQ should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverEQ", {'version': '1.2.2'}) + + +@responses.activate +def test_feature_constraintoperators_semvereq_disabled(unleash_client): + """ + F8.semverEQ should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverEQ", {'version': '1.2.0'}) + + +@responses.activate +def test_feature_constraintoperators_semvergt_enabled(unleash_client): + """ + F8.semverGT should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverGT", {'version': '1.2.3'}) + + +@responses.activate +def test_feature_constraintoperators_semvergt_disabled(unleash_client): + """ + F8.semverGT should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverGT", {'version': '1.2.0'}) + + +@responses.activate +def test_feature_constraintoperators_semverlt_enabled(unleash_client): + """ + F8.semverLT should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverLT", {'version': '1.2.1'}) + + +@responses.activate +def test_feature_constraintoperators_semverlt_disabled(unleash_client): + """ + F8.semverLT should be disabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverLT", {'version': '1.2.3'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphagt_beta(unleash_client): + """ + F8.semverAlphaGT is less than beta + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverAlphaGT", {'version': '2.0.0-beta.1'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphagt_release(unleash_client): + """ + F8.semverAlphaGT is less than release + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverAlphaGT", {'version': '2.0.0'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphalt_beta(unleash_client): + """ + F8.semverAlphaLT is less than beta + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverAlphaLT", {'version': '2.0.0-beta.1'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphalt_oldbeta(unleash_client): + """ + F8.semverAlphaLT is greater than old beta + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverAlphaLT", {'version': '1.9.1-beta.1'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphalt_release(unleash_client): + """ + F8.semverAlpha is less than release + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverAlphaLT", {'version': '2.0.0'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphalt_alphas_lt(unleash_client): + """ + F8.semverAlphaVersioning alpha.1 is less than alpha.3 + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.semverAlphaVersioning", {'version': '2.0.0-alpha.1'}) + + +@responses.activate +def test_feature_constraintoperators_semveralphalt_dif_alphas_gt(unleash_client): + """ + F8.semverAlphaVersioning alpha.4 is greater than alpha.3 + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.semverAlphaVersioning", {'version': '2.0.0-alpha.4'}) + + +@responses.activate +def test_feature_constraintoperators_semverunnumbered_lt(unleash_client): + """ + "F8.alphaUnnumbered - unnumbered is LT than numbered + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("F8.alphaUnnumbered", {'version': '2.0.0-alpha.1'}) + + +def test_feature_constraintoperators_semverrc_alpha(unleash_client): + """ + F8.releaseCandidate - alpha is not greater than rc + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.releaseCandidate", {'version': '2.0.0-alpha.1'}) + +def test_feature_constraintoperators_semverc_beta(unleash_client): + """ + F8.releaseCandidate - beta is not greater tha rc + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.releaseCandidate", {'version': '2.0.0-beta.1'}) + + +def test_feature_constraintoperators_semverc_release(unleash_client): + """ + F8.releaseCandidate - release is greater than rc + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("F8.releaseCandidate", {'version': '2.0.0'}) diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 2ce44e29..62e536a1 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -1,6 +1,7 @@ import time import json import warnings +from datetime import datetime import pytest import responses @@ -159,6 +160,10 @@ def test_uc_is_enabled(unleash_client): time.sleep(1) assert unleash_client.is_enabled("testFlag") + # Test default context values. + assert not unleash_client.is_enabled("testConstraintFlag") + assert unleash_client.is_enabled("testConstraintFlag", context={"currentTime": datetime(2022, 1, 21)}) + @responses.activate def test_uc_project(unleash_client_project): diff --git a/tests/unit_tests/test_constraints.py b/tests/unit_tests/test_constraints.py index 16715290..0a730abe 100644 --- a/tests/unit_tests/test_constraints.py +++ b/tests/unit_tests/test_constraints.py @@ -1,37 +1,19 @@ +from datetime import datetime +from sqlite3 import Date import pytest from UnleashClient.constraints import Constraint - - -CONSTRAINT_DICT_IN = \ - { - "contextName": "appName", - "operator": "IN", - "values": [ - "test", - "test2" - ] - } - - -CONSTRAINT_DICT_NOTIN = \ - { - "contextName": "appName", - "operator": "NOT_IN", - "values": [ - "test", - "test2" - ] - } +from tests.utilities.mocks import mock_constraints @pytest.fixture() def constraint_IN(): - yield Constraint(CONSTRAINT_DICT_IN) + yield Constraint(mock_constraints.CONSTRAINT_DICT_IN) @pytest.fixture() def constraint_NOTIN(): - yield Constraint(CONSTRAINT_DICT_NOTIN) + yield Constraint(mock_constraints.CONSTRAINT_DICT_NOTIN) + def test_constraint_IN_match(constraint_IN): @@ -73,3 +55,147 @@ def test_constraint_NOTIN_not_match(constraint_NOTIN): } assert constraint.apply(context) + + +def test_constraint_inversion(): + constraint_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_INVERT) + + assert not constraint_ci.apply({'customField': "adogb"}) + + +def test_constraint_STR_CONTAINS(): + constraint_not_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_CONTAINS_NOT_CI) + constraint_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_CONTAINS_CI) + + assert constraint_ci.apply({'customField': "adogb"}) + assert not constraint_ci.apply({'customField': "aparrotb"}) + assert constraint_ci.apply({'customField': "ahamsterb"}) + + assert constraint_not_ci.apply({'customField': "adogb"}) + assert not constraint_ci.apply({'customField': "aparrotb"}) + assert not constraint_not_ci.apply({'customField': "ahamsterb"}) + + +def test_constraint_STR_ENDS_WITH(): + constraint_not_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_ENDS_WITH_NOT_CI) + constraint_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_ENDS_WITH_CI) + + assert constraint_ci.apply({'customField': "adog"}) + assert not constraint_ci.apply({'customField': "aparrot"}) + assert constraint_ci.apply({'customField': "ahamster"}) + + assert constraint_not_ci.apply({'customField': "adog"}) + assert not constraint_not_ci.apply({'customField': "aparrot"}) + assert not constraint_not_ci.apply({'customField': "ahamster"}) + + +def test_constraint_STR_STARTS_WITH(): + constraint_not_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_STARTS_WITH_NOT_CI) + constraint_ci = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DICT_STR_STARTS_WITH_CI) + + assert constraint_ci.apply({'customField': "dogb"}) + assert not constraint_ci.apply({'customField': "parrotb"}) + assert constraint_ci.apply({'customField': "hamsterb"}) + + assert constraint_not_ci.apply({'customField': "dogb"}) + assert not constraint_not_ci.apply({'customField': "parrotb"}) + assert not constraint_not_ci.apply({'customField': "hamsterb"}) + + +def test_constraints_NUM_EQ(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_EQ) + + assert not constraint.apply({'customField': 4}) + assert constraint.apply({'customField': 5}) + assert not constraint.apply({'customField': 6}) + + +def test_constraints_NUM_GT(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_GT) + + assert not constraint.apply({'customField': 4}) + assert not constraint.apply({'customField': 5}) + assert constraint.apply({'customField': 6}) + + +def test_constraints_NUM_GTE(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_GTE) + + assert not constraint.apply({'customField': 4}) + assert constraint.apply({'customField': 5}) + assert constraint.apply({'customField': 6}) + + +def test_constraints_NUM_LT(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_LT) + + assert constraint.apply({'customField': 4}) + assert not constraint.apply({'customField': 5}) + assert not constraint.apply({'customField': 6}) + + +def test_constraints_NUM_LTE(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_LTE) + + assert constraint.apply({'customField': 4}) + assert constraint.apply({'customField': 5}) + assert not constraint.apply({'customField': 6}) + + +def test_constraints_NUM_FLOAT(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_NUM_FLOAT) + + assert constraint.apply({'customField': 5}) + assert constraint.apply({'customField': 5.1}) + assert not constraint.apply({'customField': 5.2}) + + +def test_constraints_DATE_AFTER(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_AFTER) + + assert constraint.apply({'currentTime': datetime(2022, 1, 23)}) + assert not constraint.apply({'currentTime': datetime(2022, 1, 22)}) + assert not constraint.apply({'currentTime': datetime(2022, 1, 21)}) + + +def test_constraints_DATE_BEFORE(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_BEFORE) + + assert not constraint.apply({'currentTime': datetime(2022, 1, 23)}) + assert not constraint.apply({'currentTime': datetime(2022, 1, 22)}) + assert constraint.apply({'currentTime': datetime(2022, 1, 21)}) + + +def test_constraints_date_error(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_ERROR) + assert not constraint.apply({'currentTime': datetime(2022, 1, 23)}) + + +def test_constraints_SEMVER_EQ(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_SEMVER_EQ) + + assert not constraint.apply({'customField': '1.2.1'}) + assert constraint.apply({'customField': '1.2.2'}) + assert not constraint.apply({'customField': '1.2.3'}) + + +def test_constraints_SEMVER_GT(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_SEMVER_GT) + + assert not constraint.apply({'customField': '1.2.1'}) + assert not constraint.apply({'customField': '1.2.2'}) + assert constraint.apply({'customField': '1.2.3'}) + + +def test_constraints_SEMVER_LT(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_SEMVER_LT) + + assert constraint.apply({'customField': '1.2.1'}) + assert not constraint.apply({'customField': '1.2.2'}) + assert not constraint.apply({'customField': '1.2.3'}) + + +def test_constraints_semverexception(): + constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_SEMVER_EQ) + + assert not constraint.apply({'customField': 'hamstershamsterhamsters'}) diff --git a/tests/utilities/mocks/mock_constraints.py b/tests/utilities/mocks/mock_constraints.py new file mode 100644 index 00000000..6437a23a --- /dev/null +++ b/tests/utilities/mocks/mock_constraints.py @@ -0,0 +1,196 @@ +CONSTRAINT_DICT_IN = \ + { + "contextName": "appName", + "operator": "IN", + "values": [ + "test", + "test2" + ] + } + + +CONSTRAINT_DICT_NOTIN = \ + { + "contextName": "appName", + "operator": "NOT_IN", + "values": [ + "test", + "test2" + ] + } + + +CONSTRAINT_DICT_STR_INVERT = \ + { + "contextName": "customField", + "operator": "STR_CONTAINS", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": True, + "inverted": True + } + +CONSTRAINT_DICT_STR_CONTAINS_CI = \ + { + "contextName": "customField", + "operator": "STR_CONTAINS", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": True, + "inverted": False + } + + +CONSTRAINT_DICT_STR_CONTAINS_NOT_CI = \ + { + "contextName": "customField", + "operator": "STR_CONTAINS", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": False, + "inverted": False + } + + +CONSTRAINT_DICT_STR_ENDS_WITH_CI = \ + { + "contextName": "customField", + "operator": "STR_ENDS_WITH", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": True, + "inverted": False + } + +CONSTRAINT_DICT_STR_ENDS_WITH_NOT_CI = \ + { + "contextName": "customField", + "operator": "STR_ENDS_WITH", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": False, + "inverted": False + } + + +CONSTRAINT_DICT_STR_STARTS_WITH_CI = \ + { + "contextName": "customField", + "operator": "STR_STARTS_WITH", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": True, + "inverted": False + } + + +CONSTRAINT_DICT_STR_STARTS_WITH_NOT_CI = \ + { + "contextName": "customField", + "operator": "STR_STARTS_WITH", + "values": ["dog", "cat", "hAmStEr"], + "caseInsensitive": False, + "inverted": False + } + + +CONSTRAINT_NUM_EQ = \ + { + "contextName": "customField", + "operator": "NUM_EQ", + "value": "5", + "inverted": False + } + + +CONSTRAINT_NUM_GT = \ + { + "contextName": "customField", + "operator": "NUM_GT", + "value": "5", + "inverted": False + } + + +CONSTRAINT_NUM_GTE = \ + { + "contextName": "customField", + "operator": "NUM_GTE", + "value": 5, + "inverted": False + } + + +CONSTRAINT_NUM_LT = \ + { + "contextName": "customField", + "operator": "NUM_LT", + "value": "5", + "inverted": False + } + + +CONSTRAINT_NUM_LTE = \ + { + "contextName": "customField", + "operator": "NUM_LTE", + "value": "5", + "inverted": False + } + + +CONSTRAINT_NUM_FLOAT = \ + { + "contextName": "customField", + "operator": "NUM_LTE", + "value": "5.1", + "inverted": False + } + + +CONSTRAINT_DATE_AFTER = \ + { + "contextName": "currentTime", + "operator": "DATE_AFTER", + "value": "2022-01-22T00:00:00.000Z", + "inverted": False + } + + +CONSTRAINT_DATE_BEFORE = \ + { + "contextName": "currentTime", + "operator": "DATE_BEFORE", + "value": "2022-01-22T00:00:00.000Z", + "inverted": False + } + + +CONSTRAINT_DATE_ERROR = \ + { + "contextName": "currentTime", + "operator": "DATE_AFTER", + "value": "abcd", + "inverted": False + } + + +CONSTRAINT_SEMVER_EQ = \ + { + "contextName": "customField", + "operator": "SEMVER_EQ", + "value": "1.2.2", + "inverted": False + } + + +CONSTRAINT_SEMVER_GT = \ + { + "contextName": "customField", + "operator": "SEMVER_GT", + "value": "1.2.2", + "inverted": False + } + + +CONSTRAINT_SEMVER_LT = \ + { + "contextName": "customField", + "operator": "SEMVER_LT", + "value": "1.2.2", + "inverted": False + } diff --git a/tests/utilities/mocks/mock_features.py b/tests/utilities/mocks/mock_features.py index deac2549..526c56c9 100644 --- a/tests/utilities/mocks/mock_features.py +++ b/tests/utilities/mocks/mock_features.py @@ -41,6 +41,26 @@ ], "createdAt": "2018-10-04T01:27:28.477Z" }, + { + "name": "testConstraintFlag", + "description": "This is a flag with a constraint!", + "enabled": True, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "currentTime", + "operator": "DATE_BEFORE", + "value": "2022-01-22T00:00:00.000Z", + "inverted": False + } + ], + }, + ], + "createdAt": "2018-10-04T01:27:28.477Z" + }, { "name": "testVariations", "description": "Test variation", diff --git a/tox.ini b/tox.ini index 3f29f9cd..cf238180 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ envlist = py36,py37,py38,py39,py310 [testenv] deps = -rrequirements.txt commands = - mypy UnleashClient + mypy UnleashClient --install-types --non-interactive pylint UnleashClient py.test tests/unit_tests py.test tests/specification_tests