Skip to content

Commit 31675bf

Browse files
committed
[FSSDK-12813] Relax campaign_id/entity_id validation to non-empty string per updated spec
1 parent b11dd6f commit 31675bf

3 files changed

Lines changed: 145 additions & 65 deletions

File tree

‎optimizely/event/event_id_normalizer.py‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
appear in dispatched decision events. See FSSDK-12813.
1919
2020
Rules:
21-
* A "numeric ID string" is a non-empty :class:`str` consisting entirely of
22-
decimal digits ``0-9``. Leading zeros are allowed. Whitespace, negatives,
23-
decimals, and exponents are INVALID.
24-
* ``campaign_id`` -> when invalid, falls back to ``experiment_id`` (which is
25-
itself passed through :func:`normalize_string_id`).
26-
* ``variation_id`` -> when invalid, becomes ``None``.
21+
* ``campaign_id`` and impression ``entity_id`` accept **any non-empty
22+
string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` /
23+
``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the
24+
value is the empty string, ``None``, or missing. Non-string types are
25+
out of scope for this normalization path (per spec assumptions; the
26+
upstream datafile producer delivers string or null values).
27+
* ``variation_id`` retains the stricter contract: it MUST be a non-empty
28+
string of decimal digits ``0-9`` (leading zeros allowed). Empty,
29+
whitespace, non-string, and non-numeric inputs are normalized to
30+
``None`` so the wire payload carries an explicit null.
2731
* ``entity_id`` on impression events shares the campaign_id normalization
2832
and is therefore byte-equivalent to the normalized campaign_id for the
2933
same impression (FR-009).
@@ -37,11 +41,23 @@
3741
from typing import Any, Optional
3842

3943

44+
def is_non_empty_string(value: Any) -> bool:
45+
"""Return ``True`` if ``value`` is a non-empty :class:`str`.
46+
47+
Used for ``campaign_id`` and ``entity_id`` validation per the relaxed
48+
FR-001 / FR-009 contract: any non-empty string is accepted regardless of
49+
character content (IDs may be opaque, e.g. ``"default-12345"``).
50+
"""
51+
return isinstance(value, str) and value != ''
52+
53+
4054
def is_numeric_id_string(value: Any) -> bool:
4155
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
4256
43-
Whitespace, signs, decimal points, exponents, and non-string types all
44-
return ``False``. Leading zeros are accepted.
57+
Used for ``variation_id`` validation per FR-003 (the only field that
58+
retains the strict numeric-string contract). Whitespace, signs, decimal
59+
points, exponents, and non-string types all return ``False``. Leading
60+
zeros are accepted.
4561
"""
4662
if not isinstance(value, str):
4763
return False
@@ -54,22 +70,18 @@ def is_numeric_id_string(value: Any) -> bool:
5470
return value.isascii() and value.isdigit()
5571

5672

57-
def normalize_string_id(value: Any) -> Optional[str]:
58-
"""Return ``value`` if it's a numeric ID string, otherwise ``None``."""
59-
return value if is_numeric_id_string(value) else None
60-
61-
6273
def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
6374
"""Normalize a decision-event ``campaign_id`` (FR-001/FR-002, FR-009).
6475
65-
If ``campaign_id`` is a valid numeric ID string it is returned unchanged.
66-
Otherwise the function falls back to ``experiment_id`` (after applying
67-
the same validation). If neither is a numeric ID string, an empty string
68-
is returned so the event still dispatches (FR-006).
76+
Returns ``campaign_id`` unchanged when it is a non-empty string (any
77+
character content — numeric like ``"12345"`` or opaque like
78+
``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it
79+
is itself a non-empty string). If neither is a non-empty string, returns
80+
an empty string so the event still dispatches (FR-006).
6981
"""
70-
if is_numeric_id_string(campaign_id):
82+
if is_non_empty_string(campaign_id):
7183
return campaign_id # type: ignore[no-any-return]
72-
if is_numeric_id_string(experiment_id):
84+
if is_non_empty_string(experiment_id):
7385
return experiment_id # type: ignore[no-any-return]
7486
return ''
7587

@@ -78,7 +90,7 @@ def normalize_variation_id(variation_id: Any) -> Optional[str]:
7890
"""Normalize a decision-event ``variation_id`` (FR-003/FR-004).
7991
8092
Returns the original value if it is a valid numeric ID string. Otherwise
81-
returns ``None`` so the event payload omits/clears the field for the
93+
returns ``None`` so the event payload carries an explicit null for the
8294
downstream consumer.
8395
"""
8496
return variation_id if is_numeric_id_string(variation_id) else None

‎tests/test_event_factory.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,17 +1322,29 @@ def test_empty_campaign_id_falls_back_to_experiment_id(self):
13221322
self.assertEqual('111127', decision['campaign_id'])
13231323
self.assertEqual('111127', event['entity_id'])
13241324

1325-
def test_non_numeric_campaign_id_falls_back_to_experiment_id(self):
1325+
def test_opaque_string_campaign_id_passes_through(self):
1326+
# FSSDK-12813: opaque (non-numeric) campaign_id values are valid and
1327+
# pass through unchanged per the relaxed FR-001 contract.
13261328
impression = self._build_impression('111127', 'campaign_a', '111129')
13271329
decision, event = self._dispatched_decision(impression)
1328-
self.assertEqual('111127', decision['campaign_id'])
1329-
self.assertEqual('111127', event['entity_id'])
1330+
self.assertEqual('campaign_a', decision['campaign_id'])
1331+
self.assertEqual('campaign_a', event['entity_id'])
13301332

1331-
def test_whitespace_campaign_id_falls_back_to_experiment_id(self):
1333+
def test_prefixed_opaque_campaign_id_passes_through(self):
1334+
# FSSDK-12813: e.g. holdout layer IDs like "default-12345".
1335+
impression = self._build_impression('111127', 'default-12345', '111129')
1336+
decision, event = self._dispatched_decision(impression)
1337+
self.assertEqual('default-12345', decision['campaign_id'])
1338+
self.assertEqual('default-12345', event['entity_id'])
1339+
1340+
def test_whitespace_campaign_id_passes_through(self):
1341+
# FSSDK-12813: whitespace is a non-empty string and so is accepted;
1342+
# character-content validation is deferred to the upstream datafile
1343+
# producer.
13321344
impression = self._build_impression('111127', ' ', '111129')
13331345
decision, event = self._dispatched_decision(impression)
1334-
self.assertEqual('111127', decision['campaign_id'])
1335-
self.assertEqual('111127', event['entity_id'])
1346+
self.assertEqual(' ', decision['campaign_id'])
1347+
self.assertEqual(' ', event['entity_id'])
13361348

13371349
# ------------------------------------------------------------------ FR-003
13381350
def test_valid_variation_id_is_passed_through(self):
@@ -1358,8 +1370,10 @@ def test_whitespace_variation_id_becomes_none(self):
13581370

13591371
# ------------------------------------------------------------------ FR-005
13601372
def test_normalization_applies_to_rollout_decisions(self):
1373+
# FSSDK-12813: empty campaign_id falls back to experiment_id;
1374+
# non-numeric variation_id normalizes to None.
13611375
impression = self._build_impression(
1362-
'111127', 'bad_layer', 'bad_var', rule_type='rollout'
1376+
'111127', '', 'bad_var', rule_type='rollout'
13631377
)
13641378
decision, event = self._dispatched_decision(impression)
13651379
self.assertEqual('111127', decision['campaign_id'])
@@ -1384,6 +1398,16 @@ def test_normalization_applies_to_holdout_decisions(self):
13841398
self.assertIsNone(decision['variation_id'])
13851399
self.assertEqual('111127', event['entity_id'])
13861400

1401+
def test_holdout_with_opaque_layer_id_passes_through(self):
1402+
# FSSDK-12813: the canonical holdout case — opaque layerId like
1403+
# "default-12345" is now a valid campaign_id and is NOT replaced.
1404+
impression = self._build_impression(
1405+
'111127', 'default-12345', '111129', rule_type='holdout'
1406+
)
1407+
decision, event = self._dispatched_decision(impression)
1408+
self.assertEqual('default-12345', decision['campaign_id'])
1409+
self.assertEqual('default-12345', event['entity_id'])
1410+
13871411
# ------------------------------------------------------------------ FR-006
13881412
def test_event_still_dispatches_when_all_ids_invalid(self):
13891413
"""FR-006: never drop / fail dispatch."""
@@ -1401,10 +1425,12 @@ def test_event_still_dispatches_when_all_ids_invalid(self):
14011425
def test_entity_id_equals_campaign_id_byte_for_byte(self):
14021426
"""FR-009: ``events[].entity_id`` must equal ``decisions[].campaign_id``."""
14031427
for layer_id, exp_id, expected in [
1404-
('111182', '111127', '111182'), # campaign_id wins
1405-
('', '111127', '111127'), # falls back to experiment_id
1406-
('bad', '111127', '111127'), # fallback on invalid
1407-
('007', '111127', '007'), # leading zeros preserved
1428+
('111182', '111127', '111182'), # numeric campaign_id wins
1429+
('', '111127', '111127'), # empty falls back to experiment_id
1430+
# FSSDK-12813: opaque non-numeric IDs now pass through unchanged.
1431+
('default-12345', '111127', 'default-12345'),
1432+
('layer_abc', '111127', 'layer_abc'),
1433+
('007', '111127', '007'), # leading zeros preserved
14081434
]:
14091435
with self.subTest(layer_id=layer_id, exp_id=exp_id):
14101436
impression = self._build_impression(exp_id, layer_id, '111129')

‎tests/test_event_id_normalizer.py‎

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,49 @@
1818
from optimizely.event import event_id_normalizer
1919

2020

21+
class IsNonEmptyStringTest(unittest.TestCase):
22+
"""Cover :func:`event_id_normalizer.is_non_empty_string` (FR-001/FR-009).
23+
24+
Any non-empty string is valid for ``campaign_id`` / ``entity_id`` — IDs
25+
may be numeric like ``"12345"`` or opaque like ``"default-12345"``.
26+
"""
27+
28+
def test_returns_true_for_numeric_string(self):
29+
self.assertTrue(event_id_normalizer.is_non_empty_string('12345'))
30+
31+
def test_returns_true_for_opaque_string(self):
32+
# Opaque IDs are explicitly valid for campaign_id / entity_id.
33+
self.assertTrue(event_id_normalizer.is_non_empty_string('default-12345'))
34+
self.assertTrue(event_id_normalizer.is_non_empty_string('layer_abc'))
35+
self.assertTrue(event_id_normalizer.is_non_empty_string('abc'))
36+
37+
def test_returns_true_for_whitespace_string(self):
38+
# Whitespace is a non-empty string and so is accepted; the spec
39+
# explicitly defers any character-content validation upstream.
40+
self.assertTrue(event_id_normalizer.is_non_empty_string(' '))
41+
42+
def test_returns_false_for_empty_string(self):
43+
self.assertFalse(event_id_normalizer.is_non_empty_string(''))
44+
45+
def test_returns_false_for_none(self):
46+
self.assertFalse(event_id_normalizer.is_non_empty_string(None))
47+
48+
def test_returns_false_for_non_string_types(self):
49+
# Non-string types are out of scope per the spec assumptions; the
50+
# predicate rejects them so the fallback path fires.
51+
self.assertFalse(event_id_normalizer.is_non_empty_string(12345))
52+
self.assertFalse(event_id_normalizer.is_non_empty_string(123.0))
53+
self.assertFalse(event_id_normalizer.is_non_empty_string(True))
54+
self.assertFalse(event_id_normalizer.is_non_empty_string(['123']))
55+
self.assertFalse(event_id_normalizer.is_non_empty_string({'id': '123'}))
56+
57+
2158
class IsNumericIdStringTest(unittest.TestCase):
22-
"""Cover :func:`event_id_normalizer.is_numeric_id_string` edge cases."""
59+
"""Cover :func:`event_id_normalizer.is_numeric_id_string` edge cases.
60+
61+
Used only for ``variation_id`` (FR-003), which retains the strict
62+
decimal-digit contract.
63+
"""
2364

2465
def test_returns_true_for_decimal_digit_string(self):
2566
self.assertTrue(event_id_normalizer.is_numeric_id_string('12345'))
@@ -29,7 +70,7 @@ def test_returns_true_for_single_digit(self):
2970
self.assertTrue(event_id_normalizer.is_numeric_id_string('9'))
3071

3172
def test_returns_true_for_leading_zeros(self):
32-
# FR-001 explicitly allows leading zeros.
73+
# FR-003 explicitly allows leading zeros.
3374
self.assertTrue(event_id_normalizer.is_numeric_id_string('007'))
3475
self.assertTrue(event_id_normalizer.is_numeric_id_string('00000'))
3576

@@ -40,7 +81,7 @@ def test_returns_false_for_none(self):
4081
self.assertFalse(event_id_normalizer.is_numeric_id_string(None))
4182

4283
def test_returns_false_for_int(self):
43-
# FR-001 requires the value to be a string.
84+
# FR-003 requires the value to be a string.
4485
self.assertFalse(event_id_normalizer.is_numeric_id_string(12345))
4586
self.assertFalse(event_id_normalizer.is_numeric_id_string(0))
4687

@@ -89,50 +130,60 @@ def test_returns_false_for_collections(self):
89130

90131

91132
class NormalizeCampaignIdTest(unittest.TestCase):
92-
"""Cover :func:`event_id_normalizer.normalize_campaign_id` per FR-001/002, FR-009."""
133+
"""Cover :func:`event_id_normalizer.normalize_campaign_id` per FR-001/002, FR-009.
134+
135+
Per the relaxed spec, any non-empty string is valid for campaign_id —
136+
fallback to ``experiment_id`` fires only on empty/None/missing.
137+
"""
93138

94-
def test_returns_campaign_id_when_valid(self):
139+
def test_returns_campaign_id_when_numeric(self):
95140
self.assertEqual(
96141
'111182',
97142
event_id_normalizer.normalize_campaign_id('111182', '111127'),
98143
)
99144

100-
def test_falls_back_to_experiment_id_when_campaign_id_empty(self):
145+
def test_returns_campaign_id_when_opaque_string(self):
146+
# FSSDK-12813: opaque IDs (e.g. holdout layer IDs) pass through.
101147
self.assertEqual(
102-
'111127',
103-
event_id_normalizer.normalize_campaign_id('', '111127'),
148+
'default-12345',
149+
event_id_normalizer.normalize_campaign_id('default-12345', '111127'),
150+
)
151+
self.assertEqual(
152+
'layer_abc',
153+
event_id_normalizer.normalize_campaign_id('layer_abc', '111127'),
104154
)
105155

106-
def test_falls_back_to_experiment_id_when_campaign_id_none(self):
156+
def test_returns_campaign_id_when_whitespace_string(self):
157+
# Whitespace is non-empty; passes through (validation deferred upstream).
107158
self.assertEqual(
108-
'111127',
109-
event_id_normalizer.normalize_campaign_id(None, '111127'),
159+
' ',
160+
event_id_normalizer.normalize_campaign_id(' ', '111127'),
110161
)
111162

112-
def test_falls_back_to_experiment_id_when_campaign_id_non_numeric(self):
163+
def test_falls_back_to_experiment_id_when_campaign_id_empty(self):
113164
self.assertEqual(
114165
'111127',
115-
event_id_normalizer.normalize_campaign_id('abc', '111127'),
166+
event_id_normalizer.normalize_campaign_id('', '111127'),
116167
)
117168

118-
def test_falls_back_to_experiment_id_when_campaign_id_whitespace(self):
169+
def test_falls_back_to_experiment_id_when_campaign_id_none(self):
119170
self.assertEqual(
120171
'111127',
121-
event_id_normalizer.normalize_campaign_id(' ', '111127'),
172+
event_id_normalizer.normalize_campaign_id(None, '111127'),
122173
)
123174

124-
def test_falls_back_to_experiment_id_when_campaign_id_int(self):
125-
# An int input is invalid (FR-001 requires a string).
175+
def test_falls_back_to_opaque_experiment_id(self):
176+
# Both fields may be opaque non-numeric strings.
126177
self.assertEqual(
127-
'111127',
128-
event_id_normalizer.normalize_campaign_id(111182, '111127'),
178+
'exp_42',
179+
event_id_normalizer.normalize_campaign_id('', 'exp_42'),
129180
)
130181

131-
def test_returns_empty_string_when_both_invalid(self):
182+
def test_returns_empty_string_when_both_empty_or_none(self):
132183
# Do not drop / fail dispatch (FR-006); return ''.
133184
self.assertEqual('', event_id_normalizer.normalize_campaign_id(None, None))
134185
self.assertEqual('', event_id_normalizer.normalize_campaign_id('', ''))
135-
self.assertEqual('', event_id_normalizer.normalize_campaign_id('abc', 'xyz'))
186+
self.assertEqual('', event_id_normalizer.normalize_campaign_id(None, ''))
136187

137188
def test_preserves_leading_zeros(self):
138189
self.assertEqual(
@@ -142,7 +193,10 @@ def test_preserves_leading_zeros(self):
142193

143194

144195
class NormalizeVariationIdTest(unittest.TestCase):
145-
"""Cover :func:`event_id_normalizer.normalize_variation_id` per FR-003/004."""
196+
"""Cover :func:`event_id_normalizer.normalize_variation_id` per FR-003/004.
197+
198+
``variation_id`` retains the strict numeric-string contract.
199+
"""
146200

147201
def test_returns_variation_id_when_valid(self):
148202
self.assertEqual(
@@ -176,17 +230,5 @@ def test_preserves_leading_zeros(self):
176230
self.assertEqual('007', event_id_normalizer.normalize_variation_id('007'))
177231

178232

179-
class NormalizeStringIdTest(unittest.TestCase):
180-
"""Cover the generic :func:`event_id_normalizer.normalize_string_id` helper."""
181-
182-
def test_returns_value_when_valid(self):
183-
self.assertEqual('42', event_id_normalizer.normalize_string_id('42'))
184-
185-
def test_returns_none_when_invalid(self):
186-
self.assertIsNone(event_id_normalizer.normalize_string_id(''))
187-
self.assertIsNone(event_id_normalizer.normalize_string_id(None))
188-
self.assertIsNone(event_id_normalizer.normalize_string_id('xx'))
189-
190-
191233
if __name__ == '__main__':
192234
unittest.main()

0 commit comments

Comments
 (0)