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..9486d5f2 --- /dev/null +++ b/optimizely/event/event_id_normalizer.py @@ -0,0 +1,102 @@ +# 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 (per spec assumptions; 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 (FR-009). + +The normalization path MUST NOT log, warn, or raise. It must never drop or +defer event dispatch. +""" + +from __future__ import annotations + +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`. + + Used for ``campaign_id`` and ``entity_id`` validation per the relaxed + FR-001 / FR-009 contract: 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. + + Used for ``variation_id`` validation per FR-003 (the only field that + retains the strict numeric-string contract). 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 + # ``str.isdigit`` rejects everything except [0-9] characters and the + # empty string. We've already excluded the empty case above. Note that + # ``isdigit`` also accepts some non-ASCII digit code points; ``isascii`` + # combined with ``isdigit`` restricts us to plain decimal digits. + return value.isascii() and value.isdigit() + + +def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str: + """Normalize a decision-event ``campaign_id`` (FR-001/FR-002, FR-009). + + 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 (FR-006). + """ + 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`` (FR-003/FR-004). + + 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/tests/test_event_factory.py b/tests/test_event_factory.py index 6d70c713..70375ebb 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,224 @@ 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. They exercise FR-001..FR-009. + """ + + 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] + + # ------------------------------------------------------------------ FR-001 + 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']) + # FR-009: entity_id mirrors campaign_id byte-for-byte. + self.assertEqual(decision['campaign_id'], event['entity_id']) + + # ------------------------------------------------------------------ FR-002 + 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']) + + # ------------------------------------------------------------------ FR-003 + 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']) + + # ------------------------------------------------------------------ FR-004 + 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']) + + # ------------------------------------------------------------------ FR-005 + 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']) + + # ------------------------------------------------------------------ FR-006 + def test_event_still_dispatches_when_all_ids_invalid(self): + """FR-006: never drop / fail dispatch.""" + 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']) + + # ------------------------------------------------------------------ FR-009 + def test_entity_id_equals_campaign_id_byte_for_byte(self): + """FR-009: ``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']) + + # ----------------------------------------------------------------- FR-010 + def test_conversion_event_entity_id_unchanged(self): + """FR-010: 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..e478af64 --- /dev/null +++ b/tests/test_event_id_normalizer.py @@ -0,0 +1,234 @@ +# 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` (FR-001/FR-009). + + 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; the spec + # explicitly defers any character-content validation 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 out of scope per the spec assumptions; the + # predicate rejects them 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`` (FR-003), 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): + # FR-003 explicitly allows leading zeros. + 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): + # FR-003 requires the value to 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` per FR-001/002, FR-009. + + Per the relaxed spec, 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 (FR-006); 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` per FR-003/004. + + ``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()