diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java index f200f963d..c46703b28 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2020, 2022, Optimizely and contributors + * Copyright 2016-2020, 2022, 2026, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -97,10 +97,15 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) { UserContext userContext = impressionEvent.getUserContext(); + String normalizedCampaignId = EventIdNormalizer.normalizeCampaignId( + impressionEvent.getLayerId(), impressionEvent.getExperimentId()); + String normalizedVariationId = EventIdNormalizer.normalizeVariationId( + impressionEvent.getVariationId()); + Decision decision = new Decision.Builder() - .setCampaignId(impressionEvent.getLayerId()) + .setCampaignId(normalizedCampaignId) .setExperimentId(impressionEvent.getExperimentId()) - .setVariationId(impressionEvent.getVariationId()) + .setVariationId(normalizedVariationId) .setMetadata(impressionEvent.getMetadata()) .setIsCampaignHoldback(false) .build(); @@ -108,7 +113,7 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) { Event event = new Event.Builder() .setTimestamp(impressionEvent.getTimestamp()) .setUuid(impressionEvent.getUUID()) - .setEntityId(impressionEvent.getLayerId()) + .setEntityId(normalizedCampaignId) .setKey(ACTIVATE_EVENT_KEY) .setType(ACTIVATE_EVENT_KEY) .build(); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventIdNormalizer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventIdNormalizer.java new file mode 100644 index 000000000..ba2a1a6dc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventIdNormalizer.java @@ -0,0 +1,111 @@ +/** + * + * Copyright 2026, Optimizely and contributors + * + * 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. + */ +package com.optimizely.ab.event.internal; + +/** + * EventIdNormalizer normalizes decision-event identifier fields prior to wire serialization. + * + *
For {@code variation_id}, a "numeric string" is a non-empty string consisting + * entirely of decimal digits {@code [0-9]}. Leading zeros are allowed. Whitespace, + * negatives, decimals, and exponents are INVALID. + * + *
Normalization applies uniformly to all decision types (experiment, feature test, + * rollout, holdout). It must not drop, defer, or fail event dispatch, and it must not + * emit any log or warning on the normalization path. + */ +final class EventIdNormalizer { + + private EventIdNormalizer() { + // Utility class — not instantiable. + } + + /** + * @return {@code true} iff {@code value} is non-null and has length ≥ 1. + * Character content is not validated — any non-empty string is accepted. + * Used to validate {@code campaign_id} and impression {@code entity_id}. + */ + static boolean isNonEmptyString(String value) { + return value != null && !value.isEmpty(); + } + + /** + * @return {@code true} iff {@code value} is non-null and consists entirely of decimal digits. + * Empty strings, whitespace, negatives, decimals, and exponents are all invalid. + * Used to validate {@code variation_id} per the strict numeric-string contract. + */ + static boolean isNumericString(String value) { + if (value == null) { + return false; + } + int length = value.length(); + if (length == 0) { + return false; + } + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + /** + * Normalize a {@code campaign_id} or impression {@code entity_id}. + * + *
Any non-empty string is accepted as-is (IDs may be opaque, e.g.
+ * {@code "default-12345"}, {@code "layer_abc"}). The fallback to
+ * {@code experiment_id} fires ONLY when {@code campaignId} is {@code null} or
+ * the empty string {@code ""}.
+ *
+ * @param campaignId the candidate campaign_id (may be null or empty)
+ * @param experimentId fallback experiment_id (returned as-is; not re-validated)
+ * @return {@code campaignId} when it is a non-empty string of any content,
+ * otherwise {@code experimentId} (which may itself be {@code null}).
+ */
+ static String normalizeCampaignId(String campaignId, String experimentId) {
+ if (isNonEmptyString(campaignId)) {
+ return campaignId;
+ }
+ return experimentId;
+ }
+
+ /**
+ * Normalize a {@code variation_id}. {@code variation_id} retains the stricter
+ * contract: must be a non-empty decimal-digit string. Anything else (null,
+ * empty, whitespace, or non-numeric) is replaced with {@code null}.
+ *
+ * @param variationId the candidate variation_id (may be null, empty, or non-numeric)
+ * @return {@code variationId} when it is a non-empty numeric string, otherwise {@code null}.
+ */
+ static String normalizeVariationId(String variationId) {
+ if (isNumericString(variationId)) {
+ return variationId;
+ }
+ return null;
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java
index e472d236e..4cb696a45 100644
--- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java
+++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java
@@ -27,6 +27,7 @@ public class Decision {
@JsonInclude(JsonInclude.Include.ALWAYS)
@JsonProperty("experiment_id")
String experimentId;
+ @JsonInclude(JsonInclude.Include.ALWAYS)
@JsonProperty("variation_id")
String variationId;
@JsonProperty("is_campaign_holdback")
diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java
index 15c3eea5f..ee8be27fd 100644
--- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java
+++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2016-2023, Optimizely, Inc. and contributors *
+ * Copyright 2016-2023, 2026, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -618,7 +618,7 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception {
assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null));
assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId));
assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId));
- eventHandler.expectImpression(null, "", testUserId);
+ eventHandler.expectImpression(null, null, testUserId);
}
/**
@@ -1810,13 +1810,13 @@ public void getEnabledFeaturesWithListenerMultipleFeatureEnabled() throws Except
List Verifies:
+ * Covers normalization rules:
+ *
+ *
+ */
+public class EventFactoryNormalizationTest {
+
+ private static final String USER_ID = "test-user";
+
+ private ProjectConfig projectConfig;
+
+ @Before
+ public void setUp() {
+ projectConfig = mock(ProjectConfig.class);
+ when(projectConfig.getAccountId()).thenReturn("1");
+ when(projectConfig.getProjectId()).thenReturn("100");
+ when(projectConfig.getRevision()).thenReturn("3");
+ when(projectConfig.getAnonymizeIP()).thenReturn(true);
+ when(projectConfig.getBotFiltering()).thenReturn(null);
+ when(projectConfig.getRegion()).thenReturn("US");
+ }
+
+ private UserContext userContext() {
+ return new UserContext.Builder()
+ .withUserId(USER_ID)
+ .withAttributes(Collections.emptyMap())
+ .withProjectConfig(projectConfig)
+ .build();
+ }
+
+ private ImpressionEvent buildImpression(String ruleType,
+ String layerId,
+ String experimentId,
+ String variationId) {
+ DecisionMetadata metadata = new DecisionMetadata.Builder()
+ .setFlagKey("test_flag")
+ .setRuleKey("test_rule")
+ .setRuleType(ruleType)
+ .setVariationKey("variationKey")
+ .setEnabled(true)
+ .build();
+
+ return new ImpressionEvent.Builder()
+ .withUserContext(userContext())
+ .withLayerId(layerId)
+ .withExperimentId(experimentId)
+ .withExperimentKey("experimentKey")
+ .withVariationId(variationId)
+ .withVariationKey("variationKey")
+ .withMetadata(metadata)
+ .build();
+ }
+
+ private static Decision firstDecision(LogEvent logEvent) {
+ EventBatch batch = logEvent.getEventBatch();
+ Visitor visitor = batch.getVisitors().get(0);
+ Snapshot snapshot = visitor.getSnapshots().get(0);
+ return snapshot.getDecisions().get(0);
+ }
+
+ private static Event firstEvent(LogEvent logEvent) {
+ EventBatch batch = logEvent.getEventBatch();
+ Visitor visitor = batch.getVisitors().get(0);
+ Snapshot snapshot = visitor.getSnapshots().get(0);
+ return snapshot.getEvents().get(0);
+ }
+
+ // ---------------------------------------------------------------------
+ // Happy path
+ // ---------------------------------------------------------------------
+
+ /**
+ * FR-001/FR-009 happy path: a valid numeric campaign_id is passed through,
+ * and entity_id matches it byte-for-byte.
+ */
+ @Test
+ public void happyPath_numericIds_passThroughUnchanged() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "1111", "2222", "3333");
+
+ LogEvent log = EventFactory.createLogEvent(imp);
+
+ assertNotNull(log);
+ Decision d = firstDecision(log);
+ Event e = firstEvent(log);
+
+ assertEquals("1111", d.getCampaignId());
+ assertEquals("2222", d.getExperimentId());
+ assertEquals("3333", d.getVariationId());
+ assertEquals("1111", e.getEntityId());
+ // FR-009 byte-for-byte: same reference content
+ assertEquals(d.getCampaignId(), e.getEntityId());
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-001/FR-002: campaign_id fallback
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void campaignId_null_fallsBackToExperimentId() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", null, "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertEquals("2222", firstDecision(log).getCampaignId());
+ assertEquals("2222", firstEvent(log).getEntityId());
+ }
+
+ @Test
+ public void campaignId_empty_fallsBackToExperimentId() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "", "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertEquals("2222", firstDecision(log).getCampaignId());
+ assertEquals("2222", firstEvent(log).getEntityId());
+ }
+
+ @Test
+ public void campaignId_whitespace_passesThroughUnchanged() {
+ // Whitespace-only strings have length >= 1, so they pass through.
+ ImpressionEvent imp = buildImpression(
+ "experiment", " ", "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertEquals(" ", firstDecision(log).getCampaignId());
+ assertEquals(" ", firstEvent(log).getEntityId());
+ }
+
+ @Test
+ public void campaignId_nonNumericString_passesThroughUnchanged() {
+ // Opaque/non-numeric IDs (e.g. "layerKey", "default-12345",
+ // "layer_abc") are valid campaign_id values and must pass through to
+ // both decisions[].campaign_id and events[].entity_id.
+ ImpressionEvent imp = buildImpression(
+ "experiment", "layerKey", "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertEquals("layerKey", firstDecision(log).getCampaignId());
+ assertEquals("layerKey", firstEvent(log).getEntityId());
+ }
+
+ @Test
+ public void campaignId_opaqueDashSeparatedString_passesThroughUnchanged() {
+ // Explicit coverage for the canonical opaque-ID example
+ // ("default-12345").
+ ImpressionEvent imp = buildImpression(
+ "experiment", "default-12345", "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertEquals("default-12345", firstDecision(log).getCampaignId());
+ assertEquals("default-12345", firstEvent(log).getEntityId());
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-003/FR-004: variation_id null replacement
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void variationId_null_remainsNull() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "1111", "2222", null);
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertNull(firstDecision(log).getVariationId());
+ }
+
+ @Test
+ public void variationId_empty_becomesNull() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "1111", "2222", "");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertNull(firstDecision(log).getVariationId());
+ }
+
+ @Test
+ public void variationId_whitespace_becomesNull() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "1111", "2222", " ");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertNull(firstDecision(log).getVariationId());
+ }
+
+ @Test
+ public void variationId_nonNumericString_becomesNull() {
+ ImpressionEvent imp = buildImpression(
+ "experiment", "1111", "2222", "variationKey");
+ LogEvent log = EventFactory.createLogEvent(imp);
+ assertNull(firstDecision(log).getVariationId());
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-005: Uniform across decision types
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void normalization_uniformAcrossAllRuleTypes() {
+ // Verifies the same relaxed-campaign / strict-variation behavior
+ // applies uniformly across every rule type.
+ String[] ruleTypes = {"experiment", "feature-test", "rollout", "holdout"};
+ for (String ruleType : ruleTypes) {
+ ImpressionEvent imp = buildImpression(
+ ruleType, "bad-layer", "2222", "bad-variation");
+ LogEvent log = EventFactory.createLogEvent(imp);
+
+ Decision d = firstDecision(log);
+ Event e = firstEvent(log);
+
+ // campaign_id: opaque string is valid under the relaxed contract,
+ // so it passes through unchanged for every rule type.
+ assertEquals(
+ "campaign_id should pass through unchanged for ruleType=" + ruleType,
+ "bad-layer", d.getCampaignId());
+ assertEquals(
+ "entity_id must mirror campaign_id for ruleType=" + ruleType,
+ "bad-layer", e.getEntityId());
+ // variation_id: still strict numeric-only — non-numeric becomes null.
+ assertNull(
+ "variation_id should become null for ruleType=" + ruleType,
+ d.getVariationId());
+ }
+ }
+
+ @Test
+ public void normalization_uniformAcrossAllRuleTypes_nullCampaignFallsBack() {
+ // When campaign_id is null, the fallback to experiment_id fires
+ // uniformly across every rule type.
+ String[] ruleTypes = {"experiment", "feature-test", "rollout", "holdout"};
+ for (String ruleType : ruleTypes) {
+ ImpressionEvent imp = buildImpression(
+ ruleType, null, "2222", "bad-variation");
+ LogEvent log = EventFactory.createLogEvent(imp);
+
+ Decision d = firstDecision(log);
+ Event e = firstEvent(log);
+
+ assertEquals(
+ "campaign_id should fall back to experiment_id when null for ruleType=" + ruleType,
+ "2222", d.getCampaignId());
+ assertEquals(
+ "entity_id must mirror campaign_id for ruleType=" + ruleType,
+ "2222", e.getEntityId());
+ assertNull(
+ "variation_id should become null for ruleType=" + ruleType,
+ d.getVariationId());
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-009: byte-for-byte equality of entity_id and campaign_id
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void entityId_alwaysMirrorsNormalizedCampaignId() {
+ // 1) Both valid numeric → equal, passed through.
+ ImpressionEvent imp1 = buildImpression("experiment", "1111", "2222", "3333");
+ LogEvent log1 = EventFactory.createLogEvent(imp1);
+ assertEquals(firstDecision(log1).getCampaignId(), firstEvent(log1).getEntityId());
+ assertEquals("1111", firstEvent(log1).getEntityId());
+
+ // 2) campaign_id is opaque non-numeric string → passes through;
+ // entity_id still mirrors it.
+ ImpressionEvent imp2 = buildImpression("experiment", "bad", "2222", "3333");
+ LogEvent log2 = EventFactory.createLogEvent(imp2);
+ assertEquals(firstDecision(log2).getCampaignId(), firstEvent(log2).getEntityId());
+ assertEquals("bad", firstEvent(log2).getEntityId());
+
+ // 3) campaign_id null → fallback to experiment_id, entity_id mirrors it.
+ ImpressionEvent imp3 = buildImpression("experiment", null, "2222", "3333");
+ LogEvent log3 = EventFactory.createLogEvent(imp3);
+ assertEquals(firstDecision(log3).getCampaignId(), firstEvent(log3).getEntityId());
+ assertEquals("2222", firstEvent(log3).getEntityId());
+
+ // 4) campaign_id empty string → fallback to experiment_id, entity_id mirrors it.
+ ImpressionEvent imp4 = buildImpression("experiment", "", "2222", "3333");
+ LogEvent log4 = EventFactory.createLogEvent(imp4);
+ assertEquals(firstDecision(log4).getCampaignId(), firstEvent(log4).getEntityId());
+ assertEquals("2222", firstEvent(log4).getEntityId());
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-006/FR-007: Event must never be dropped, no exceptions
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void event_isNeverDropped_evenWhenAllIdsAreInvalid() {
+ // Even when EVERY id is null or non-numeric, the event must still be
+ // created — normalization never drops events and never throws.
+ ImpressionEvent impNullCampaign = buildImpression(
+ "experiment", null, "2222", "bad");
+ LogEvent logNull = EventFactory.createLogEvent(impNullCampaign);
+ assertNotNull("Event must not be dropped due to id normalization", logNull);
+ assertNotNull(firstDecision(logNull));
+ assertNotNull(firstEvent(logNull));
+
+ ImpressionEvent impEmptyCampaign = buildImpression(
+ "experiment", "", "2222", "bad");
+ LogEvent logEmpty = EventFactory.createLogEvent(impEmptyCampaign);
+ assertNotNull("Event must not be dropped due to id normalization", logEmpty);
+ assertNotNull(firstDecision(logEmpty));
+ assertNotNull(firstEvent(logEmpty));
+
+ // Also verify an opaque-but-non-empty campaign_id (now valid under the
+ // relaxed contract) still produces a non-null event.
+ ImpressionEvent impOpaqueCampaign = buildImpression(
+ "experiment", "bad", "2222", "bad");
+ LogEvent logOpaque = EventFactory.createLogEvent(impOpaqueCampaign);
+ assertNotNull("Event must not be dropped due to id normalization", logOpaque);
+ assertNotNull(firstDecision(logOpaque));
+ assertNotNull(firstEvent(logOpaque));
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-010: Conversion entity_id is unchanged
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void conversionEvent_entityId_isUnchanged() {
+ // Conversion events derive entity_id from a different source (event_id) and
+ // must NOT be normalized. We use a non-numeric event_id ("event_abc") and
+ // verify it passes through verbatim.
+ ConversionEvent conversion = new ConversionEvent.Builder()
+ .withUserContext(userContext())
+ .withEventId("event_abc")
+ .withEventKey("checkout")
+ .withRevenue(null)
+ .withValue(null)
+ .withTags(Collections.emptyMap())
+ .build();
+
+ LogEvent log = EventFactory.createLogEvent(conversion);
+ assertNotNull(log);
+
+ Event e = firstEvent(log);
+ assertEquals("event_abc", e.getEntityId());
+ assertEquals("checkout", e.getKey());
+ }
+
+ // ---------------------------------------------------------------------
+ // FR-008: Wire output is identical for identical inputs (determinism).
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void wireOutput_isDeterministic_forSameInput() {
+ ImpressionEvent imp1 = buildImpression("experiment", "1111", "2222", "3333");
+ ImpressionEvent imp2 = buildImpression("experiment", "1111", "2222", "3333");
+
+ LogEvent log1 = EventFactory.createLogEvent(imp1);
+ LogEvent log2 = EventFactory.createLogEvent(imp2);
+
+ Decision d1 = firstDecision(log1);
+ Decision d2 = firstDecision(log2);
+ assertEquals(d1.getCampaignId(), d2.getCampaignId());
+ assertEquals(d1.getExperimentId(), d2.getExperimentId());
+ assertEquals(d1.getVariationId(), d2.getVariationId());
+ assertEquals(firstEvent(log1).getEntityId(), firstEvent(log2).getEntityId());
+ }
+
+ // ---------------------------------------------------------------------
+ // Reference safety: when campaign_id is valid, the same string is used
+ // for both decision.campaign_id and event.entity_id (no defensive copy).
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void validCampaignId_usedForBothDecisionAndEntity() {
+ String layerId = "9876543210";
+ ImpressionEvent imp = buildImpression("experiment", layerId, "2222", "3333");
+ LogEvent log = EventFactory.createLogEvent(imp);
+
+ assertSame(
+ "Same numeric campaign_id must be reused for both fields",
+ layerId, firstDecision(log).getCampaignId());
+ assertSame(
+ "entity_id must reuse the same normalized campaign_id reference",
+ layerId, firstEvent(log).getEntityId());
+ }
+}
diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventIdNormalizerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventIdNormalizerTest.java
new file mode 100644
index 000000000..2b9f2d254
--- /dev/null
+++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventIdNormalizerTest.java
@@ -0,0 +1,242 @@
+/**
+ *
+ * Copyright 2026, Optimizely and contributors
+ *
+ * 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.
+ */
+package com.optimizely.ab.event.internal;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for {@link EventIdNormalizer}.
+ *
+ *
+ *
+ */
+public class EventIdNormalizerTest {
+
+ // ---------------------------------------------------------------------
+ // isNonEmptyString — used for campaign_id / entity_id
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void isNonEmptyString_anyNonEmpty_returnsTrue() {
+ assertTrue(EventIdNormalizer.isNonEmptyString("0"));
+ assertTrue(EventIdNormalizer.isNonEmptyString("12345"));
+ assertTrue(EventIdNormalizer.isNonEmptyString("default-12345"));
+ assertTrue(EventIdNormalizer.isNonEmptyString("layer_abc"));
+ // Whitespace-only strings are still non-empty by length, and any character
+ // content is allowed under the relaxed campaign_id / entity_id contract.
+ assertTrue(EventIdNormalizer.isNonEmptyString(" "));
+ assertTrue(EventIdNormalizer.isNonEmptyString("\t"));
+ }
+
+ @Test
+ public void isNonEmptyString_null_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNonEmptyString(null));
+ }
+
+ @Test
+ public void isNonEmptyString_empty_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNonEmptyString(""));
+ }
+
+ // ---------------------------------------------------------------------
+ // isNumericString — used for variation_id (strict)
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void isNumericString_validDecimalDigits_returnsTrue() {
+ assertTrue(EventIdNormalizer.isNumericString("0"));
+ assertTrue(EventIdNormalizer.isNumericString("1"));
+ assertTrue(EventIdNormalizer.isNumericString("12345"));
+ assertTrue(EventIdNormalizer.isNumericString("9999999999999"));
+ }
+
+ @Test
+ public void isNumericString_leadingZerosAllowed_returnsTrue() {
+ assertTrue(EventIdNormalizer.isNumericString("0123"));
+ assertTrue(EventIdNormalizer.isNumericString("00"));
+ assertTrue(EventIdNormalizer.isNumericString("000000001"));
+ }
+
+ @Test
+ public void isNumericString_null_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString(null));
+ }
+
+ @Test
+ public void isNumericString_empty_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString(""));
+ }
+
+ @Test
+ public void isNumericString_whitespace_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString(" "));
+ assertFalse(EventIdNormalizer.isNumericString(" "));
+ assertFalse(EventIdNormalizer.isNumericString("\t"));
+ assertFalse(EventIdNormalizer.isNumericString("\n"));
+ // surrounding whitespace is also invalid
+ assertFalse(EventIdNormalizer.isNumericString(" 123"));
+ assertFalse(EventIdNormalizer.isNumericString("123 "));
+ assertFalse(EventIdNormalizer.isNumericString(" 123 "));
+ }
+
+ @Test
+ public void isNumericString_nonDigits_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString("abc"));
+ assertFalse(EventIdNormalizer.isNumericString("variation_a"));
+ assertFalse(EventIdNormalizer.isNumericString("exp_42"));
+ assertFalse(EventIdNormalizer.isNumericString("layerId"));
+ assertFalse(EventIdNormalizer.isNumericString("12a"));
+ assertFalse(EventIdNormalizer.isNumericString("a12"));
+ }
+
+ @Test
+ public void isNumericString_negativesAreInvalid_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString("-1"));
+ assertFalse(EventIdNormalizer.isNumericString("-123"));
+ }
+
+ @Test
+ public void isNumericString_decimalsAreInvalid_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString("1.0"));
+ assertFalse(EventIdNormalizer.isNumericString("123.456"));
+ assertFalse(EventIdNormalizer.isNumericString("."));
+ }
+
+ @Test
+ public void isNumericString_exponentsAreInvalid_returnsFalse() {
+ assertFalse(EventIdNormalizer.isNumericString("1e5"));
+ assertFalse(EventIdNormalizer.isNumericString("1E5"));
+ assertFalse(EventIdNormalizer.isNumericString("1.0e3"));
+ }
+
+ @Test
+ public void isNumericString_unicodeDigitsAreInvalid_returnsFalse() {
+ // Unicode digit U+0660 (Arabic-Indic 0) — not ASCII [0-9], must be rejected.
+ assertFalse(EventIdNormalizer.isNumericString("٠١٢"));
+ // Fullwidth digits U+FF10..U+FF19 — must be rejected.
+ assertFalse(EventIdNormalizer.isNumericString("123"));
+ }
+
+ // ---------------------------------------------------------------------
+ // normalizeCampaignId — relaxed: any non-empty string passes through
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void normalizeCampaignId_validNumeric_returnsCampaignId() {
+ assertEquals("12345", EventIdNormalizer.normalizeCampaignId("12345", "67890"));
+ assertEquals("0", EventIdNormalizer.normalizeCampaignId("0", "67890"));
+ assertEquals("0123", EventIdNormalizer.normalizeCampaignId("0123", "67890"));
+ }
+
+ @Test
+ public void normalizeCampaignId_opaqueString_passesThroughUnchanged() {
+ // Any non-empty string is valid for campaign_id (IDs may be opaque),
+ // so no fallback fires.
+ assertEquals("default-12345", EventIdNormalizer.normalizeCampaignId("default-12345", "67890"));
+ assertEquals("layer_abc", EventIdNormalizer.normalizeCampaignId("layer_abc", "67890"));
+ assertEquals("abc", EventIdNormalizer.normalizeCampaignId("abc", "67890"));
+ assertEquals("exp_42", EventIdNormalizer.normalizeCampaignId("exp_42", "67890"));
+ assertEquals("12a", EventIdNormalizer.normalizeCampaignId("12a", "67890"));
+ assertEquals("-1", EventIdNormalizer.normalizeCampaignId("-1", "67890"));
+ assertEquals("1.0", EventIdNormalizer.normalizeCampaignId("1.0", "67890"));
+ }
+
+ @Test
+ public void normalizeCampaignId_whitespace_passesThroughUnchanged() {
+ // Whitespace-only strings have length >= 1, so under the relaxed contract
+ // they are accepted as-is. The pipeline is responsible for further validation.
+ assertEquals(" ", EventIdNormalizer.normalizeCampaignId(" ", "67890"));
+ assertEquals(" ", EventIdNormalizer.normalizeCampaignId(" ", "67890"));
+ assertEquals("\t", EventIdNormalizer.normalizeCampaignId("\t", "67890"));
+ }
+
+ @Test
+ public void normalizeCampaignId_null_fallsBackToExperimentId() {
+ assertEquals("67890", EventIdNormalizer.normalizeCampaignId(null, "67890"));
+ }
+
+ @Test
+ public void normalizeCampaignId_empty_fallsBackToExperimentId() {
+ assertEquals("67890", EventIdNormalizer.normalizeCampaignId("", "67890"));
+ }
+
+ @Test
+ public void normalizeCampaignId_invalidCampaignAndNullExperiment_returnsNull() {
+ // Spec is silent on this combination; library returns experiment_id as-is, which may be null.
+ // This is intentional: no logging, no failure, just pass-through.
+ assertNull(EventIdNormalizer.normalizeCampaignId(null, null));
+ assertNull(EventIdNormalizer.normalizeCampaignId("", null));
+ }
+
+ @Test
+ public void normalizeCampaignId_nullCampaignAndOpaqueExperiment_returnsExperimentAsIs() {
+ // Fallback is verbatim — not re-validated. This matches the spec.
+ assertEquals("expKey", EventIdNormalizer.normalizeCampaignId(null, "expKey"));
+ assertEquals("default-99", EventIdNormalizer.normalizeCampaignId("", "default-99"));
+ }
+
+ // ---------------------------------------------------------------------
+ // normalizeVariationId — strict numeric-string-only (UNCHANGED)
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void normalizeVariationId_validNumeric_returnsVariationId() {
+ assertEquals("12345", EventIdNormalizer.normalizeVariationId("12345"));
+ assertEquals("0", EventIdNormalizer.normalizeVariationId("0"));
+ assertEquals("0123", EventIdNormalizer.normalizeVariationId("0123"));
+ }
+
+ @Test
+ public void normalizeVariationId_null_returnsNull() {
+ assertNull(EventIdNormalizer.normalizeVariationId(null));
+ }
+
+ @Test
+ public void normalizeVariationId_empty_returnsNull() {
+ assertNull(EventIdNormalizer.normalizeVariationId(""));
+ }
+
+ @Test
+ public void normalizeVariationId_whitespace_returnsNull() {
+ assertNull(EventIdNormalizer.normalizeVariationId(" "));
+ assertNull(EventIdNormalizer.normalizeVariationId(" "));
+ assertNull(EventIdNormalizer.normalizeVariationId("\t"));
+ }
+
+ @Test
+ public void normalizeVariationId_nonNumeric_returnsNull() {
+ assertNull(EventIdNormalizer.normalizeVariationId("abc"));
+ assertNull(EventIdNormalizer.normalizeVariationId("variation_a"));
+ assertNull(EventIdNormalizer.normalizeVariationId("variationId"));
+ assertNull(EventIdNormalizer.normalizeVariationId("12a"));
+ assertNull(EventIdNormalizer.normalizeVariationId("-1"));
+ assertNull(EventIdNormalizer.normalizeVariationId("1.0"));
+ }
+}
diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java
index 84f69055c..534557193 100644
--- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java
+++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java
@@ -151,4 +151,51 @@ public void serializeDecisionMetadataWithCmabUuid() throws IOException {
assertTrue("Serialized JSON should contain the UUID value", serialized.contains(cmabUuid));
assertFalse("Serialized JSON must NOT contain 'cmab_u_u_i_d'", serialized.contains("\"cmab_u_u_i_d\""));
}
+
+ @Test
+ public void serializeDecisionWithNullVariationId_emitsExplicitJsonNull() {
+ // JacksonSerializer sets Include.NON_NULL globally, so fields default to being
+ // stripped when null. Decision.variation_id must override that with
+ // @JsonInclude(ALWAYS) so the wire payload carries "variation_id": null instead
+ // of dropping the key — required for cross-SDK byte-equivalence under FSSDK-12813.
+ DecisionMetadata metadata = new DecisionMetadata.Builder()
+ .setFlagKey("test_flag")
+ .setRuleKey("test_rule")
+ .setRuleType("holdout")
+ .setVariationKey("ho_off_key")
+ .setEnabled(false)
+ .build();
+
+ Decision decision = new Decision.Builder()
+ .setCampaignId("12345")
+ .setExperimentId("67890")
+ .setVariationId(null)
+ .setIsCampaignHoldback(false)
+ .setMetadata(metadata)
+ .build();
+
+ Snapshot snapshot = new Snapshot.Builder()
+ .setDecisions(Collections.singletonList(decision))
+ .setEvents(Collections.