diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 880804d3..d848899b 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -487,10 +487,10 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, Returns: True if the condition is met, False otherwise. """ - custom_signal_operator = custom_signal_condition.get('customSignalOperator') or {} - custom_signal_key = custom_signal_condition.get('customSignalKey') or {} + custom_signal_operator = custom_signal_condition.get('customSignalOperator') or '' + custom_signal_key = custom_signal_condition.get('customSignalKey') or '' target_custom_signal_values = ( - custom_signal_condition.get('targetCustomSignalValues') or {}) + custom_signal_condition.get('targetCustomSignalValues') 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.") @@ -498,9 +498,10 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, if not target_custom_signal_values: return False - actual_custom_signal_value = context.get(custom_signal_key) or {} + actual_custom_signal_value = context.get(custom_signal_key) - if not actual_custom_signal_value: + # Falsy signal values such as 0 are valid; only a missing signal is skipped. + if actual_custom_signal_value is None: logger.debug("Custom signal value not found in context: %s", custom_signal_key) return False @@ -613,7 +614,7 @@ def _compare_numbers(self, custom_signal_key, target_value, actual_value, predic actual = float(actual_value) result = -1 if actual < target else 1 if actual > target else 0 return predicate_fn(result) - except ValueError: + except (ValueError, TypeError): logger.warning("Invalid numeric value for comparison for custom signal key %s.", custom_signal_key) return False @@ -739,7 +740,7 @@ def as_boolean(self) -> bool: return self.DEFAULT_VALUE_FOR_BOOLEAN return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES - def as_int(self) -> float: + def as_int(self) -> int: """Returns the value as a number.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_INTEGER diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 7bbf9721..c4cb4f19 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -820,6 +820,83 @@ def test_evaluate_custom_signal_semantic_version(self, assert server_config.get_boolean('is_enabled') == parameter_value + @pytest.mark.parametrize( + 'custom_signal_operator, target_custom_signal_value, context_value, parameter_value', + [ + (CustomSignalOperator.NUMERIC_EQUAL.value, ['0'], 0, True), + (CustomSignalOperator.NUMERIC_LESS_THAN.value, ['1'], 0, True), + (CustomSignalOperator.NUMERIC_GREATER_THAN.value, ['-1'], 0.0, True), + (CustomSignalOperator.NUMERIC_GREATER_THAN.value, ['0'], 0, False), + (CustomSignalOperator.STRING_EXACTLY_MATCHES.value, ['0'], 0, True), + ]) + def test_evaluate_custom_signal_zero_value(self, + custom_signal_operator, + target_custom_signal_value, + context_value, + parameter_value): + server_template = self._custom_signal_template(custom_signal_operator, + target_custom_signal_value) + context = {'randomization_id': '123', 'signal_key': context_value} + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') == parameter_value + + @pytest.mark.parametrize('context_value', [[], {}]) + @pytest.mark.parametrize('operator', [ + CustomSignalOperator.NUMERIC_LESS_THAN, + CustomSignalOperator.NUMERIC_LESS_EQUAL, + CustomSignalOperator.NUMERIC_EQUAL, + CustomSignalOperator.NUMERIC_NOT_EQUAL, + CustomSignalOperator.NUMERIC_GREATER_THAN, + CustomSignalOperator.NUMERIC_GREATER_EQUAL, + ]) + def test_evaluate_custom_signal_non_numeric_value(self, context_value, operator): + server_template = self._custom_signal_template(operator.value, ['0']) + server_config = server_template.evaluate({'signal_key': context_value}) + assert server_config.get_boolean('is_enabled') is False + + def test_evaluate_custom_signal_missing_value(self): + server_template = self._custom_signal_template( + CustomSignalOperator.NUMERIC_LESS_THAN.value, ['1']) + server_config = server_template.evaluate({'randomization_id': '123'}) + assert server_config.get_boolean('is_enabled') is False + + def _custom_signal_template(self, custom_signal_operator, target_custom_signal_value): + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'customSignal': { + 'customSignalOperator': custom_signal_operator, + 'customSignalKey': 'signal_key', + 'targetCustomSignalValues': target_custom_signal_value + } + }], + } + }] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + return remote_config.init_server_template( + app=firebase_admin.get_app(), + default_config={'dog_is_cute': True}, + template_data_json=json.dumps(template_data) + ) + class MockAdapter(testutils.MockAdapter): """A Mock HTTP Adapter that provides Firebase Remote Config responses with ETag in header."""