diff --git a/optimizely/event/event_factory.py b/optimizely/event/event_factory.py index f66c1d59..b6a9c02a 100644 --- a/optimizely/event/event_factory.py +++ b/optimizely/event/event_factory.py @@ -1,4 +1,4 @@ -# Copyright 2019, 2022, Optimizely +# Copyright 2019, 2022, 2026, Optimizely # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -18,6 +18,7 @@ from optimizely.helpers import enums from optimizely.helpers import event_tag_utils from optimizely.helpers import validator +from . import event_id_normalizer from . import log_event from . import payload from . import user_event @@ -134,12 +135,19 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger) if isinstance(event.experiment, entities.Experiment): experiment_layerId = event.experiment.layerId + normalized_campaign_id = event_id_normalizer.normalize_campaign_id( + experiment_layerId, experiment_id + ) + normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id) + metadata = payload.Metadata(event.flag_key, event.rule_key, event.rule_type, variation_key, event.enabled, event.cmab_uuid) - decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata) + decision = payload.Decision( + normalized_campaign_id, experiment_id, normalized_variation_id, metadata + ) snapshot_event = payload.SnapshotEvent( - experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp, + normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp, ) snapshot = payload.Snapshot([snapshot_event], [decision]) diff --git a/optimizely/event/event_id_normalizer.py b/optimizely/event/event_id_normalizer.py new file mode 100644 index 00000000..f2d4235d --- /dev/null +++ b/optimizely/event/event_id_normalizer.py @@ -0,0 +1,93 @@ +# Copyright 2026, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Normalization helpers for decision-event ID fields. + +This module provides byte-equivalent, cross-SDK normalization for the +``campaign_id``, ``variation_id``, and impression ``entity_id`` fields that +appear in dispatched decision events. + +Rules: + * ``campaign_id`` and impression ``entity_id`` accept **any non-empty + string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` / + ``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the + value is the empty string, ``None``, or missing. Non-string types are + out of scope for this normalization path (the upstream datafile + producer delivers string or null values). + * ``variation_id`` retains the stricter contract: it MUST be a non-empty + string of decimal digits ``0-9`` (leading zeros allowed). Empty, + whitespace, non-string, and non-numeric inputs are normalized to + ``None`` so the wire payload carries an explicit null. + * ``entity_id`` on impression events shares the campaign_id normalization + and is therefore byte-equivalent to the normalized campaign_id for the + same impression. + +The normalization path MUST NOT log, warn, or raise. It must never drop or +defer event dispatch. +""" + + +from sys import version_info +from typing import Any, Optional + +if version_info < (3, 10): + from typing_extensions import TypeGuard +else: + from typing import TypeGuard + + +def is_non_empty_string(value: Any) -> TypeGuard[str]: + """Return ``True`` if ``value`` is a non-empty :class:`str`. + Any non-empty string is accepted regardless of + character content (IDs may be opaque, e.g. ``"default-12345"``). + """ + return isinstance(value, str) and value != '' + + +def is_numeric_id_string(value: Any) -> TypeGuard[str]: + """Return ``True`` if ``value`` is a non-empty decimal-digit string. + Whitespace, signs, decimal points, exponents + and non-string types all return ``False``. Leading + zeros are accepted. + """ + if not isinstance(value, str): + return False + if value == '': + return False + return value.isascii() and value.isdigit() + + +def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str: + """Normalize a decision-event ``campaign_id``. + + Returns ``campaign_id`` unchanged when it is a non-empty string (any + character content — numeric like ``"12345"`` or opaque like + ``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it + is itself a non-empty string). If neither is a non-empty string, returns + an empty string so the event still dispatches. + """ + if is_non_empty_string(campaign_id): + return campaign_id + if is_non_empty_string(experiment_id): + return experiment_id + return '' + + +def normalize_variation_id(variation_id: Any) -> Optional[str]: + """Normalize a decision-event ``variation_id``. + + Returns the original value if it is a valid numeric ID string. Otherwise + returns ``None`` so the event payload carries an explicit null for the + downstream consumer. + """ + return variation_id if is_numeric_id_string(variation_id) else None diff --git a/optimizely/event/payload.py b/optimizely/event/payload.py index e352dd10..a87f97e2 100644 --- a/optimizely/event/payload.py +++ b/optimizely/event/payload.py @@ -1,4 +1,4 @@ -# Copyright 2019, 2022, Optimizely +# Copyright 2019, 2022, 2026, Optimizely # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -71,7 +71,13 @@ def get_event_params(self) -> dict[str, Any]: class Decision: """ Class respresenting Decision. """ - def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, metadata: Metadata): + def __init__( + self, + campaign_id: str, + experiment_id: str, + variation_id: Optional[str], + metadata: Metadata, + ): self.campaign_id = campaign_id self.experiment_id = experiment_id self.variation_id = variation_id diff --git a/optimizely/event_builder.py b/optimizely/event_builder.py index e9c9fd44..7ef6b347 100644 --- a/optimizely/event_builder.py +++ b/optimizely/event_builder.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019, 2022, Optimizely +# Copyright 2016-2019, 2022, 2026, Optimizely # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -18,6 +18,7 @@ from sys import version_info from . import version +from .event import event_id_normalizer from .helpers import enums from .helpers import event_tag_utils from .helpers import validator @@ -178,7 +179,7 @@ def _get_common_params( def _get_required_params_for_impression( self, experiment: Experiment, variation_id: str - ) -> dict[str, list[dict[str, str | int]]]: + ) -> dict[str, list[dict[str, Any]]]: """ Get parameters that are required for the impression event to register. Args: @@ -188,19 +189,24 @@ def _get_required_params_for_impression( Returns: Dict consisting of decisions and events info for impression event. """ - snapshot: dict[str, list[dict[str, str | int]]] = {} + snapshot: dict[str, list[dict[str, Any]]] = {} + + normalized_campaign_id = event_id_normalizer.normalize_campaign_id( + experiment.layerId, experiment.id + ) + normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id) snapshot[self.EventParams.DECISIONS] = [ { self.EventParams.EXPERIMENT_ID: experiment.id, - self.EventParams.VARIATION_ID: variation_id, - self.EventParams.CAMPAIGN_ID: experiment.layerId, + self.EventParams.VARIATION_ID: normalized_variation_id, + self.EventParams.CAMPAIGN_ID: normalized_campaign_id, } ] snapshot[self.EventParams.EVENTS] = [ { - self.EventParams.EVENT_ID: experiment.layerId, + self.EventParams.EVENT_ID: normalized_campaign_id, self.EventParams.TIME: self._get_time(), self.EventParams.KEY: 'campaign_activated', self.EventParams.UUID: str(uuid.uuid4()), diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 728bf89a..a7152ae8 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -122,7 +122,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): self.global_holdouts.append(holdout) # Process local holdouts: every entry must carry 'includedRules' (list of rule IDs). - # Entries without 'includedRules' are invalid per spec — log an error and exclude + # Entries without 'includedRules' are invalid — log an error and exclude # them from evaluation (do NOT fall back to global application). for holdout_data in local_holdouts_data: if 'includedRules' not in holdout_data or holdout_data.get('includedRules') is None: diff --git a/tests/test_event_factory.py b/tests/test_event_factory.py index 6d70c713..b86a1544 100644 --- a/tests/test_event_factory.py +++ b/tests/test_event_factory.py @@ -1,4 +1,4 @@ -# Copyright 2019, Optimizely +# Copyright 2019, 2026, Optimizely # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -1237,3 +1237,214 @@ def test_create_impression_event_without_cmab_uuid(self): EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, ) + + +class EventFactoryIdNormalizationIntegrationTest(base.BaseTest): + """End-to-end decision-event ID normalization. + + These tests build real ``ImpressionEvent`` instances using crafted + Experiment/Variation objects, then call ``EventFactory.create_log_event`` + and inspect the dispatched payload. + """ + + def setUp(self, *args, **kwargs): + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') + self.logger = logger.NoOpLogger() + + def _build_impression( + self, + experiment_id, + layer_id, + variation_id, + rule_type='experiment', + ): + """Build an ImpressionEvent with the provided raw ID values. + + ``experiment_id``/``layer_id``/``variation_id`` are inserted verbatim + so tests can exercise empty/non-string/non-numeric inputs. + """ + from optimizely.entities import Experiment, Variation + from optimizely.event.user_event import EventContext, ImpressionEvent + + experiment = Experiment( + id=experiment_id, + key='exp_key', + status='Running', + audienceIds=[], + variations=[], + forcedVariations={}, + trafficAllocation=[], + layerId=layer_id, + ) + variation = Variation( + id=variation_id, + key='variation_key', + featureEnabled=True, + ) if isinstance(variation_id, str) else None + + event_context = EventContext( + account_id='12001', + project_id='111001', + revision='42', + anonymize_ip=False, + region='US', + ) + return ImpressionEvent( + event_context=event_context, + user_id='test_user', + experiment=experiment, + visitor_attributes=[], + variation=variation, + flag_key='flag_key', + rule_key='rule_key', + rule_type=rule_type, + enabled=True, + ) + + def _dispatched_decision(self, impression_event): + """Return (decision_dict, event_dict) for an impression event.""" + log_event = EventFactory.create_log_event(impression_event, self.logger) + snapshot = log_event.params['visitors'][0]['snapshots'][0] + return snapshot['decisions'][0], snapshot['events'][0] + + def test_valid_campaign_id_is_passed_through(self): + impression = self._build_impression('111127', '111182', '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual('111182', decision['campaign_id']) + # entity_id mirrors campaign_id byte-for-byte. + self.assertEqual(decision['campaign_id'], event['entity_id']) + + def test_empty_campaign_id_falls_back_to_experiment_id(self): + impression = self._build_impression('111127', '', '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual('111127', decision['campaign_id']) + self.assertEqual('111127', event['entity_id']) + + def test_opaque_string_campaign_id_passes_through(self): + impression = self._build_impression('111127', 'campaign_a', '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual('campaign_a', decision['campaign_id']) + self.assertEqual('campaign_a', event['entity_id']) + + def test_prefixed_opaque_campaign_id_passes_through(self): + # Holdout layer IDs are opaque strings like "default-12345". + impression = self._build_impression('111127', 'default-12345', '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual('default-12345', decision['campaign_id']) + self.assertEqual('default-12345', event['entity_id']) + + def test_whitespace_campaign_id_passes_through(self): + # Whitespace is a non-empty string; character-content validation is + # the upstream datafile producer's responsibility. + impression = self._build_impression('111127', ' ', '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual(' ', decision['campaign_id']) + self.assertEqual(' ', event['entity_id']) + + def test_valid_variation_id_is_passed_through(self): + impression = self._build_impression('111127', '111182', '111129') + decision, _ = self._dispatched_decision(impression) + self.assertEqual('111129', decision['variation_id']) + + def test_empty_variation_id_becomes_none(self): + impression = self._build_impression('111127', '111182', '') + decision, _ = self._dispatched_decision(impression) + self.assertIsNone(decision['variation_id']) + + def test_non_numeric_variation_id_becomes_none(self): + impression = self._build_impression('111127', '111182', 'variation_a') + decision, _ = self._dispatched_decision(impression) + self.assertIsNone(decision['variation_id']) + + def test_whitespace_variation_id_becomes_none(self): + impression = self._build_impression('111127', '111182', ' ') + decision, _ = self._dispatched_decision(impression) + self.assertIsNone(decision['variation_id']) + + def test_normalization_applies_to_rollout_decisions(self): + impression = self._build_impression( + '111127', '', 'bad_var', rule_type='rollout' + ) + decision, event = self._dispatched_decision(impression) + self.assertEqual('111127', decision['campaign_id']) + self.assertIsNone(decision['variation_id']) + self.assertEqual('111127', event['entity_id']) + + def test_normalization_applies_to_feature_test_decisions(self): + impression = self._build_impression( + '111127', '', '', rule_type='feature-test' + ) + decision, event = self._dispatched_decision(impression) + self.assertEqual('111127', decision['campaign_id']) + self.assertIsNone(decision['variation_id']) + self.assertEqual('111127', event['entity_id']) + + def test_normalization_applies_to_holdout_decisions(self): + impression = self._build_impression( + '111127', '', '', rule_type='holdout' + ) + decision, event = self._dispatched_decision(impression) + self.assertEqual('111127', decision['campaign_id']) + self.assertIsNone(decision['variation_id']) + self.assertEqual('111127', event['entity_id']) + + def test_holdout_with_opaque_layer_id_passes_through(self): + # Canonical holdout case: opaque layerId like "default-12345" is a + # valid campaign_id and must not be replaced. + impression = self._build_impression( + '111127', 'default-12345', '111129', rule_type='holdout' + ) + decision, event = self._dispatched_decision(impression) + self.assertEqual('default-12345', decision['campaign_id']) + self.assertEqual('default-12345', event['entity_id']) + + def test_event_still_dispatches_when_all_ids_invalid(self): + """Event must still dispatch even when all IDs are invalid.""" + impression = self._build_impression('', '', '') + log_event = EventFactory.create_log_event(impression, self.logger) + self.assertIsNotNone(log_event) + decision, event = self._dispatched_decision(impression) + # campaign_id and entity_id end up as '' but the event still + # dispatches and the two fields remain byte-equivalent. + self.assertEqual('', decision['campaign_id']) + self.assertEqual('', event['entity_id']) + self.assertIsNone(decision['variation_id']) + + def test_entity_id_equals_campaign_id_byte_for_byte(self): + """``events[].entity_id`` must equal ``decisions[].campaign_id``.""" + for layer_id, exp_id, expected in [ + ('111182', '111127', '111182'), # numeric campaign_id wins + ('', '111127', '111127'), # empty falls back to experiment_id + # Opaque non-numeric IDs pass through unchanged. + ('default-12345', '111127', 'default-12345'), + ('layer_abc', '111127', 'layer_abc'), + ('007', '111127', '007'), # leading zeros preserved + ]: + with self.subTest(layer_id=layer_id, exp_id=exp_id): + impression = self._build_impression(exp_id, layer_id, '111129') + decision, event = self._dispatched_decision(impression) + self.assertEqual(expected, decision['campaign_id']) + self.assertEqual(decision['campaign_id'], event['entity_id']) + + def test_conversion_event_entity_id_unchanged(self): + """Conversion events derive entity_id from event.id, not the normalizer.""" + from optimizely.event.user_event_factory import UserEventFactory + + with mock.patch('time.time', return_value=42.123), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ): + conversion_event = UserEventFactory.create_conversion_event( + self.project_config, + 'test_event', + 'test_user', + None, + None, + ) + log_event = EventFactory.create_log_event(conversion_event, self.logger) + snapshot = log_event.params['visitors'][0]['snapshots'][0] + # Conversion entity_id comes from the event.id of the conversion event + # and must NOT pass through the campaign_id normalizer. + self.assertEqual( + self.project_config.get_event('test_event').id, + snapshot['events'][0]['entity_id'], + ) diff --git a/tests/test_event_id_normalizer.py b/tests/test_event_id_normalizer.py new file mode 100644 index 00000000..ca261482 --- /dev/null +++ b/tests/test_event_id_normalizer.py @@ -0,0 +1,233 @@ +# Copyright 2026, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for :mod:`optimizely.event.event_id_normalizer`.""" + +import unittest + +from optimizely.event import event_id_normalizer + + +class IsNonEmptyStringTest(unittest.TestCase): + """Cover :func:`event_id_normalizer.is_non_empty_string`. + + Any non-empty string is valid for ``campaign_id`` / ``entity_id`` — IDs + may be numeric like ``"12345"`` or opaque like ``"default-12345"``. + """ + + def test_returns_true_for_numeric_string(self): + self.assertTrue(event_id_normalizer.is_non_empty_string('12345')) + + def test_returns_true_for_opaque_string(self): + # Opaque IDs are explicitly valid for campaign_id / entity_id. + self.assertTrue(event_id_normalizer.is_non_empty_string('default-12345')) + self.assertTrue(event_id_normalizer.is_non_empty_string('layer_abc')) + self.assertTrue(event_id_normalizer.is_non_empty_string('abc')) + + def test_returns_true_for_whitespace_string(self): + # Whitespace is a non-empty string and so is accepted; + # character-content validation is deferred upstream. + self.assertTrue(event_id_normalizer.is_non_empty_string(' ')) + + def test_returns_false_for_empty_string(self): + self.assertFalse(event_id_normalizer.is_non_empty_string('')) + + def test_returns_false_for_none(self): + self.assertFalse(event_id_normalizer.is_non_empty_string(None)) + + def test_returns_false_for_non_string_types(self): + # Non-string types are rejected so the fallback path fires. + self.assertFalse(event_id_normalizer.is_non_empty_string(12345)) + self.assertFalse(event_id_normalizer.is_non_empty_string(123.0)) + self.assertFalse(event_id_normalizer.is_non_empty_string(True)) + self.assertFalse(event_id_normalizer.is_non_empty_string(['123'])) + self.assertFalse(event_id_normalizer.is_non_empty_string({'id': '123'})) + + +class IsNumericIdStringTest(unittest.TestCase): + """Cover :func:`event_id_normalizer.is_numeric_id_string` edge cases. + + Used only for ``variation_id``, which retains the strict + decimal-digit contract. + """ + + def test_returns_true_for_decimal_digit_string(self): + self.assertTrue(event_id_normalizer.is_numeric_id_string('12345')) + + def test_returns_true_for_single_digit(self): + self.assertTrue(event_id_normalizer.is_numeric_id_string('0')) + self.assertTrue(event_id_normalizer.is_numeric_id_string('9')) + + def test_returns_true_for_leading_zeros(self): + # Leading zeros are explicitly allowed. + self.assertTrue(event_id_normalizer.is_numeric_id_string('007')) + self.assertTrue(event_id_normalizer.is_numeric_id_string('00000')) + + def test_returns_false_for_empty_string(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string('')) + + def test_returns_false_for_none(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string(None)) + + def test_returns_false_for_int(self): + # The value must be a string. + self.assertFalse(event_id_normalizer.is_numeric_id_string(12345)) + self.assertFalse(event_id_normalizer.is_numeric_id_string(0)) + + def test_returns_false_for_float(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string(123.0)) + + def test_returns_false_for_bool(self): + # ``bool`` is a subclass of ``int`` but is still not a ``str``. + self.assertFalse(event_id_normalizer.is_numeric_id_string(True)) + self.assertFalse(event_id_normalizer.is_numeric_id_string(False)) + + def test_returns_false_for_whitespace(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string(' ')) + self.assertFalse(event_id_normalizer.is_numeric_id_string(' 123')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('123 ')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('1 2')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('\t')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('\n')) + + def test_returns_false_for_signed_numbers(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string('-1')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('+1')) + + def test_returns_false_for_decimals(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string('1.0')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('.5')) + + def test_returns_false_for_exponents(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string('1e5')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('1E5')) + + def test_returns_false_for_hex(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string('0x1A')) + self.assertFalse(event_id_normalizer.is_numeric_id_string('abc')) + + def test_returns_false_for_unicode_digits(self): + # ``str.isdigit`` is True for many non-ASCII digit code points; the + # normalizer must reject these because the wire format expects ASCII. + self.assertFalse(event_id_normalizer.is_numeric_id_string('٠١')) # Arabic-Indic 01 + self.assertFalse(event_id_normalizer.is_numeric_id_string('²')) # superscript 2 + + def test_returns_false_for_collections(self): + self.assertFalse(event_id_normalizer.is_numeric_id_string(['123'])) + self.assertFalse(event_id_normalizer.is_numeric_id_string({'id': '123'})) + self.assertFalse(event_id_normalizer.is_numeric_id_string(('1',))) + + +class NormalizeCampaignIdTest(unittest.TestCase): + """Cover :func:`event_id_normalizer.normalize_campaign_id`. + + Any non-empty string is valid for campaign_id — fallback to + ``experiment_id`` fires only on empty/None/missing. + """ + + def test_returns_campaign_id_when_numeric(self): + self.assertEqual( + '111182', + event_id_normalizer.normalize_campaign_id('111182', '111127'), + ) + + def test_returns_campaign_id_when_opaque_string(self): + # Opaque IDs (e.g. holdout layer IDs) pass through. + self.assertEqual( + 'default-12345', + event_id_normalizer.normalize_campaign_id('default-12345', '111127'), + ) + self.assertEqual( + 'layer_abc', + event_id_normalizer.normalize_campaign_id('layer_abc', '111127'), + ) + + def test_returns_campaign_id_when_whitespace_string(self): + # Whitespace is non-empty; passes through (validation deferred upstream). + self.assertEqual( + ' ', + event_id_normalizer.normalize_campaign_id(' ', '111127'), + ) + + def test_falls_back_to_experiment_id_when_campaign_id_empty(self): + self.assertEqual( + '111127', + event_id_normalizer.normalize_campaign_id('', '111127'), + ) + + def test_falls_back_to_experiment_id_when_campaign_id_none(self): + self.assertEqual( + '111127', + event_id_normalizer.normalize_campaign_id(None, '111127'), + ) + + def test_falls_back_to_opaque_experiment_id(self): + # Both fields may be opaque non-numeric strings. + self.assertEqual( + 'exp_42', + event_id_normalizer.normalize_campaign_id('', 'exp_42'), + ) + + def test_returns_empty_string_when_both_empty_or_none(self): + # Do not drop / fail dispatch; return ''. + self.assertEqual('', event_id_normalizer.normalize_campaign_id(None, None)) + self.assertEqual('', event_id_normalizer.normalize_campaign_id('', '')) + self.assertEqual('', event_id_normalizer.normalize_campaign_id(None, '')) + + def test_preserves_leading_zeros(self): + self.assertEqual( + '007', + event_id_normalizer.normalize_campaign_id('007', '111127'), + ) + + +class NormalizeVariationIdTest(unittest.TestCase): + """Cover :func:`event_id_normalizer.normalize_variation_id`. + + ``variation_id`` retains the strict numeric-string contract. + """ + + def test_returns_variation_id_when_valid(self): + self.assertEqual( + '111129', + event_id_normalizer.normalize_variation_id('111129'), + ) + + def test_returns_none_when_empty(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id('')) + + def test_returns_none_when_none(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id(None)) + + def test_returns_none_when_non_string(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id(111129)) + self.assertIsNone(event_id_normalizer.normalize_variation_id(123.0)) + self.assertIsNone(event_id_normalizer.normalize_variation_id(True)) + + def test_returns_none_when_non_numeric(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id('variation_a')) + self.assertIsNone(event_id_normalizer.normalize_variation_id('abc')) + + def test_returns_none_when_whitespace(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id(' ')) + self.assertIsNone(event_id_normalizer.normalize_variation_id(' 111129')) + + def test_returns_none_when_signed(self): + self.assertIsNone(event_id_normalizer.normalize_variation_id('-111129')) + + def test_preserves_leading_zeros(self): + self.assertEqual('007', event_id_normalizer.normalize_variation_id('007')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_holdout_config.py b/tests/test_holdout_config.py index 6420e42c..e9416e11 100644 --- a/tests/test_holdout_config.py +++ b/tests/test_holdout_config.py @@ -471,7 +471,7 @@ def test_local_holdouts_section_entries_excluded_from_global_list(self): self.assertEqual(config.get_global_holdouts(), []) def test_local_holdouts_missing_included_rules_logged_and_excluded(self): - """Entries in 'localHoldouts' without 'includedRules' are invalid per spec. + """Entries in 'localHoldouts' without 'includedRules' are invalid. SDK must log an error and exclude the entry from evaluation. It must NOT fall back to global application (the partition between sections is hard).