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 d2db01c90..2c51bf6d8 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1356,6 +1356,19 @@ private OptimizelyDecision createOptimizelyDecision( cmabUuid); } + if (flagDecision.holdoutDecision != null && !allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { + decisionEventDispatched = sendImpression( + projectConfig, + flagDecision.holdoutDecision.experiment, + userId, + copiedAttributes, + flagDecision.holdoutDecision.variation, + flagKey, + flagDecision.holdoutDecision.decisionSource != null ? flagDecision.holdoutDecision.decisionSource.toString() : FeatureDecision.DecisionSource.HOLDOUT.toString(), + flagDecision.holdoutDecision.variation != null && flagDecision.holdoutDecision.variation.getFeatureEnabled(), + null) || decisionEventDispatched; + } + DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() .withUserId(userId) .withAttributes(copiedAttributes) 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 149fc1438..afb868d5e 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 @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022, 2024, Optimizely, Inc. and contributors * + * Copyright 2017-2022, 2024, 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. * @@ -328,43 +328,66 @@ public List> getVariationsForFeatureList(@Non // Evaluate global holdouts at flag level (before any rules are iterated) List globalHoldouts = projectConfig.getGlobalHoldouts(); + FeatureDecision globalHoldoutDecision = null; + boolean excludeTargetedDeliveries = false; if (!globalHoldouts.isEmpty()) { for (Holdout holdout : globalHoldouts) { DecisionResponse holdoutDecision = getVariationForHoldout(holdout, user, projectConfig); reasons.merge(holdoutDecision.getReasons()); if (holdoutDecision.getResult() != null) { - decisions.add(new DecisionResponse<>(new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT), reasons)); - continue flagLoop; + globalHoldoutDecision = new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT); + excludeTargetedDeliveries = holdout.isExcludeTargetedDeliveries(); + break; } } } - DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath); - reasons.merge(decisionVariationResponse.getReasons()); + if (globalHoldoutDecision != null && !excludeTargetedDeliveries) { + decisions.add(new DecisionResponse<>(globalHoldoutDecision, reasons)); + continue flagLoop; + } + + if (globalHoldoutDecision != null && excludeTargetedDeliveries) { + reasons.addInfo("Holdout '%s' has excludeTargetedDeliveries enabled, continuing to rollout evaluation.", globalHoldoutDecision.experiment.getKey()); + } - FeatureDecision decision = decisionVariationResponse.getResult(); - boolean error = decisionVariationResponse.isError(); + if (globalHoldoutDecision == null) { + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath); + reasons.merge(decisionVariationResponse.getReasons()); - if (decision != null) { - decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid)); - continue; + FeatureDecision decision = decisionVariationResponse.getResult(); + boolean error = decisionVariationResponse.isError(); + + if (decision != null) { + decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid)); + continue flagLoop; + } } DecisionResponse decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); reasons.merge(decisionFeatureResponse.getReasons()); - decision = decisionFeatureResponse.getResult(); + FeatureDecision decision = decisionFeatureResponse.getResult(); - String message; - if (decision.variation == null) { - message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", + if (decision != null && decision.variation != null) { + if (globalHoldoutDecision != null) { + decision.setHoldoutDecision(globalHoldoutDecision); + } + String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", user.getUserId(), featureFlag.getKey()); + logger.info(message); + decisions.add(new DecisionResponse(decision, reasons)); } else { - message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", + if (globalHoldoutDecision != null) { + if (decision == null) { + decision = new FeatureDecision(null, null, null); + } + decision.setHoldoutDecision(globalHoldoutDecision); + } + String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", user.getUserId(), featureFlag.getKey()); + logger.info(message); + decisions.add(new DecisionResponse(decision, reasons)); } - logger.info(message); - - decisions.add(new DecisionResponse(decision, reasons)); } if (userProfileService != null && !ignoreUPS) { diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java index ed369ee69..678ab77dc 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java @@ -45,6 +45,13 @@ public class FeatureDecision { @Nullable public String cmabUuid; + @Nullable + public FeatureDecision holdoutDecision; + + public void setHoldoutDecision(@Nullable FeatureDecision holdoutDecision) { + this.holdoutDecision = holdoutDecision; + } + public enum DecisionSource { FEATURE_TEST("feature-test"), ROLLOUT("rollout"), diff --git a/core-api/src/main/java/com/optimizely/ab/config/Holdout.java b/core-api/src/main/java/com/optimizely/ab/config/Holdout.java index 1680ae941..6a6dd092b 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Holdout.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Holdout.java @@ -51,6 +51,8 @@ public class Holdout implements ExperimentCore { @Nullable private final List includedRules; + private final boolean excludeTargetedDeliveries; + private final Map variationKeyToVariationMap; private final Map variationIdToVariationMap; // Not necessary for HO @@ -75,7 +77,7 @@ public String toString() { @VisibleForTesting public Holdout(String id, String key) { - this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null); + this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null, false); } /** @@ -88,7 +90,7 @@ public Holdout(@Nonnull String id, @Nullable Condition audienceConditions, @Nonnull List variations, @Nonnull List trafficAllocation) { - this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null); + this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null, false); } /** @@ -105,7 +107,8 @@ public Holdout(@JsonProperty("id") @Nonnull String id, @JsonProperty("audienceConditions") @Nullable Condition audienceConditions, @JsonProperty("variations") @Nonnull List variations, @JsonProperty("trafficAllocation") @Nonnull List trafficAllocation, - @JsonProperty("includedRules") @Nullable List includedRules) { + @JsonProperty("includedRules") @Nullable List includedRules, + @JsonProperty("exclude_targeted_deliveries") @Nullable Boolean excludeTargetedDeliveries) { this.id = id; this.key = key; this.status = status; @@ -114,6 +117,7 @@ public Holdout(@JsonProperty("id") @Nonnull String id, this.variations = variations; this.trafficAllocation = trafficAllocation; this.includedRules = includedRules; + this.excludeTargetedDeliveries = excludeTargetedDeliveries != null ? excludeTargetedDeliveries : false; this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations); this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations); } @@ -188,6 +192,10 @@ public List getIncludedRules() { * * @return true if this is a global holdout, false if it is a local holdout */ + public boolean isExcludeTargetedDeliveries() { + return excludeTargetedDeliveries; + } + public boolean isGlobal() { return includedRules == null; } @@ -204,6 +212,7 @@ public String toString() { + ", variationKeyToVariationMap=" + variationKeyToVariationMap + ", trafficAllocation=" + trafficAllocation + ", includedRules=" + includedRules + + ", excludeTargetedDeliveries=" + excludeTargetedDeliveries + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java b/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java index d514e533f..30ed2f885 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java @@ -193,7 +193,8 @@ private static Holdout stripIncludedRules(Holdout holdout) { holdout.getAudienceConditions(), holdout.getVariations(), holdout.getTrafficAllocation(), - null + null, + holdout.isExcludeTargetedDeliveries() ); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java index 52dbcd9b3..16d65e321 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, 2019, Optimizely and contributors + * Copyright 2016-2017, 2019, 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. @@ -212,7 +212,12 @@ static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext c } } - return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules); + boolean excludeTargetedDeliveries = false; + if (holdoutJson.has("exclude_targeted_deliveries") && !holdoutJson.get("exclude_targeted_deliveries").isJsonNull()) { + excludeTargetedDeliveries = holdoutJson.get("exclude_targeted_deliveries").getAsBoolean(); + } + + return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules, excludeTargetedDeliveries); } static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 5a546acf8..0ee61edba 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2022, Optimizely and contributors + * Copyright 2016-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. @@ -238,8 +238,13 @@ private List parseHoldouts(JSONArray holdoutJson) { } } + boolean excludeTargetedDeliveries = false; + if (holdoutObject.has("exclude_targeted_deliveries") && !holdoutObject.isNull("exclude_targeted_deliveries")) { + excludeTargetedDeliveries = holdoutObject.getBoolean("exclude_targeted_deliveries"); + } + holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations, - trafficAllocations, includedRules)); + trafficAllocations, includedRules, excludeTargetedDeliveries)); } return holdouts; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index 435abca28..0d9ce1c32 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2022, Optimizely and contributors + * Copyright 2016-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. @@ -257,8 +257,13 @@ private List parseHoldouts(JSONArray holdoutJson) { } } + boolean excludeTargetedDeliveries = false; + if (hoObject.containsKey("exclude_targeted_deliveries") && hoObject.get("exclude_targeted_deliveries") != null) { + excludeTargetedDeliveries = (Boolean) hoObject.get("exclude_targeted_deliveries"); + } + holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations, - trafficAllocations, includedRules)); + trafficAllocations, includedRules, excludeTargetedDeliveries)); } return holdouts; 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 4634fcbe1..9c9ad0e4f 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 @@ -82,6 +82,8 @@ import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; import static com.optimizely.ab.config.ValidProjectConfigV4.HOLDOUT_BASIC_HOLDOUT; import static com.optimizely.ab.config.ValidProjectConfigV4.HOLDOUT_TYPEDAUDIENCE_HOLDOUT; +import static com.optimizely.ab.config.ValidProjectConfigV4.HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES; +import static com.optimizely.ab.config.ValidProjectConfigV4.HOLDOUT_LOCAL_EXCLUDE_TARGETED_DELIVERIES; import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2; import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE; import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION; @@ -307,7 +309,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { } - //========== get Variation for Feature tests ==========// + //========= get Variation for Feature tests =========/ /** * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} @@ -556,7 +558,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails ); } - //========== getVariationForFeatureList tests ==========// + //========= getVariationForFeatureList tests =========/ @Test public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception { @@ -583,7 +585,7 @@ public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception } - //========== getVariationForFeatureInRollout tests ==========// + //========= getVariationForFeatureInRollout tests =========/ /** * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} @@ -889,7 +891,7 @@ public void validatedForcedDecisionWithoutRuleKey() { assertEquals(variationKey, variation.getKey()); } - //========= white list tests ==========/ + //========= white list tests =========/ /** * Test {@link DecisionService#getWhitelistedVariation(Experiment, String)} correctly returns a whitelisted variation. @@ -938,7 +940,7 @@ public void getWhitelistedReturnsNullWhenUserIsNotWhitelisted() throws Exception assertNull(decisionService.getWhitelistedVariation(whitelistedExperiment, genericUserId).getResult()); } - //======== User Profile tests =========// + //========= User Profile tests =========/ /** * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile, ProjectConfig)} returns a variation that is @@ -1746,7 +1748,6 @@ public void getVariationStandardExperimentSavesUserProfile() throws Exception { String.format("Saved user profile of user \"%s\".", genericUserId)); } - // =================================================================== //========= evaluateLocalHoldouts tests =========/ @Test @@ -1801,8 +1802,7 @@ public void evaluateLocalHoldouts_returnsNullWhenConfigHasNoHoldouts() { assertNull(response.getResult()); } - // Local holdout decision service tests (FSSDK-12369) - // =================================================================== + //========= global holdout tests =========/ /** * Global holdout is evaluated at flag level — a user bucketed into a global holdout @@ -1939,6 +1939,257 @@ public void localHoldout_forcedDecisionTakesPriorityOverLocalHoldout() { FeatureDecision.DecisionSource.HOLDOUT, featureDecision.decisionSource); } + //========= excludeTargetedDeliveries tests =========/ + + @Test + public void excludeTargetedDeliveries_defaultsToFalse() { + assertFalse(HOLDOUT_BASIC_HOLDOUT.isExcludeTargetedDeliveries()); + } + + @Test + public void excludeTargetedDeliveries_trueWhenSetOnHoldout() { + assertTrue(HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES.isExcludeTargetedDeliveries()); + } + + @Test + public void excludeTargetedDeliveries_globalHoldoutFalse_blocksAllRules() { + ProjectConfig holdoutConfig = generateValidProjectConfigV4_holdout(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + Map attributes = new HashMap<>(); + attributes.put("$opt_bucketing_id", "ppid160000"); + FeatureDecision decision = ds.getVariationForFeature( + FEATURE_FLAG_BOOLEAN_FEATURE, + optimizely.createUserContext("user123", attributes), + holdoutConfig + ).getResult(); + + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.decisionSource); + assertEquals(HOLDOUT_BASIC_HOLDOUT, decision.experiment); + } + + @Test + public void excludeTargetedDeliveries_globalHoldoutTrue_skipsExperimentRules() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + ValidProjectConfigV4.FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertTrue("With excludeTargetedDeliveries=true and no rollout, variation should be null", + decision == null || decision.variation == null); + if (decision != null) { + assertNotNull("holdoutDecision should be attached", decision.holdoutDecision); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.holdoutDecision.decisionSource); + } + } + + @Test + public void excludeTargetedDeliveries_globalHoldoutTrue_allowsDeliveryRules() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + FEATURE_FLAG_SINGLE_VARIABLE_INTEGER, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertNotNull(decision); + assertNotNull(decision.variation); + assertEquals(FeatureDecision.DecisionSource.ROLLOUT, decision.decisionSource); + } + + @Test + public void excludeTargetedDeliveries_globalHoldoutTrue_noDeliveryMatch_returnsNullWithHoldoutAttached() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + FEATURE_FLAG_BOOLEAN_FEATURE, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertTrue("When excludeTargetedDeliveries=true and no delivery match, variation should be null", + decision == null || decision.variation == null); + if (decision != null) { + assertNotNull("holdoutDecision should be attached", decision.holdoutDecision); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.holdoutDecision.decisionSource); + } + } + + @Test + public void excludeTargetedDeliveries_localHoldoutTrue_appliesHoldoutForDeliveryRules() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + Experiment deliveryRule = config.getExperimentIdMapping().get("1323241596"); + + DecisionResponse response = ds.evaluateLocalHoldouts( + deliveryRule, config, + optimizely.createUserContext("any_user", Collections.emptyMap()) + ); + + assertNotNull(response.getResult()); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, response.getResult().decisionSource); + } + + @Test + public void excludeTargetedDeliveries_localHoldoutTrue_appliesForExperimentRules() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + ValidProjectConfigV4.FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertNotNull(decision); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.decisionSource); + assertEquals(HOLDOUT_LOCAL_EXCLUDE_TARGETED_DELIVERIES, decision.experiment); + } + + @Test + public void excludeTargetedDeliveries_evaluateLocalHoldouts_falseDoesNotSkipForDeliveryRules() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldout(); + Experiment targetedRule = config.getExperimentIdMapping().get("1323241596"); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + DecisionResponse response = ds.evaluateLocalHoldouts( + targetedRule, config, + optimizely.createUserContext("any_user", Collections.emptyMap()) + ); + + assertNotNull(response.getResult()); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, response.getResult().decisionSource); + } + + @Test + public void excludeTargetedDeliveries_forcedDecisionBeatsLocalHoldoutWithExcludeFlag() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + OptimizelyUserContext userContext = optimizely.createUserContext("forced_user", Collections.emptyMap()); + userContext.setForcedDecision( + new OptimizelyDecisionContext(ValidProjectConfigV4.FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE_KEY, + ValidProjectConfigV4.EXPERIMENT_BASIC_EXPERIMENT_KEY), + new OptimizelyForcedDecision("A") + ); + + FeatureDecision decision = ds.getVariationForFeature( + ValidProjectConfigV4.FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE, + userContext, + config + ).getResult(); + + assertNotNull(decision); + assertNotEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.decisionSource); + } + + //========= local holdout ignores excludeTargetedDeliveries tests =========/ + + @Test + public void localHoldout_ignoresExcludeTargetedDeliveries() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + ValidProjectConfigV4.FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertNotNull(decision); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.decisionSource); + assertEquals(HOLDOUT_LOCAL_EXCLUDE_TARGETED_DELIVERIES, decision.experiment); + } + + @Test + public void globalHoldout_excludeTD_rolloutReturnsNull_returnsNull() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + FEATURE_FLAG_BOOLEAN_FEATURE, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertTrue("When excludeTargetedDeliveries=true and rollout returns null, result should be null or have null variation", + decision == null || decision.variation == null); + if (decision != null) { + assertNotEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.decisionSource); + } + } + + @Test + public void globalHoldout_excludeTD_holdoutDecisionAttached() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + FeatureDecision decision = ds.getVariationForFeature( + FEATURE_FLAG_SINGLE_VARIABLE_INTEGER, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ).getResult(); + + assertNotNull(decision); + assertEquals(FeatureDecision.DecisionSource.ROLLOUT, decision.decisionSource); + assertNotNull("holdoutDecision should be attached when user is in holdout with excludeTargetedDeliveries=true", + decision.holdoutDecision); + assertEquals(FeatureDecision.DecisionSource.HOLDOUT, decision.holdoutDecision.decisionSource); + assertEquals(HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES, decision.holdoutDecision.experiment); + } + + @Test + public void globalHoldout_excludeTD_addsDecisionReason() { + ProjectConfig config = ValidProjectConfigV4.generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries(); + + Bucketer bucketer = new Bucketer(); + DecisionService ds = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService); + + DecisionResponse response = ds.getVariationForFeature( + FEATURE_FLAG_SINGLE_VARIABLE_INTEGER, + optimizely.createUserContext("any_user", Collections.emptyMap()), + config + ); + + List reasons = response.getReasons().toReport(); + String expectedReason = String.format( + "Holdout '%s' has excludeTargetedDeliveries enabled, continuing to rollout evaluation.", + HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES.getKey() + ); + assertTrue("Reasons should contain excludeTargetedDeliveries bypass message", + reasons.contains(expectedReason)); + } + private Experiment createMockCmabExperiment() { List variations = Arrays.asList( new Variation("111151", "variation_1"), diff --git a/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java index 5fc51164c..f5fe9dcaf 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java @@ -52,7 +52,8 @@ public void setUp() { null, Collections.emptyList(), Collections.emptyList(), - Arrays.asList("ruleA") + Arrays.asList("ruleA"), + false ); // Local holdout targeting rules "ruleA" and "ruleB" @@ -63,7 +64,8 @@ public void setUp() { null, Collections.emptyList(), Collections.emptyList(), - Arrays.asList("ruleA", "ruleB") + Arrays.asList("ruleA", "ruleB"), + false ); // Local holdout with empty includedRules list — targets no rules @@ -74,7 +76,8 @@ public void setUp() { null, Collections.emptyList(), Collections.emptyList(), - Collections.emptyList() + Collections.emptyList(), + false ); } @@ -265,7 +268,7 @@ public void testGetAllHoldoutsIsUnmodifiable() { } // ----------------------------------------------------------------------- - // Section-aware constructor (FSSDK-12760): localHoldouts datafile section + // Section-aware constructor: localHoldouts datafile section // ----------------------------------------------------------------------- @Test @@ -299,7 +302,8 @@ public void testGlobalSectionEntriesIgnoreIncludedRules() { null, Collections.emptyList(), Collections.emptyList(), - Arrays.asList("ruleA") + Arrays.asList("ruleA"), + false ); HoldoutConfig config = new HoldoutConfig( @@ -335,7 +339,8 @@ public void testLocalSectionEntryWithoutIncludedRulesIsExcluded() { null, Collections.emptyList(), Collections.emptyList(), - null // missing includedRules + null, // missing includedRules + false ); HoldoutConfig config = new HoldoutConfig( diff --git a/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java b/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java index aff4a288e..153f9d108 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java @@ -196,6 +196,48 @@ public List getAudienceConditionsList() { return conditionTestScenarios; } + @Test + public void testExcludeTargetedDeliveries_defaultsToFalse() { + Holdout holdout = new Holdout("id1", "key1"); + assertEquals(false, holdout.isExcludeTargetedDeliveries()); + } + + @Test + public void testExcludeTargetedDeliveries_defaultsToFalseWhenNullPassedToConstructor() { + Holdout holdout = new Holdout( + "id1", "key1", "Running", + Collections.emptyList(), null, + Collections.emptyList(), + Collections.emptyList(), + null, null + ); + assertEquals(false, holdout.isExcludeTargetedDeliveries()); + } + + @Test + public void testExcludeTargetedDeliveries_trueWhenSet() { + Holdout holdout = new Holdout( + "id1", "key1", "Running", + Collections.emptyList(), null, + Collections.emptyList(), + Collections.emptyList(), + null, true + ); + assertEquals(true, holdout.isExcludeTargetedDeliveries()); + } + + @Test + public void testExcludeTargetedDeliveries_falseWhenExplicitlySet() { + Holdout holdout = new Holdout( + "id1", "key1", "Running", + Collections.emptyList(), null, + Collections.emptyList(), + Collections.emptyList(), + null, false + ); + assertEquals(false, holdout.isExcludeTargetedDeliveries()); + } + private Holdout makeMockHoldoutWithStatus(Holdout.HoldoutStatus status, Condition audienceConditions) { return new Holdout("12345", "mockHoldoutKey", diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 5f28003c2..15da186d8 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -609,7 +609,8 @@ public class ValidProjectConfigV4 { ), DatafileProjectConfigTestUtils.createListOfObjects( EXPERIMENT_BASIC_EXPERIMENT_ID - ) + ), + false ); /** @@ -647,7 +648,48 @@ public class ValidProjectConfigV4 { ), DatafileProjectConfigTestUtils.createListOfObjects( EXPERIMENT_BASIC_EXPERIMENT_ID // targets the basic experiment rule - ) + ), + false + ); + + public static final Holdout HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES = new Holdout( + "30075323428", + "global_holdout_exclude_td", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "$opt_dummy_variation_id", + 10000 + ) + ), + null, + true + ); + + public static final Holdout HOLDOUT_LOCAL_EXCLUDE_TARGETED_DELIVERIES = new Holdout( + "30075323429", + "local_holdout_exclude_td", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "$opt_dummy_variation_id", + 10000 + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_BASIC_EXPERIMENT_ID + ), + true ); private static final String LAYER_TYPEDAUDIENCE_EXPERIMENT_ID = "1630555627"; @@ -1795,4 +1837,188 @@ public static ProjectConfig generateValidProjectConfigV4_localHoldout() { integrations ); } + + public static ProjectConfig generateValidProjectConfigV4_globalHoldoutExcludeTargetedDeliveries() { + List attributes = new ArrayList(); + attributes.add(ATTRIBUTE_HOUSE); + attributes.add(ATTRIBUTE_NATIONALITY); + attributes.add(ATTRIBUTE_OPT); + attributes.add(ATTRIBUTE_BOOLEAN); + attributes.add(ATTRIBUTE_INTEGER); + attributes.add(ATTRIBUTE_DOUBLE); + attributes.add(ATTRIBUTE_EMPTY); + + List audiences = new ArrayList(); + audiences.add(AUDIENCE_GRYFFINDOR); + audiences.add(AUDIENCE_SLYTHERIN); + audiences.add(AUDIENCE_ENGLISH_CITIZENS); + audiences.add(AUDIENCE_WITH_MISSING_VALUE); + + List typedAudiences = new ArrayList(); + typedAudiences.add(TYPED_AUDIENCE_BOOL); + typedAudiences.add(TYPED_AUDIENCE_EXACT_INT); + typedAudiences.add(TYPED_AUDIENCE_INT); + typedAudiences.add(TYPED_AUDIENCE_DOUBLE); + typedAudiences.add(TYPED_AUDIENCE_GRYFFINDOR); + typedAudiences.add(TYPED_AUDIENCE_SLYTHERIN); + typedAudiences.add(TYPED_AUDIENCE_ENGLISH_CITIZENS); + typedAudiences.add(AUDIENCE_WITH_MISSING_VALUE); + + List events = new ArrayList(); + events.add(EVENT_BASIC_EVENT); + events.add(EVENT_PAUSED_EXPERIMENT); + events.add(EVENT_LAUNCHED_EXPERIMENT_ONLY); + + List experiments = new ArrayList(); + experiments.add(EXPERIMENT_BASIC_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT); + experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); + experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); + experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); + experiments.add(EXPERIMENT_LAUNCHED_EXPERIMENT); + experiments.add(EXPERIMENT_WITH_MALFORMED_AUDIENCE); + + List holdouts = new ArrayList(); + holdouts.add(HOLDOUT_GLOBAL_EXCLUDE_TARGETED_DELIVERIES); + + List featureFlags = new ArrayList(); + featureFlags.add(FEATURE_FLAG_BOOLEAN_FEATURE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_INTEGER); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE); + featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); + featureFlags.add(FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE); + + List groups = new ArrayList(); + groups.add(GROUP_1); + groups.add(GROUP_2); + + List rollouts = new ArrayList(); + rollouts.add(ROLLOUT_1); + rollouts.add(ROLLOUT_2); + rollouts.add(ROLLOUT_3); + + List integrations = new ArrayList<>(); + integrations.add(odpIntegration); + + return new DatafileProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + SEND_FLAG_DECISIONS, + BOT_FILTERING, + REGION, + PROJECT_ID, + REVISION, + SDK_KEY, + ENVIRONMENT_KEY, + VERSION, + attributes, + audiences, + typedAudiences, + events, + experiments, + holdouts, + featureFlags, + groups, + rollouts, + integrations + ); + } + + public static ProjectConfig generateValidProjectConfigV4_localHoldoutExcludeTargetedDeliveries() { + List attributes = new ArrayList(); + attributes.add(ATTRIBUTE_HOUSE); + attributes.add(ATTRIBUTE_NATIONALITY); + attributes.add(ATTRIBUTE_OPT); + attributes.add(ATTRIBUTE_BOOLEAN); + attributes.add(ATTRIBUTE_INTEGER); + attributes.add(ATTRIBUTE_DOUBLE); + attributes.add(ATTRIBUTE_EMPTY); + + List audiences = new ArrayList(); + audiences.add(AUDIENCE_GRYFFINDOR); + audiences.add(AUDIENCE_SLYTHERIN); + audiences.add(AUDIENCE_ENGLISH_CITIZENS); + audiences.add(AUDIENCE_WITH_MISSING_VALUE); + + List typedAudiences = new ArrayList(); + typedAudiences.add(TYPED_AUDIENCE_BOOL); + typedAudiences.add(TYPED_AUDIENCE_EXACT_INT); + typedAudiences.add(TYPED_AUDIENCE_INT); + typedAudiences.add(TYPED_AUDIENCE_DOUBLE); + typedAudiences.add(TYPED_AUDIENCE_GRYFFINDOR); + typedAudiences.add(TYPED_AUDIENCE_SLYTHERIN); + typedAudiences.add(TYPED_AUDIENCE_ENGLISH_CITIZENS); + typedAudiences.add(AUDIENCE_WITH_MISSING_VALUE); + + List events = new ArrayList(); + events.add(EVENT_BASIC_EVENT); + events.add(EVENT_PAUSED_EXPERIMENT); + events.add(EVENT_LAUNCHED_EXPERIMENT_ONLY); + + List experiments = new ArrayList(); + experiments.add(EXPERIMENT_BASIC_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT); + experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); + experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); + experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); + experiments.add(EXPERIMENT_LAUNCHED_EXPERIMENT); + experiments.add(EXPERIMENT_WITH_MALFORMED_AUDIENCE); + + List holdouts = new ArrayList(); + holdouts.add(HOLDOUT_LOCAL_EXCLUDE_TARGETED_DELIVERIES); + + List featureFlags = new ArrayList(); + featureFlags.add(FEATURE_FLAG_BASIC_EXPERIMENT_FEATURE); + featureFlags.add(FEATURE_FLAG_BOOLEAN_FEATURE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_INTEGER); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE); + featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); + + List groups = new ArrayList(); + groups.add(GROUP_1); + groups.add(GROUP_2); + + List rollouts = new ArrayList(); + rollouts.add(ROLLOUT_1); + rollouts.add(ROLLOUT_2); + rollouts.add(ROLLOUT_3); + + List integrations = new ArrayList<>(); + integrations.add(odpIntegration); + + return new DatafileProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + SEND_FLAG_DECISIONS, + BOT_FILTERING, + REGION, + PROJECT_ID, + REVISION, + SDK_KEY, + ENVIRONMENT_KEY, + VERSION, + attributes, + audiences, + typedAudiences, + events, + experiments, + holdouts, + featureFlags, + groups, + rollouts, + integrations + ); + } }