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 featureFlags = optimizely.getEnabledFeatures(testUserId, Collections.emptyMap()); assertEquals(2, featureFlags.size()); - eventHandler.expectImpression(null, "", testUserId); - eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, null, testUserId); + eventHandler.expectImpression(null, null, testUserId); eventHandler.expectImpression("3794675122", "589640735", testUserId); - eventHandler.expectImpression(null, "", testUserId); - eventHandler.expectImpression(null, "", testUserId); - eventHandler.expectImpression(null, "", testUserId); - eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, null, testUserId); + eventHandler.expectImpression(null, null, testUserId); + eventHandler.expectImpression(null, null, testUserId); + eventHandler.expectImpression(null, null, testUserId); eventHandler.expectImpression("1786133852", "1619235542", testUserId); // Verify that listener being called @@ -1853,14 +1853,14 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { // Verify that listener not being called assertFalse(isListenerCalled); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } @@ -2013,7 +2013,7 @@ public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() thr "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? false" ); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); // Verify that listener being called assertTrue(isListenerCalled); @@ -3339,7 +3339,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? false" ); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), @@ -3384,7 +3384,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria "Feature \"" + validFeatureKey + "\" is enabled for user \"" + genericUserId + "\"? true" ); - eventHandler.expectImpression("3421010877", "variationId", genericUserId); + eventHandler.expectImpression("3421010877", null, genericUserId); verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), @@ -3456,7 +3456,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc ); assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId)); - eventHandler.expectImpression("3421010877", "variationId", genericUserId); + eventHandler.expectImpression("3421010877", null, genericUserId); } @@ -3485,7 +3485,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E ); assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId)); - eventHandler.expectImpression("3421010877", "variationId", genericUserId); + eventHandler.expectImpression("3421010877", null, genericUserId); } @@ -3582,10 +3582,10 @@ public void getEnabledFeatureWithValidUserId() throws Exception { List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertFalse(featureFlags.isEmpty()); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); eventHandler.expectImpression("3794675122", "589640735", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); eventHandler.expectImpression("1785077004", "1566407342", genericUserId); eventHandler.expectImpression("828245624", "3137445031", genericUserId); eventHandler.expectImpression("828245624", "3137445031", genericUserId); @@ -3606,10 +3606,10 @@ public void getEnabledFeatureWithEmptyUserId() throws Exception { List featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap()); assertFalse(featureFlags.isEmpty()); - eventHandler.expectImpression(null, "", ""); - eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression(null, null, ""); + eventHandler.expectImpression(null, null, ""); eventHandler.expectImpression("3794675122", "589640735", ""); - eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression(null, null, ""); eventHandler.expectImpression("1785077004", "1566407342", ""); eventHandler.expectImpression("828245624", "3137445031", ""); eventHandler.expectImpression("828245624", "3137445031", ""); @@ -3660,14 +3660,14 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception { List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); - eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); + eventHandler.expectImpression(null, null, genericUserId); } /** diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 2e479d2ea..807576513 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2021-2024, Optimizely and contributors + * Copyright 2021-2024, 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. @@ -267,7 +267,7 @@ public void decide_nullVariation() { .setVariationKey("") .setEnabled(false) .build(); - eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata); + eventHandler.expectImpression(null, null, userId, Collections.emptyMap(), metadata); } // decideAll @@ -639,7 +639,7 @@ public void decide_sendEvent_rollout_withSendFlagDecisionsOn() { user.decide(flagKey); assertTrue(isListenerCalled); - eventHandler.expectImpression(null, "", userId, attributes); + eventHandler.expectImpression(null, null, userId, attributes); } @Test @@ -2102,6 +2102,7 @@ public void decisionNotification_with_holdout() throws Exception { String variationKey = "ho_off_key"; // holdout (off) variation key String experimentId = "10075323428"; // holdout experiment id in holdouts-project-config.json String variationId = "$opt_dummy_variation_id";// dummy variation id used for holdout impressions + String expectedDispatchedVariationId = null; String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (" + ruleKey + ")."; Map attrs = new HashMap<>(); @@ -2153,7 +2154,7 @@ public void decisionNotification_with_holdout() throws Exception { .setVariationKey(variationKey) .setEnabled(false) .build(); - eventHandler.expectImpression(experimentId, variationId, userId, Collections.singletonMap("nationality", "English"), metadata); + eventHandler.expectImpression(experimentId, expectedDispatchedVariationId, userId, Collections.singletonMap("nationality", "English"), metadata); // Log expectation (reuse existing pattern) logbackVerifier.expectMessage(Level.INFO, expectedReason); @@ -2177,6 +2178,7 @@ public void decide_for_keys_with_holdout() throws Exception { String holdoutExperimentId = "10075323428"; // basic_holdout id String variationId = "$opt_dummy_variation_id"; + String expectedDispatchedVariationId = null; String variationKey = "ho_off_key"; String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (basic_holdout)."; @@ -2195,7 +2197,7 @@ public void decide_for_keys_with_holdout() throws Exception { .setEnabled(false) .build(); // attributes map expected empty (reserved $opt_ attribute filtered out) - eventHandler.expectImpression(holdoutExperimentId, variationId, userId, Collections.emptyMap(), metadata); + eventHandler.expectImpression(holdoutExperimentId, expectedDispatchedVariationId, userId, Collections.emptyMap(), metadata); } // At least one log message confirming holdout membership diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryNormalizationTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryNormalizationTest.java new file mode 100644 index 000000000..7644ad220 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryNormalizationTest.java @@ -0,0 +1,424 @@ +/** + * + * 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 com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.internal.payload.Decision; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.event.internal.payload.Event; +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.event.internal.payload.Snapshot; +import com.optimizely.ab.event.internal.payload.Visitor; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration tests for decision-event identifier normalization, exercising + * the full path from {@link ImpressionEvent} through + * {@link EventFactory#createLogEvent} to the wire payload. + * + *

Verifies: + *

+ */ +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}. + * + *

Covers normalization rules: + *

+ */ +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.emptyList()) + .build(); + + Visitor visitor = new Visitor.Builder() + .setVisitorId("visitor123") + .setAttributes(Collections.emptyList()) + .setSnapshots(Collections.singletonList(snapshot)) + .build(); + + EventBatch eventBatch = new EventBatch.Builder() + .setAccountId("accountId") + .setProjectId("projectId") + .setRevision("1") + .setVisitors(Collections.singletonList(visitor)) + .build(); + + String serialized = serializer.serialize(eventBatch); + assertTrue( + "variation_id key must be present with explicit null when normalization " + + "yields null: " + serialized, + serialized.contains("\"variation_id\":null")); + } }