diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 66dd30d15..f9631db7c 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -43,7 +43,6 @@ import com.optimizely.ab.event.internal.UserEvent; import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; -import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.ActivateNotification; import com.optimizely.ab.notification.DecisionNotification; @@ -306,7 +305,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Map filteredAttributes, @Nonnull Variation variation, @Nonnull String ruleType) { - sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true); + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true, null); } /** @@ -319,6 +318,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, * @param variation the variation that was returned from activate. * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + * @param cmabUUID The cmabUUID if the experiment is a cmab experiment. */ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable ExperimentCore experiment, @@ -327,7 +327,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable Variation variation, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, @@ -337,7 +338,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, filteredAttributes, flagKey, ruleType, - enabled); + enabled, + cmabUUID); if (userEvent == null) { return false; @@ -499,7 +501,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, if (featureDecision.decisionSource != null) { decisionSource = featureDecision.decisionSource; } - + String cmabUUID = featureDecision.cmabUUID; if (featureDecision.variation != null) { // This information is only necessary for feature tests. // For rollouts experiments and variations are an implementation detail only. @@ -521,7 +523,8 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, featureDecision.variation, featureKey, decisionSource.toString(), - featureEnabled); + featureEnabled, + cmabUUID); DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() .withUserId(userId) @@ -1336,6 +1339,8 @@ private OptimizelyDecision createOptimizelyDecision( Map attributes = user.getAttributes(); Map copiedAttributes = new HashMap<>(attributes); + String cmabUUID = flagDecision.cmabUUID; + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { decisionEventDispatched = sendImpression( projectConfig, @@ -1345,7 +1350,8 @@ private OptimizelyDecision createOptimizelyDecision( flagDecision.variation, flagKey, decisionSource.toString(), - flagEnabled); + flagEnabled, + cmabUUID); } DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 35fa21c71..be37b4b7b 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.bucketing; +import java.util.Collections; import java.util.List; import javax.annotation.Nonnull; @@ -97,7 +98,8 @@ private Experiment bucketToExperiment(@Nonnull Group group, @Nonnull private DecisionResponse bucketToVariation(@Nonnull ExperimentCore experiment, - @Nonnull String bucketingId) { + @Nonnull String bucketingId, + @Nonnull DecisionPath decisionPath) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // "salt" the bucket id using the experiment id @@ -111,8 +113,25 @@ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore ex int bucketValue = generateBucketValue(hashCode); logger.debug("Assigned bucket {} to user with bucketingId \"{}\" when bucketing to a variation.", bucketValue, bucketingId); + // Only apply CMAB traffic allocation logic if decision path is WITH_CMAB + if (decisionPath == DecisionPath.WITH_CMAB && experiment instanceof Experiment && ((Experiment) experiment).getCmab() != null) { + // For CMAB experiments, the original trafficAllocation is kept empty for backward compatibility. + // Use the traffic allocation defined in the CMAB block for bucketing instead. + String message = reasons.addInfo("Using CMAB traffic allocation for experiment \"%s\"", experimentKey); + logger.info(message); + trafficAllocations = Collections.singletonList( + new TrafficAllocation("$", ((Experiment) experiment).getCmab().getTrafficAllocation()) + ); + } + String bucketedVariationId = bucketToEntity(bucketValue, trafficAllocations); - if (bucketedVariationId != null) { + if (decisionPath == DecisionPath.WITH_CMAB && "$".equals(bucketedVariationId)) { + // for cmab experiments + String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into CMAB for experiment \"%s\"", bucketingId, experimentKey); + logger.info(message); + return new DecisionResponse(new Variation("$", "$"), reasons); + } + else if (bucketedVariationId != null) { Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId); String variationKey = bucketedVariation.getKey(); String message = reasons.addInfo("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey, @@ -134,12 +153,14 @@ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore ex * @param experiment The Experiment in which the user is to be bucketed. * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. * @param projectConfig The current projectConfig + * @param decisionPath enum for decision making logic * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull public DecisionResponse bucket(@Nonnull ExperimentCore experiment, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionPath decisionPath) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // ---------- Bucket User ---------- @@ -154,8 +175,6 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); logger.info(message); return new DecisionResponse(null, reasons); - } else { - } // if the experiment a user is bucketed in within a group isn't the same as the experiment provided, // don't perform further bucketing within the experiment @@ -172,11 +191,26 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, } } - DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId); + DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId, decisionPath); reasons.merge(decisionResponse.getReasons()); return new DecisionResponse<>(decisionResponse.getResult(), reasons); } + /** + * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3. + * + * @param experiment The Experiment in which the user is to be bucketed. + * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons + */ + @Nonnull + public DecisionResponse bucket(@Nonnull ExperimentCore experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + return bucket(experiment, bucketingId, projectConfig, DecisionPath.WITHOUT_CMAB); + } + //======== Helper methods ========// /** diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index a077d3788..65703ac55 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -170,8 +170,8 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); String cmabUUID = null; - decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); - if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment) && decisionVariation.getResult() != null) { + decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig, decisionPath); + if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment) && decisionVariation.getResult() != null) { // group-allocation and traffic-allocation checking passed for cmab // we need server decision overruling local bucketing for cmab DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index c8687f7a6..93f0f1f8b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -41,7 +41,8 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje @Nonnull Map attributes, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) { @@ -68,13 +69,18 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje .withProjectConfig(projectConfig) .build(); - DecisionMetadata metadata = new DecisionMetadata.Builder() + DecisionMetadata.Builder metadataBuilder = new DecisionMetadata.Builder() .setFlagKey(flagKey) .setRuleKey(experimentKey) .setRuleType(ruleType) .setVariationKey(variationKey) - .setEnabled(enabled) - .build(); + .setEnabled(enabled); + + if (cmabUUID != null) { + metadataBuilder.setCmabUUID(cmabUUID); + } + + DecisionMetadata metadata = metadataBuilder.build(); return new ImpressionEvent.Builder() .withUserContext(userContext) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index aec6cdce2..3613c979a 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -33,17 +33,20 @@ public class DecisionMetadata { String variationKey; @JsonProperty("enabled") boolean enabled; + @JsonProperty("cmab_uuid") + String cmabUUID; @VisibleForTesting public DecisionMetadata() { } - public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled) { + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled, String cmabUUID) { this.flagKey = flagKey; this.ruleKey = ruleKey; this.ruleType = ruleType; this.variationKey = variationKey; this.enabled = enabled; + this.cmabUUID = cmabUUID; } public String getRuleType() { @@ -66,6 +69,10 @@ public String getVariationKey() { return variationKey; } + public String getCmabUUID() { + return cmabUUID; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -77,6 +84,7 @@ public boolean equals(Object o) { if (!ruleKey.equals(that.ruleKey)) return false; if (!flagKey.equals(that.flagKey)) return false; if (enabled != that.enabled) return false; + if (!java.util.Objects.equals(cmabUUID, that.cmabUUID)) return false; return variationKey.equals(that.variationKey); } @@ -86,6 +94,7 @@ public int hashCode() { result = 31 * result + flagKey.hashCode(); result = 31 * result + ruleKey.hashCode(); result = 31 * result + variationKey.hashCode(); + result = 31 * result + (cmabUUID != null ? cmabUUID.hashCode() : 0); return result; } @@ -97,6 +106,7 @@ public String toString() { .add("ruleType='" + ruleType + "'") .add("variationKey='" + variationKey + "'") .add("enabled=" + enabled) + .add("cmabUUID='" + cmabUUID + "'") .toString(); } @@ -108,6 +118,7 @@ public static class Builder { private String flagKey; private String variationKey; private boolean enabled; + private String cmabUUID; public Builder setEnabled(boolean enabled) { this.enabled = enabled; @@ -134,8 +145,13 @@ public Builder setVariationKey(String variationKey) { return this; } + public Builder setCmabUUID(String cmabUUID){ + this.cmabUUID = cmabUUID; + return this; + } + public DecisionMetadata build() { - return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled, cmabUUID); } } } 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 3b066df21..e24de6c2b 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -504,7 +504,7 @@ public void activateForNullVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Map testUserAttributes = Collections.singletonMap("browser_type", "chromey"); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.nullNoReasons()); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + activatedExperiment.getKey() + "\"."); @@ -1381,7 +1381,7 @@ public void getVariation() throws Exception { Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Map testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); @@ -1391,7 +1391,7 @@ public void getVariation() throws Exception { testUserAttributes); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig)); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), any(DecisionPath.class)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1412,13 +1412,13 @@ public void getVariationWithExperimentKey() throws Exception { .withConfig(noAudienceProjectConfig) .build(); - when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig)); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), any(DecisionPath.class)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1473,7 +1473,7 @@ public void getVariationWithAudiences() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1482,7 +1482,7 @@ public void getVariationWithAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); - verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig)); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), any(DecisionPath.class)); assertThat(actualVariation, is(bucketedVariation)); } @@ -1523,7 +1523,7 @@ public void getVariationNoAudiences() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); Optimizely optimizely = optimizelyBuilder .withConfig(noAudienceProjectConfig) @@ -1532,7 +1532,7 @@ public void getVariationNoAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig)); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), any(DecisionPath.class)); assertThat(actualVariation, is(bucketedVariation)); } @@ -1590,7 +1590,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); + when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(variation)); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index ff451edbe..c5d9f25d6 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -1041,7 +1041,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Collections.singletonMap(experiment.getId(), decision)); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); + when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(variation)); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService, mockCmabService); @@ -1107,7 +1107,7 @@ public void getVariationSavesANewUserProfile() throws Exception { UserProfileService userProfileService = mock(UserProfileService.class); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, mockCmabService); - when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); + when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); assertEquals(variation, decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); @@ -1121,7 +1121,7 @@ public void getVariationBucketingId() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); - when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); + when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig), any(DecisionPath.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); @@ -1538,7 +1538,7 @@ public void getVariationCmabExperimentServiceError() { // Bucketer bucketer = new Bucketer(); Bucketer mockBucketer = mock(Bucketer.class); Variation bucketedVariation = new Variation("$", "$"); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), any(DecisionPath.class))) .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); DecisionService decisionServiceWithMockCmabService = new DecisionService( mockBucketer, @@ -1603,7 +1603,7 @@ public void getVariationCmabExperimentServiceSuccess() { // Mock bucketer to return a variation (user is in CMAB traffic) Variation bucketedVariation = new Variation("$", "$"); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(DecisionPath.WITH_CMAB))) .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); DecisionService decisionServiceWithMockCmabService = new DecisionService( @@ -1674,7 +1674,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { // Mock bucketer to return null for CMAB allocation (user not in CMAB traffic) Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), any(DecisionPath.class))) .thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionServiceWithMockCmabService = new DecisionService( @@ -1701,7 +1701,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); // Verify that bucketer was called for CMAB allocation - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), any(DecisionPath.class)); } private Experiment createMockCmabExperiment() { diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index 08a8b7da9..ed9d32979 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -104,7 +104,7 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Map attributeMap = new HashMap(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true); + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true, null); Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) @@ -1064,7 +1064,8 @@ public static LogEvent createImpressionEvent(ProjectConfig projectConfig, attributes, activatedExperiment.getKey(), "experiment", - true); + true, + null); return EventFactory.createLogEvent(userEvent); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java index a7739bb73..24c0c5c80 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -32,6 +32,8 @@ import java.util.Map; import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -67,7 +69,7 @@ public class UserEventFactoryTest { public void setUp() { experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); variation = new Variation(VARIATION_ID, VARIATION_KEY); - decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true, null); } @Test @@ -81,7 +83,8 @@ public void createImpressionEventNull() { ATTRIBUTES, EXPERIMENT_KEY, "rollout", - false + false, + null ); assertNull(actual); } @@ -96,7 +99,8 @@ public void createImpressionEvent() { ATTRIBUTES, "", "experiment", - true + true, + null ); assertTrue(actual.getTimestamp() > 0); @@ -140,4 +144,102 @@ public void createConversionEvent() { assertEquals(VALUE, actual.getValue()); assertEquals(TAGS, actual.getTags()); } + @Test + public void createImpressionEventWithCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = "test-cmab-uuid-123"; + Map attributes = Collections.emptyMap(); + + // Create mock objects + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + // Setup mock behavior + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + + // Verify DecisionMetadata contains cmabUUID + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertEquals(cmabUUID, metadata.getCmabUUID()); + assertEquals(flagKey, metadata.getFlagKey()); + assertEquals("experimentKey", metadata.getRuleKey()); + assertEquals(ruleType, metadata.getRuleType()); + assertEquals("variationKey", metadata.getVariationKey()); + assertEquals(enabled, metadata.getEnabled()); + + // Verify other fields + assertEquals("layer123", result.getLayerId()); + assertEquals("experiment123", result.getExperimentId()); + assertEquals("experimentKey", result.getExperimentKey()); + assertEquals("variation123", result.getVariationId()); + assertEquals("variationKey", result.getVariationKey()); + } + + @Test + public void createImpressionEventWithNullCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = null; + Map attributes = Collections.emptyMap(); + + // Create mock objects (same setup as above) + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertNull(metadata.getCmabUUID()); + } }