diff --git a/CHANGELOG.md b/CHANGELOG.md index a6167dfe..56d75eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Optimizely Swift SDK Changelog ## 5.4.2 -July 9, 2026 +July 9th, 2026 ### Fixes @@ -15,7 +15,7 @@ June 24, 2026 ### Fixes -- normalize campaign_id, variation_id, and entity_id on decision events ([#642](https://github.com/optimizely/swift-sdk/pull/642)) +- Normalize campaign_id, variation_id, and entity_id on decision events ([#642](https://github.com/optimizely/swift-sdk/pull/642)) ## 5.4.0 June 22, 2026 diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 7aba56bd..f36163d9 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2121,6 +2121,8 @@ 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; 98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; }; 98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; }; + E793710B634841BF89AA931E /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */; }; + BC46ADF752734A65854A6676 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */; }; 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; @@ -2633,6 +2635,7 @@ 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = ""; }; 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = ""; }; 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_LocalHoldouts.swift; sourceTree = ""; }; + 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_ExcludeTargetedDeliveries.swift; sourceTree = ""; }; 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; 98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = ""; }; 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; @@ -3171,6 +3174,7 @@ 6E75198B22C5211100B2B157 /* NotificationCenterTests.swift */, 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */, + 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, 98F28A512E02E81500A86546 /* CMABClientTests.swift */, @@ -5138,6 +5142,7 @@ 6E75187922C520D400B2B157 /* Variation.swift in Sources */, 6E75191522C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */, + E793710B634841BF89AA931E /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */, 6E75195D22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E9B117622C5487100C22D81 /* DatafileHandlerTests.swift in Sources */, 84E2E97F2855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5443,6 +5448,7 @@ 6E7516FD22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E75187322C520D400B2B157 /* Variation.swift in Sources */, 98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */, + BC46ADF752734A65854A6676 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */, 6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */, diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index b6da58cb..9e037f48 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -1,5 +1,5 @@ // -// Copyright 2022, Optimizely, Inc. and contributors +// Copyright 2022, 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. @@ -32,9 +32,10 @@ struct Holdout: Codable, ExperimentCore { var audienceIds: [String] var audienceConditions: ConditionHolder? var includedRules: [String]? + var excludeTargetedDeliveries: Bool enum CodingKeys: String, CodingKey { - case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules + case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules, excludeTargetedDeliveries } var variationsMap: [String: OptimizelyVariation] = [:] @@ -55,6 +56,7 @@ struct Holdout: Codable, ExperimentCore { audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions) includedRules = try container.decodeIfPresent([String].self, forKey: .includedRules) + excludeTargetedDeliveries = try container.decodeIfPresent(Bool.self, forKey: .excludeTargetedDeliveries) ?? false } } @@ -67,7 +69,8 @@ extension Holdout: Equatable { lhs.trafficAllocation == rhs.trafficAllocation && lhs.audienceIds == rhs.audienceIds && lhs.audienceConditions == rhs.audienceConditions && - lhs.includedRules == rhs.includedRules + lhs.includedRules == rhs.includedRules && + lhs.excludeTargetedDeliveries == rhs.excludeTargetedDeliveries } } diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index f2210635..5cb71497 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -1,5 +1,5 @@ // -// Copyright 2019-2022, Optimizely, Inc. and contributors +// Copyright 2019-2022, 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. @@ -16,12 +16,25 @@ import Foundation +struct HoldoutDecision { + let experiment: ExperimentCore + let variation: Variation + + init?(from featureDecision: FeatureDecision?) { + guard let experiment = featureDecision?.experiment, + let variation = featureDecision?.variation else { return nil } + self.experiment = experiment + self.variation = variation + } +} + struct FeatureDecision { var experiment: ExperimentCore? let variation: Variation? let source: String var cmabUUID: String? var error = false + var holdoutDecision: HoldoutDecision? = nil } struct VariationDecision { @@ -400,6 +413,8 @@ class DefaultDecisionService: OPTDecisionService { options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) + var storedHoldoutDecision: FeatureDecision? + let holdouts = config.getGlobalHoldouts() for holdout in holdouts { let holdoutDecision = getVariationForHoldout(config: config, @@ -410,24 +425,45 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(holdoutDecision.reasons) if let variation = holdoutDecision.result { let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) - return DecisionResponse(result: featureDecision, reasons: reasons) + if holdout.excludeTargetedDeliveries { + storedHoldoutDecision = featureDecision + let info = LogMessage.holdoutExcludeTargetedDeliveriesEnabled(holdout.key) + reasons.addInfo(info) + } else { + return DecisionResponse(result: featureDecision, reasons: reasons) + } + break } } - let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options) - - reasons.merge(flagExpDecision.reasons) - - if let decision = flagExpDecision.result { - return DecisionResponse(result: decision, reasons: reasons) + if storedHoldoutDecision == nil { + let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options) + + reasons.merge(flagExpDecision.reasons) + + if let decision = flagExpDecision.result { + return DecisionResponse(result: decision, reasons: reasons) + } } let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options) reasons.merge(rolloutDecision.reasons) - - if let decision = rolloutDecision.result { + + if var decision: FeatureDecision = rolloutDecision.result { + decision.holdoutDecision = HoldoutDecision(from: storedHoldoutDecision) + let info = LogMessage.userBucketedIntoRollout(user.userId, featureFlag.key) + logger.i(info) + reasons.addInfo(info) return DecisionResponse(result: decision, reasons: reasons) } else { + let info = LogMessage.userNotBucketedIntoRollout(user.userId, featureFlag.key) + logger.i(info) + reasons.addInfo(info) + if let holdout = HoldoutDecision(from: storedHoldoutDecision) { + var emptyDecision = FeatureDecision(experiment: nil, variation: nil, source: Constants.DecisionSource.rollout.rawValue) + emptyDecision.holdoutDecision = holdout + return DecisionResponse(result: emptyDecision, reasons: reasons) + } return DecisionResponse(result: nil, reasons: reasons) } } @@ -667,7 +703,6 @@ class DefaultDecisionService: OPTDecisionService { options: options) reasons.merge(holdoutDecision.reasons) if let variation = holdoutDecision.result { - // User is in holdout — return holdout variation immediately, skip this rule let variationDecision = VariationDecision(variation: variation, holdout: holdout) return DecisionResponse(result: variationDecision, reasons: reasons) } @@ -725,7 +760,6 @@ class DefaultDecisionService: OPTDecisionService { options: options) reasons.merge(holdoutDecision.reasons) if let variation = holdoutDecision.result { - // User is in holdout — return holdout variation with holdout info let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse, holdout: holdout) return DecisionResponse(result: decision, reasons: reasons) } @@ -848,7 +882,7 @@ class DefaultDecisionService: OPTDecisionService { } // MARK: - Utilities - + /// Retrieves the bucketing ID for a user, defaulting to user ID unless overridden in attributes. /// - Parameters: /// - userId: The user's ID. diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index fad9141e..ee6cbc0d 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -1,5 +1,5 @@ // -// Copyright 2021-2022, Optimizely, Inc. and contributors +// Copyright 2021-2022, 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. @@ -324,8 +324,19 @@ extension OptimizelyClient { cmabUUID: flagDecision?.cmabUUID) decisionEventDispatched = true } + if let holdoutInfo = flagDecision?.holdoutDecision { + sendImpressionEvent(experiment: holdoutInfo.experiment, + variation: holdoutInfo.variation, + userId: userId, + attributes: attributes, + flagKey: feature.key, + ruleType: Constants.DecisionSource.holdout.rawValue, + enabled: false, + cmabUUID: nil) + decisionEventDispatched = true + } } - + var variableMap = [String: Any]() if !allOptions.contains(.excludeVariables) { let decisionResponse = getDecisionVariableMap(feature: feature, diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 9b0dfee8..3d32a7bb 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -1,5 +1,5 @@ // -// Copyright 2019-2021, 2023 Optimizely, Inc. and contributors +// Copyright 2019-2021, 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. @@ -79,6 +79,10 @@ enum LogMessage { case cmabFetchSuccess(_ variationId: String, _ cmabUUID: String, _expKey: String) case cmabFetchFailed(_ expKey: String) case cmabNotSupportedInSyncMode + case holdoutExcludesTargetedDelivery(_ holdoutKey: String, _ ruleKey: String) + case holdoutExcludeTargetedDeliveriesEnabled(_ holdoutKey: String) + case userBucketedIntoRollout(_ userId: String, _ flagKey: String) + case userNotBucketedIntoRollout(_ userId: String, _ flagKey: String) } extension LogMessage: CustomStringConvertible { @@ -152,6 +156,10 @@ extension LogMessage: CustomStringConvertible { case .cmabFetchSuccess(let variationId, let cmabUUID, let expKey): message = "Successfully fetched CMAB decision, variationId: \(variationId), cmabUUID: \(cmabUUID) for experiment \(expKey)." case .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment \(key)." case .cmabNotSupportedInSyncMode: message = "CMAB is not supported in sync mode." + case .holdoutExcludesTargetedDelivery(let holdoutKey, let ruleKey): message = "Holdout (\(holdoutKey)) excludes targeted deliveries, skipping holdout for rule (\(ruleKey))." + case .holdoutExcludeTargetedDeliveriesEnabled(let holdoutKey): message = "Holdout \"\(holdoutKey)\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation." + case .userBucketedIntoRollout(let userId, let flagKey): message = "The user \"\(userId)\" was bucketed into a rollout for feature flag \"\(flagKey)\"." + case .userNotBucketedIntoRollout(let userId, let flagKey): message = "The user \"\(userId)\" was not bucketed into a rollout for feature flag \"\(flagKey)\"." } return message diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_ExcludeTargetedDeliveries.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_ExcludeTargetedDeliveries.swift new file mode 100644 index 00000000..bae26ac4 --- /dev/null +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_ExcludeTargetedDeliveries.swift @@ -0,0 +1,400 @@ +// +// Copyright 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. +// 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. +// + +import XCTest + +class DecisionServiceTests_ExcludeTargetedDeliveries: XCTestCase { + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + + let userId = "test_user" + let flagKey = "feature_1" + let experimentRuleId = "10390977673" + let deliveryRuleId = "3332020515" + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "9999900010", + "key": "holdout_test_key", + "trafficAllocation": [ + ["entityId": "9999900020", "endOfRange": 5000] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "9999900020", + "key": "holdout_variation_key", + "featureEnabled": false + ] + ] + ] + } + + override func setUp() { + super.setUp() + optimizely = OTUtils.createOptimizely(datafileName: "decide_datafile", clearUserProfileService: true) + config = optimizely.config! + } + + // MARK: - excludeTargetedDeliveries = false (default) + + func testExcludeTargetedDeliveriesFalse_HoldoutApplesToTDRule() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = false + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - excludeTargetedDeliveries = true with TD rule + + func testExcludeTargetedDeliveriesTrue_TDRule_LocalHoldoutStillApplies() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - excludeTargetedDeliveries = true with AB rule + + func testExcludeTargetedDeliveriesTrue_ABRule_HoldoutStillApplies() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .ab + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - Backward compatibility (field missing from JSON) + + func testExcludeTargetedDeliveries_MissingFromJSON_DefaultsFalse() { + var holdoutData = sampleHoldout + holdoutData.removeValue(forKey: "excludeTargetedDeliveries") + holdoutData["includedRules"] = [experimentRuleId] + let holdout = try! OTUtils.model(from: holdoutData) as Holdout + + XCTAssertFalse(holdout.excludeTargetedDeliveries) + } + + func testExcludeTargetedDeliveries_MissingFromJSON_HoldoutAppliesNormally() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdoutData = sampleHoldout + holdoutData.removeValue(forKey: "excludeTargetedDeliveries") + holdoutData["includedRules"] = [experimentRuleId] + let holdout = try! OTUtils.model(from: holdoutData) as Holdout + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - Global holdout with excludeTargetedDeliveries true and TD rule + + func testGlobalHoldout_ExcludeTargetedDeliveriesTrue_TDRule_HoldoutSkippedForTD() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertNotEqual(decision.variationKey, "holdout_variation_key") + } + + // MARK: - Local holdout with excludeTargetedDeliveries true and TD delivery rule + + func testLocalHoldout_ExcludeTargetedDeliveriesTrue_DeliveryTDRule_HoldoutStillApplies() { + if var rollout = config.getRollout(id: config.getFeatureFlag(key: flagKey)!.rolloutId) { + if rollout.experiments.count > 0 { + rollout.experiments[0].type = .targetedDelivery + config.project.rollouts = [rollout] + } + } + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [deliveryRuleId] + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - Forced decision beats 100% traffic local holdout + + func testForcedDecisionBeats100PercentLocalHoldout_ExcludeTargetedDeliveriesTrue() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = true + holdout.trafficAllocation = [TrafficAllocation(entityId: "9999900020", endOfRange: 10000)] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + + let context = OptimizelyDecisionContext(flagKey: flagKey, ruleKey: nil) + let forcedDecision = OptimizelyForcedDecision(variationKey: "a") + user.setForcedDecision(context: context, decision: forcedDecision) + + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "a", "Forced decision should take precedence over 100% traffic local holdout") + } + + func testForcedDecisionBeats100PercentLocalHoldout_ExcludeTargetedDeliveriesFalse() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = false + holdout.trafficAllocation = [TrafficAllocation(entityId: "9999900020", endOfRange: 10000)] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + + let context = OptimizelyDecisionContext(flagKey: flagKey, ruleKey: nil) + let forcedDecision = OptimizelyForcedDecision(variationKey: "a") + user.setForcedDecision(context: context, decision: forcedDecision) + + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "a", "Forced decision should take precedence over 100% traffic local holdout") + } + + // MARK: - Global holdout excludeTrue, TD returns null => returns null + + // When excludeTargetedDeliveries=true, experiment rules are skipped and delivery + // (rollout) rules are always evaluated. The Everyone Else rule matches so a + // rollout variation is returned and the holdout is queued for impression. + func testGlobalHoldout_ExcludeTrue_DeliveryRulesEvaluated_HoldoutQueued() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let featureFlag = config.getFeatureFlag(key: flagKey)! + let response = mockDecisionService.getDecisionForFlag(config: config, + featureFlag: featureFlag, + user: user, + isAsync: false, + options: nil) + + // Delivery (rollout) rules are evaluated — Everyone Else matches + XCTAssertNotNil(response.result?.variation) + XCTAssertEqual(response.result?.source, Constants.DecisionSource.rollout.rawValue) + // Holdout is queued so its impression fires via holdoutDecision + XCTAssertNotNil(response.result?.holdoutDecision) + XCTAssertEqual(response.result?.holdoutDecision?.experiment.key, "holdout_test_key") + XCTAssertEqual(response.result?.holdoutDecision?.variation.key, "holdout_variation_key") + } + + // MARK: - Local holdout with excludeTrue still applies + + func testLocalHoldout_ExcludeTrue_StillApplies() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + XCTAssertEqual(decision.variationKey, "holdout_variation_key") + XCTAssertFalse(decision.enabled) + } + + // MARK: - Decision reason for excludeTargetedDeliveries bypass + + func testGlobalHoldout_ExcludeTrue_DecisionReasonPresent() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey, options: [.includeReasons]) + + let reasons = decision.reasons + let expectedReason = "Holdout \"holdout_test_key\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation." + XCTAssertTrue(reasons.contains(expectedReason), + "Decision reasons should contain excludeTargetedDeliveries bypass reason. Got: \(reasons)") + } + + // MARK: - decisionEventDispatched for holdout impression + + func testGlobalHoldout_ExcludeTrue_TDMatch_DecisionEventDispatched() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + experiment.audienceIds = [] + experiment.audienceConditions = nil + config.project.experiments = [experiment] + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let exp = expectation(description: "decision notification") + let user = optimizely.createUserContext(userId: userId) + + optimizely.notificationCenter?.addDecisionNotificationListener { (_, _, _, decisionInfo) in + let dispatched = decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as? Bool + XCTAssertEqual(dispatched, true, "decisionEventDispatched should be true when holdout impression is sent") + exp.fulfill() + } + + _ = user.decide(key: flagKey) + wait(for: [exp], timeout: 1) + } + + // MARK: - Global holdout excludeTrue, TD matches => holdoutDecision populated + + // When excludeTargetedDeliveries=true, experiment rules are skipped and the + // decision comes from the delivery (rollout) path. The holdout is queued via + // holdoutDecision so its impression fires alongside the rollout impression. + func testGlobalHoldout_ExcludeTrue_HoldoutEventSent() { + var experiment = config.getExperiment(id: experimentRuleId)! + experiment.type = .targetedDelivery + config.project.experiments = [experiment] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil + holdout.excludeTargetedDeliveries = true + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: mockBucketer) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: userId) + let featureFlag = config.getFeatureFlag(key: flagKey)! + let response = mockDecisionService.getDecisionForFlag(config: config, + featureFlag: featureFlag, + user: user, + isAsync: false, + options: nil) + + XCTAssertNotNil(response.result) + // Experiments are skipped; decision comes from delivery (rollout) rules + XCTAssertEqual(response.result?.source, Constants.DecisionSource.rollout.rawValue) + XCTAssertNotNil(response.result?.holdoutDecision) + XCTAssertEqual(response.result?.holdoutDecision?.experiment.key, "holdout_test_key") + XCTAssertEqual(response.result?.holdoutDecision?.variation.key, "holdout_variation_key") + } +}