From 6f9591bbf03c8084fc8e65e37380e938e4345870 Mon Sep 17 00:00:00 2001 From: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Date: Sat, 16 Nov 2024 01:41:20 +0530 Subject: [PATCH] Implementation for Evaluating Condition and Custom Signals Server Side Remote Config (#824) * Added implemenation of evaluate function * Improvement * Add farmhash to extension whitelist pkg * Replace farmhash to hashlib * Added unit testcase * removed lint error * add mock test * resolve lint comments * Fixed bug * Added fixes * Added fixe * Added fix for lint * Changed structure of test * Added fix for comments * Added fix for comments --------- Co-authored-by: Varun Rathore --- firebase_admin/remote_config.py | 492 ++++++++++++++++++++- tests/test_remote_config.py | 748 +++++++++++++++++++++++++++++++- tests/testutils.py | 40 ++ 3 files changed, 1261 insertions(+), 19 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index cd7d17f6f..c975b7c12 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -17,12 +17,51 @@ """ import asyncio -from typing import Any, Dict, Optional +import logging +from typing import Dict, Optional, Literal, Union, Any +from enum import Enum +import re +import hashlib import requests from firebase_admin import App, _http_client, _utils import firebase_admin +# Set up logging (you can customize the level and output) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + _REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig' +MAX_CONDITION_RECURSION_DEPTH = 10 +ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type + +class PercentConditionOperator(Enum): + """Enum representing the available operators for percent conditions. + """ + LESS_OR_EQUAL = "LESS_OR_EQUAL" + GREATER_THAN = "GREATER_THAN" + BETWEEN = "BETWEEN" + UNKNOWN = "UNKNOWN" + +class CustomSignalOperator(Enum): + """Enum representing the available operators for custom signal conditions. + """ + STRING_CONTAINS = "STRING_CONTAINS" + STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN" + STRING_EXACTLY_MATCHES = "STRING_EXACTLY_MATCHES" + STRING_CONTAINS_REGEX = "STRING_CONTAINS_REGEX" + NUMERIC_LESS_THAN = "NUMERIC_LESS_THAN" + NUMERIC_LESS_EQUAL = "NUMERIC_LESS_EQUAL" + NUMERIC_EQUAL = "NUMERIC_EQUAL" + NUMERIC_NOT_EQUAL = "NUMERIC_NOT_EQUAL" + NUMERIC_GREATER_THAN = "NUMERIC_GREATER_THAN" + NUMERIC_GREATER_EQUAL = "NUMERIC_GREATER_EQUAL" + SEMANTIC_VERSION_LESS_THAN = "SEMANTIC_VERSION_LESS_THAN" + SEMANTIC_VERSION_LESS_EQUAL = "SEMANTIC_VERSION_LESS_EQUAL" + SEMANTIC_VERSION_EQUAL = "SEMANTIC_VERSION_EQUAL" + SEMANTIC_VERSION_NOT_EQUAL = "SEMANTIC_VERSION_NOT_EQUAL" + SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN" + SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" + UNKNOWN = "UNKNOWN" class ServerTemplateData: """Parses, validates and encapsulates template data and metadata.""" @@ -89,7 +128,6 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N """ self._rc_service = _utils.get_app_service(app, _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) - # This gets set when the template is # fetched from RC servers via the load API, or via the set API. self._cache = None @@ -106,10 +144,30 @@ async def load(self): """Fetches the server template and caches the data.""" self._cache = await self._rc_service.get_server_template() - def evaluate(self): + def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig': + """Evaluates the cached server template to produce a ServerConfig. + + Args: + context: A dictionary of values to use for evaluating conditions. + + Returns: + A ServerConfig object. + Raises: + ValueError: If the input arguments are invalid. + """ # Logic to process the cached template into a ServerConfig here. - # TODO: Add and validate Condition evaluator. - self._evaluator = _ConditionEvaluator(self._cache.parameters) + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling evaluate().""") + context = context or {} + config_values = {} + # Initializes config Value objects with default values. + if self._stringified_default_config is not None: + for key, value in self._stringified_default_config.items(): + config_values[key] = _Value('default', value) + self._evaluator = _ConditionEvaluator(self._cache.conditions, + self._cache.parameters, context, + config_values) return ServerConfig(config_values=self._evaluator.evaluate()) def set(self, template: ServerTemplateData): @@ -127,16 +185,21 @@ def __init__(self, config_values): self._config_values = config_values # dictionary of param key to values def get_boolean(self, key): - return bool(self.get_value(key)) + return self.get_value(key).as_boolean() def get_string(self, key): - return str(self.get_value(key)) + return self.get_value(key).as_string() def get_int(self, key): - return int(self.get_value(key)) + return self.get_value(key).as_int() + + def get_float(self, key): + return self.get_value(key).as_float() def get_value(self, key): - return self._config_values[key] + if self._config_values[key]: + return self._config_values[key] + return _Value('static') class _RemoteConfigService: @@ -188,13 +251,371 @@ def _handle_remote_config_error(cls, error: Any): class _ConditionEvaluator: """Internal class that facilitates sending requests to the Firebase Remote Config backend API.""" - def __init__(self, parameters): + def __init__(self, conditions, parameters, context, config_values): + self._context = context + self._conditions = conditions self._parameters = parameters + self._config_values = config_values def evaluate(self): - # TODO: Write logic for evaluator - return self._parameters + """Internal function Evaluates the cached server template to produce + a ServerConfig""" + evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) + + # Overlays config Value objects derived by evaluating the template. + if self._parameters: + for key, parameter in self._parameters.items(): + conditional_values = parameter.get('conditionalValues', {}) + default_value = parameter.get('defaultValue', {}) + parameter_value_wrapper = None + # Iterates in order over condition list. If there is a value associated + # with a condition, this checks if the condition is true. + if evaluated_conditions: + for condition_name, condition_evaluation in evaluated_conditions.items(): + if condition_name in conditional_values and condition_evaluation: + parameter_value_wrapper = conditional_values[condition_name] + break + + if parameter_value_wrapper and parameter_value_wrapper.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + + if parameter_value_wrapper: + parameter_value = parameter_value_wrapper.get('value') + self._config_values[key] = _Value('remote', parameter_value) + continue + + if not default_value: + logger.warning("No default value found for key '%s'", key) + continue + + if default_value.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + self._config_values[key] = _Value('remote', default_value.get('value')) + return self._config_values + + def evaluate_conditions(self, conditions, context)-> Dict[str, bool]: + """Evaluates a list of conditions and returns a dictionary of results. + + Args: + conditions: A list of NamedCondition objects. + context: An EvaluationContext object. + + Returns: + A dictionary mapping condition names to boolean evaluation results. + """ + evaluated_conditions = {} + for condition in conditions: + evaluated_conditions[condition.get('name')] = self.evaluate_condition( + condition.get('condition'), context + ) + return evaluated_conditions + + def evaluate_condition(self, condition, context, + nesting_level: int = 0) -> bool: + """Recursively evaluates a condition. + + Args: + condition: The condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + The boolean result of the condition evaluation. + """ + if nesting_level >= MAX_CONDITION_RECURSION_DEPTH: + logger.warning("Maximum condition recursion depth exceeded.") + return False + if condition.get('orCondition') is not None: + return self.evaluate_or_condition(condition.get('orCondition'), + context, nesting_level + 1) + if condition.get('andCondition') is not None: + return self.evaluate_and_condition(condition.get('andCondition'), + context, nesting_level + 1) + if condition.get('true') is not None: + return True + if condition.get('false') is not None: + return False + if condition.get('percent') is not None: + return self.evaluate_percent_condition(condition.get('percent'), context) + if condition.get('customSignal') is not None: + return self.evaluate_custom_signal_condition(condition.get('customSignal'), context) + logger.warning("Unknown condition type encountered.") + return False + + def evaluate_or_condition(self, or_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an OR condition. + + Args: + or_condition: The OR condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if any of the subconditions are true, False otherwise. + """ + sub_conditions = or_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if result: + return True + return False + + def evaluate_and_condition(self, and_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an AND condition. + + Args: + and_condition: The AND condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if all of the subconditions are true, False otherwise. + """ + sub_conditions = and_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if not result: + return False + return True + + def evaluate_percent_condition(self, percent_condition, + context) -> bool: + """Evaluates a percent condition. + + Args: + percent_condition: The percent condition to evaluate. + context: An EvaluationContext object. + Returns: + True if the condition is met, False otherwise. + """ + if not context.get('randomization_id'): + logger.warning("Missing randomization ID for percent condition.") + return False + + seed = percent_condition.get('seed') + percent_operator = percent_condition.get('percentOperator') + micro_percent = percent_condition.get('microPercent') + micro_percent_range = percent_condition.get('microPercentRange') + if not percent_operator: + logger.warning("Missing percent operator for percent condition.") + return False + if micro_percent_range: + norm_percent_upper_bound = micro_percent_range.get('microPercentUpperBound') + norm_percent_lower_bound = micro_percent_range.get('microPercentLowerBound') + else: + norm_percent_upper_bound = 0 + norm_percent_lower_bound = 0 + if micro_percent: + norm_micro_percent = micro_percent + else: + norm_micro_percent = 0 + seed_prefix = f"{seed}." if seed else "" + string_to_hash = f"{seed_prefix}{context.get('randomization_id')}" + + hash64 = self.hash_seeded_randomization_id(string_to_hash) + instance_micro_percentile = hash64 % (100 * 1000000) + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL: + return instance_micro_percentile <= norm_micro_percent + if percent_operator == PercentConditionOperator.GREATER_THAN: + return instance_micro_percentile > norm_micro_percent + if percent_operator == PercentConditionOperator.BETWEEN: + return norm_percent_lower_bound < instance_micro_percentile <= norm_percent_upper_bound + logger.warning("Unknown percent operator: %s", percent_operator) + return False + def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: + """Hashes a seeded randomization ID. + + Args: + seeded_randomization_id: The seeded randomization ID to hash. + + Returns: + The hashed value. + """ + hash_object = hashlib.sha256() + hash_object.update(seeded_randomization_id.encode('utf-8')) + hash64 = hash_object.hexdigest() + return abs(int(hash64, 16)) + + def evaluate_custom_signal_condition(self, custom_signal_condition, + context) -> bool: + """Evaluates a custom signal condition. + + Args: + custom_signal_condition: The custom signal condition to evaluate. + context: An EvaluationContext object. + + Returns: + True if the condition is met, False otherwise. + """ + custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} + custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} + target_custom_signal_values = ( + custom_signal_condition.get('target_custom_signal_values') or {}) + + if not all([custom_signal_operator, custom_signal_key, target_custom_signal_values]): + logger.warning("Missing operator, key, or target values for custom signal condition.") + return False + + if not target_custom_signal_values: + return False + actual_custom_signal_value = context.get(custom_signal_key) or {} + + if not actual_custom_signal_value: + logger.warning("Custom signal value not found in context: %s", custom_signal_key) + return False + + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + return not self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target.strip() == actual.strip()) + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + re.search) + + # For numeric operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + + # For semantic operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + return False + + def _compare_strings(self, target_values, actual_value, predicate_fn) -> bool: + """Compares the actual string value of a signal against a list of target values. + + Args: + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes two string arguments (target and actual) + and returns a boolean indicating whether + the target matches the actual value. + + Returns: + bool: True if the predicate function returns True for any target value in the list, + False otherwise. + """ + + for target in target_values: + if predicate_fn(target, str(actual_value)): + return True + return False + + def _compare_numbers(self, target_value, actual_value, predicate_fn) -> bool: + try: + target = float(target_value) + actual = float(actual_value) + result = -1 if actual < target else 1 if actual > target else 0 + return predicate_fn(result) + except ValueError: + logger.warning("Invalid numeric value for comparison.") + return False + + def _compare_semantic_versions(self, target_value, actual_value, predicate_fn) -> bool: + """Compares the actual semantic version value of a signal against a target value. + Calls the predicate function with -1, 0, 1 if actual is less than, equal to, + or greater than target. + + Args: + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes an integer (-1, 0, or 1) and returns a boolean. + + Returns: + bool: True if the predicate function returns True for the result of the comparison, + False otherwise. + """ + return self._compare_versions(str(actual_value), + str(target_value), predicate_fn) + + def _compare_versions(self, version1, version2, predicate_fn) -> bool: + """Compares two semantic version strings. + + Args: + version1: The first semantic version string. + version2: The second semantic version string. + predicate_fn: A function that takes an integer and returns a boolean. + + Returns: + bool: The result of the predicate function. + """ + try: + v1_parts = [int(part) for part in version1.split('.')] + v2_parts = [int(part) for part in version2.split('.')] + max_length = max(len(v1_parts), len(v2_parts)) + v1_parts.extend([0] * (max_length - len(v1_parts))) + v2_parts.extend([0] * (max_length - len(v2_parts))) + + for part1, part2 in zip(v1_parts, v2_parts): + if part1 < part2: + return predicate_fn(-1) + if part1 > part2: + return predicate_fn(1) + return predicate_fn(0) + except ValueError: + logger.warning("Invalid semantic version format for comparison.") + return False async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None): """Initializes a new ServerTemplate instance and fetches the server template. @@ -229,3 +650,50 @@ def init_server_template(app: App = None, default_config: Optional[Dict[str, str if template_data is not None: template.set(template_data) return template + +class _Value: + """Represents a value fetched from Remote Config. + """ + DEFAULT_VALUE_FOR_BOOLEAN = False + DEFAULT_VALUE_FOR_STRING = '' + DEFAULT_VALUE_FOR_INTEGER = 0 + DEFAULT_VALUE_FOR_FLOAT_NUMBER = 0.0 + BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on'] + + def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): + """Initializes a Value instance. + + Args: + source: The source of the value (e.g., 'default', 'remote', 'static'). + value: The string value. + """ + self.source = source + self.value = value + + def as_string(self) -> str: + """Returns the value as a string.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_STRING + return self.value + + def as_boolean(self) -> bool: + """Returns the value as a boolean.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_BOOLEAN + return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES + + def as_int(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_INTEGER + return self.value + + def as_float(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER + return float(self.value) + + def get_source(self) -> ValueSource: + """Returns the source of the value.""" + return self.source diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 6b0b171cd..914b99cb1 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -14,15 +14,743 @@ """Tests for firebase_admin.remote_config.""" import json +import uuid import pytest import firebase_admin -from firebase_admin import remote_config -from firebase_admin.remote_config import _REMOTE_CONFIG_ATTRIBUTE -from firebase_admin.remote_config import _RemoteConfigService, ServerTemplateData - -from firebase_admin import _utils +from firebase_admin.remote_config import ( + PercentConditionOperator, + _REMOTE_CONFIG_ATTRIBUTE, + _RemoteConfigService, + ServerTemplateData) +from firebase_admin import remote_config, _utils from tests import testutils +VERSION_INFO = { + 'versionNumber': '86', + 'updateOrigin': 'ADMIN_SDK_PYTHON', + 'updateType': 'INCREMENTAL_UPDATE', + 'updateUser': { + 'email': 'firebase-adminsdk@gserviceaccount.com' + }, + 'description': 'production version', + 'updateTime': '2024-11-05T16:45:03.541527Z' + } + +SERVER_REMOTE_CONFIG_RESPONSE = { + 'conditions': [ + { + 'name': 'ios', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + {'true': {}} + ] + } + } + ] + } + } + }, + ], + 'parameters': { + 'holiday_promo_enabled': { + 'defaultValue': {'value': 'true'}, + 'conditionalValues': {'ios': {'useInAppDefault': 'true'}} + }, + }, + 'parameterGroups': '', + 'etag': 'etag-123456789012-5', + 'version': VERSION_INFO, + } + +class TestEvaluate: + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_evaluate_or_and_true_condition_true(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'true': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_evaluate_or_and_false_condition_false(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'false': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_non_or_condition(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'true': { + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_evaluate_return_conditional_values_honor_order(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + template_data = { + 'conditions': [ + { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + }, + { + 'name': 'is_true_too', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + } + ], + 'parameters': { + 'dog_type': { + 'defaultValue': {'value': 'chihuahua'}, + 'conditionalValues': { + 'is_true_too': {'value': 'dachshund'}, + 'is_true': {'value': 'corgi'} + } + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'corgi' + + def test_evaluate_default_when_no_param(self): + app = firebase_admin.get_app() + default_config = {'promo_enabled': False, 'promo_discount': '20',} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') + assert server_config.get_int('promo_discount') == default_config.get('promo_discount') + + def test_evaluate_default_when_no_default_value(self): + app = firebase_admin.get_app() + default_config = {'default_value': 'local default'} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'default_value': {} + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('default_value') == default_config.get('default_value') + + def test_evaluate_default_when_in_default(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'remote_default_value': {} + } + default_config = { + 'inapp_default': '🐕' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('inapp_default') == default_config.get('inapp_default') + + def test_evaluate_default_when_defined(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + default_config = { + 'dog_type': 'shiba' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_value('dog_type').as_string() == 'shiba' + assert server_config.get_value('dog_type').get_source() == 'default' + + def test_evaluate_return_numeric_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_age': '12' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_int('dog_age') == default_config.get('dog_age') + + def test_evaluate_return_boolean_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_is_cute': True + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('dog_is_cute') + + def test_evaluate_unknown_operator_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.UNKNOWN + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'seed': 'abcdef', + 'microPercent': 100_000_000 + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercent_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercentrange_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_between_min_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 0, + 'microPercentUpperBound': 100_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_between_equal_bounds_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 50000000, + 'microPercentUpperBound': 50000000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'seed': 'abcdef', + 'microPercent': 10_000_000 # 10% + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 284 + assert truthy_assignments >= 10000 - tolerance + assert truthy_assignments <= 10000 + tolerance + + def test_evaluate_between_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 40_000_000, + 'microPercentUpperBound': 60_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 379 + assert truthy_assignments >= 20000 - tolerance + assert truthy_assignments <= 20000 + tolerance + + def test_evaluate_between_interquartile_range_accuracy(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 25_000_000, + 'microPercentUpperBound': 75_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 474 + assert truthy_assignments >= 50000 - tolerance + assert truthy_assignments <= 50000 + tolerance + + def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config): + """Evaluates random assignments based on a condition. + + Args: + condition: The condition to evaluate. + num_of_assignments: The number of assignments to generate. + condition_evaluator: An instance of the ConditionEvaluator class. + + Returns: + int: The number of assignments that evaluated to true. + """ + eval_true_count = 0 + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + for _ in range(num_of_assignments): + context = {'randomization_id': str(uuid.uuid4())} + result = server_template.evaluate(context) + if result.get_boolean('is_enabled') is True: + eval_true_count += 1 + + return eval_true_count + + class MockAdapter(testutils.MockAdapter): """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" @@ -109,7 +837,10 @@ def test_init_server_template(self): template_data = { 'conditions': [], 'parameters': { - 'test_key': 'test_value' + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } }, 'version': '', } @@ -132,7 +863,10 @@ async def test_get_server_template(self): recorder = [] response = json.dumps({ 'parameters': { - 'test_key': 'test_value' + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } }, 'conditions': [], 'version': 'test' diff --git a/tests/testutils.py b/tests/testutils.py index ab4fb40cb..17013b469 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -218,3 +218,43 @@ def send(self, request, **kwargs): # pylint: disable=arguments-differ resp.raw = io.BytesIO(response.encode()) break return resp + +def build_mock_condition(name, condition): + return { + 'name': name, + 'condition': condition, + } + +def build_mock_parameter(name, description, value=None, + conditional_values=None, default_value=None, parameter_groups=None): + return { + 'name': name, + 'description': description, + 'value': value, + 'conditionalValues': conditional_values, + 'defaultValue': default_value, + 'parameterGroups': parameter_groups, + } + +def build_mock_conditional_value(condition_name, value): + return { + 'conditionName': condition_name, + 'value': value, + } + +def build_mock_default_value(value): + return { + 'value': value, + } + +def build_mock_parameter_group(name, description, parameters): + return { + 'name': name, + 'description': description, + 'parameters': parameters, + } + +def build_mock_version(version_number): + return { + 'versionNumber': version_number, + }