diff --git a/CLAUDE.md b/CLAUDE.md index da0c60fb..b9625ed5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,6 +174,22 @@ Similar test targets exist for tvOS and other platforms. - Use JSON fixtures from `Tests/TestData/` for consistent test data - Each test should use unique file names for persistent storage +### Adding New Test Files + +**CRITICAL**: New test files must be manually added to `OptimizelySwiftSDK.xcodeproj/project.pbxproj` - file creation alone is insufficient. + +**Steps**: +1. Create test file in appropriate directory (`Tests/OptimizelyTests-Common/`, etc.) +2. Generate unique IDs: `uuidgen | tr 'A-F' 'a-f' | tr -d '-' | cut -c1-24 | awk '{print toupper($0)}'` +3. Edit `project.pbxproj` following pattern of similar files (e.g., `DecisionServiceTests_LocalHoldouts.swift`): + - Add PBXBuildFile entries (~line 2120) - one per target (iOS, tvOS) + - Add PBXFileReference entry (~line 2640) + - Add to file group listing (~line 3180) + - Add to PBXSourcesBuildPhase for each target (~lines 5200, 5510) +4. Verify: `swift build && swift test --filter YourTestClass` + +**Pattern**: Common tests need 2 targets (iOS + tvOS); base classes may need 4 targets. + ## Development Workflow ### Branch Strategy diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 828d69b5..27dbcf31 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2121,6 +2121,12 @@ 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 */; }; + 0921CDD1BC7F41F59B2D4CC3 /* FeatureGateTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5D873BB4C44181B07ECC15 /* FeatureGateTests_LocalHoldouts.swift */; }; + BB21E5C3E29D4F0C892E523C /* FeatureGateTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5D873BB4C44181B07ECC15 /* FeatureGateTests_LocalHoldouts.swift */; }; + 98C2DF752FA8D055003F2443 /* BaseHoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.swift */; }; + 98C2DF762FA8D055003F2443 /* BaseHoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.swift */; }; + 98C2DF772FA8D1FF003F2443 /* BaseHoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.swift */; }; + 98C2DF782FA8D207003F2443 /* BaseHoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.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 */; }; @@ -2638,6 +2644,8 @@ 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 = ""; }; + 7C5D873BB4C44181B07ECC15 /* FeatureGateTests_LocalHoldouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGateTests_LocalHoldouts.swift; sourceTree = ""; }; + 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseHoldoutTests.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 = ""; }; @@ -3169,6 +3177,7 @@ 6E75198022C5211100B2B157 /* DecisionServiceTests_Experiments.swift */, 6E75199122C5211100B2B157 /* DecisionServiceTests_Features.swift */, 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */, + 98C2DF742FA8D055003F2443 /* BaseHoldoutTests.swift */, 6E75199422C5211100B2B157 /* DecisionServiceTests_Others.swift */, 6E75198622C5211100B2B157 /* DecisionServiceTests_UserProfiles.swift */, 6E75198822C5211100B2B157 /* DefaultLoggerTests.swift */, @@ -3181,6 +3190,7 @@ 6E75198B22C5211100B2B157 /* NotificationCenterTests.swift */, 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */, + 7C5D873BB4C44181B07ECC15 /* FeatureGateTests_LocalHoldouts.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, 98F28A512E02E81500A86546 /* CMABClientTests.swift */, @@ -5124,6 +5134,7 @@ 6E75192D22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516D322C520D400B2B157 /* OPTLogger.swift in Sources */, 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, + 98C2DF752FA8D055003F2443 /* BaseHoldoutTests.swift in Sources */, 98F28A672E05220300A86546 /* CmabServiceTests.swift in Sources */, 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, @@ -5196,6 +5207,7 @@ 6E75187922C520D400B2B157 /* Variation.swift in Sources */, 6E75191522C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */, + 0921CDD1BC7F41F59B2D4CC3 /* FeatureGateTests_LocalHoldouts.swift in Sources */, 6E75195D22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E9B117622C5487100C22D81 /* DatafileHandlerTests.swift in Sources */, 84E2E97F2855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5294,6 +5306,7 @@ 6E7516EC22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75181A22C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EF8DE2624BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 98C2DF782FA8D207003F2443 /* BaseHoldoutTests.swift in Sources */, 8464087E28130D3200CCF97D /* Integration.swift in Sources */, 6E9B119722C5488300C22D81 /* ConditionLeafTests.swift in Sources */, 6E75184A22C520D400B2B157 /* Event.swift in Sources */, @@ -5428,6 +5441,7 @@ 6E75193322C520D500B2B157 /* OPTDataStore.swift in Sources */, 84861811286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */, 6E7517EF22C520D400B2B157 /* DataStoreMemory.swift in Sources */, + 98C2DF762FA8D055003F2443 /* BaseHoldoutTests.swift in Sources */, 98F28A682E05220300A86546 /* CmabServiceTests.swift in Sources */, 6E75194B22C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, 6E75195722C520D500B2B157 /* OPTBucketer.swift in Sources */, @@ -5500,6 +5514,7 @@ 6E7516FD22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E75187322C520D400B2B157 /* Variation.swift in Sources */, 98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */, + BB21E5C3E29D4F0C892E523C /* FeatureGateTests_LocalHoldouts.swift in Sources */, 6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */, @@ -5572,6 +5587,7 @@ 6E75193522C520D500B2B157 /* OPTDataStore.swift in Sources */, 6EC6DD4824ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75182122C520D400B2B157 /* BatchEventBuilder.swift in Sources */, + 98C2DF772FA8D1FF003F2443 /* BaseHoldoutTests.swift in Sources */, 983F81852F801E7500CDBC8D /* FeatureRolloutTests.swift in Sources */, 6E86CEA924FDC847005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B118322C5488100C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index f2210635..7036d5cd 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -658,18 +658,20 @@ class DefaultDecisionService: OPTDecisionService { } // check local holdouts targeting this rule - let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id) - for holdout in localHoldouts { - let holdoutDecision = getVariationForHoldout(config: config, - flagKey: flagKey, - holdout: holdout, - user: user, - 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) + if FeatureGates.localHoldouts { + let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id) + for holdout in localHoldouts { + let holdoutDecision = getVariationForHoldout(config: config, + flagKey: flagKey, + holdout: holdout, + user: user, + 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) + } } } @@ -716,18 +718,20 @@ class DefaultDecisionService: OPTDecisionService { } // check local holdouts targeting this delivery rule - let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id) - for holdout in localHoldouts { - let holdoutDecision = getVariationForHoldout(config: config, - flagKey: flagKey, - holdout: holdout, - user: user, - 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) + if FeatureGates.localHoldouts { + let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id) + for holdout in localHoldouts { + let holdoutDecision = getVariationForHoldout(config: config, + flagKey: flagKey, + holdout: holdout, + user: user, + 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) + } } } diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift index 0c106b2a..db04f22c 100644 --- a/Sources/Utils/Constants.swift +++ b/Sources/Utils/Constants.swift @@ -16,6 +16,10 @@ import Foundation +struct FeatureGates { + static var localHoldouts = false +} + struct Constants { struct Attributes { static let reservedBucketIdAttribute = "$opt_bucketing_id" diff --git a/Tests/OptimizelyTests-Common/BaseHoldoutTests.swift b/Tests/OptimizelyTests-Common/BaseHoldoutTests.swift new file mode 100644 index 00000000..4a64c1d3 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BaseHoldoutTests.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022, 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 BaseHoldoutTests: XCTestCase { + override func setUp() { + FeatureGates.localHoldouts = true + } + + override func tearDown() { + FeatureGates.localHoldouts = false + } +} diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift index 0470af28..1f9bc14d 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -16,7 +16,7 @@ import XCTest -class DecisionListenerTests_Holdouts: XCTestCase { +class DecisionListenerTests_Holdouts: BaseHoldoutTests { let kUserId = "11111" var optimizely: OptimizelyClient! var notificationCenter: OPTNotificationCenter! @@ -61,13 +61,12 @@ class DecisionListenerTests_Holdouts: XCTestCase { override func setUp() { super.setUp() - optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, eventDispatcher: eventDispatcher, userProfileService: OTUtils.createClearUserProfileService()) - + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) - + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Audience "13389130056" requires "country" = "US" holdout.audienceIds = ["13389130056"] @@ -79,6 +78,10 @@ class DecisionListenerTests_Holdouts: XCTestCase { self.notificationCenter = self.optimizely.notificationCenter! } + + override func tearDown() { + super.tearDown() + } func testDecisionListenerDecideWithUserInHoldout() { let exp = expectation(description: "x") diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index 31f2e857..62907151 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -16,7 +16,7 @@ import XCTest -class DecisionServiceTests_Holdouts: XCTestCase { +class DecisionServiceTests_Holdouts: BaseHoldoutTests { var optimizely: OptimizelyClient! var config: ProjectConfig! @@ -189,7 +189,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { override func setUp() { super.setUp() - self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", clearUserProfileService: true) self.config = self.optimizely.config! @@ -212,6 +211,10 @@ class DecisionServiceTests_Holdouts: XCTestCase { self.config.holdoutConfig.allHoldouts = [holdout] } + override func tearDown() { + super.tearDown() + } + } // MARK: - Test doesMeetAudienceConditions() diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift index 0d39787a..f652b766 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift @@ -19,7 +19,7 @@ import XCTest /// Integration tests for Local Holdouts functionality /// Tests that local holdouts are correctly evaluated at the rule level /// and global holdouts are evaluated at the flag level before any rules -class DecisionServiceTests_LocalHoldouts: XCTestCase { +class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var optimizely: OptimizelyClient! var config: ProjectConfig! @@ -52,7 +52,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { override func setUp() { super.setUp() - // Load a real datafile for testing optimizely = OTUtils.createOptimizely(datafileName: "decide_datafile", clearUserProfileService: true) @@ -60,6 +59,10 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { decisionService = optimizely.decisionService as? DefaultDecisionService } + override func tearDown() { + super.tearDown() + } + // MARK: - Global Holdouts Tests func testGlobalHoldout_EvaluatedBeforeAllRules() { diff --git a/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift new file mode 100644 index 00000000..6081cef2 --- /dev/null +++ b/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift @@ -0,0 +1,224 @@ +// +// 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 + +/// Tests to verify FeatureGates.localHoldouts flag behavior +/// These tests do NOT inherit from BaseHoldoutTests to control the flag state directly +class FeatureGateTests_LocalHoldouts: XCTestCase { + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + + let userId = "test_user" + let flagKey = "feature_1" + let experimentRuleId = "10390977673" // From decide_datafile + let deliveryRuleId = "3332020515" // From decide_datafile + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "holdout_test_id", + "key": "holdout_test_key", + "trafficAllocation": [ + ["entityId": "holdout_variation_id", "endOfRange": 5000] // 50% traffic + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "holdout_variation_id", + "key": "holdout_variation_key", + "featureEnabled": false + ] + ] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OTUtils.createOptimizely(datafileName: "decide_datafile", + clearUserProfileService: true) + config = optimizely.config! + } + + override func tearDown() { + // Always reset flag to false to prevent test pollution + FeatureGates.localHoldouts = false + super.tearDown() + } + + // MARK: - Flag OFF Tests (Local Holdouts Should Be Skipped) + + func testLocalHoldoutsSkippedWhenFlagOff_ExperimentRule() { + // Setup: Flag is OFF + FeatureGates.localHoldouts = false + + // Create local holdout targeting experiment rule + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + config.project.holdouts = [holdout] + config.holdoutConfig.allHoldouts = [holdout] + + // Mock bucketer to ensure user WOULD bucket into holdout if it were evaluated + let mockBucketer = MockBucketer(mockBucketValue: 2500) // Within holdout range (0-5000) + let mockDecisionService = DefaultDecisionService( + userProfileService: OTUtils.createClearUserProfileService(), + bucketer: mockBucketer + ) + optimizely.decisionService = mockDecisionService + + // Execute decision + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + // Verify: User did NOT get holdout variation (flag is off, so holdout skipped) + XCTAssertNotEqual(decision.variationKey, "holdout_variation_key", + "Local holdout should be skipped when FeatureGates.localHoldouts = false") + XCTAssertNotEqual(decision.ruleKey, "holdout_test_key", + "Should get experiment rule, not holdout") + } + + func testLocalHoldoutsSkippedWhenFlagOff_DeliveryRule() { + // Setup: Flag is OFF + FeatureGates.localHoldouts = false + + // Create local holdout targeting delivery rule + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [deliveryRuleId] + config.project.holdouts = [holdout] + config.holdoutConfig.allHoldouts = [holdout] + + // Mock bucketer to ensure user WOULD bucket into holdout if it were evaluated + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService( + userProfileService: OTUtils.createClearUserProfileService(), + bucketer: mockBucketer + ) + optimizely.decisionService = mockDecisionService + + // Execute decision + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + // Verify: User did NOT get holdout variation + XCTAssertNotEqual(decision.variationKey, "holdout_variation_key", + "Local holdout should be skipped when FeatureGates.localHoldouts = false") + XCTAssertNotEqual(decision.ruleKey, "holdout_test_key", + "Should get delivery rule, not holdout") + } + + // MARK: - Flag ON Tests (Local Holdouts Should Be Evaluated) + + func testLocalHoldoutsEvaluatedWhenFlagOn_ExperimentRule() { + // Setup: Flag is ON + FeatureGates.localHoldouts = true + + // Create local holdout targeting experiment rule + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [experimentRuleId] + config.project.holdouts = [holdout] + config.holdoutConfig.allHoldouts = [holdout] + + // Mock bucketer to bucket user into holdout + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService( + userProfileService: OTUtils.createClearUserProfileService(), + bucketer: mockBucketer + ) + optimizely.decisionService = mockDecisionService + + // Execute decision + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + // Verify: User DID get holdout variation (flag is on) + XCTAssertEqual(decision.variationKey, "holdout_variation_key", + "Local holdout should be evaluated when FeatureGates.localHoldouts = true") + XCTAssertEqual(decision.ruleKey, "holdout_test_key", + "Should get holdout, not experiment rule") + XCTAssertFalse(decision.enabled, "Holdout variation has featureEnabled: false") + } + + func testLocalHoldoutsEvaluatedWhenFlagOn_DeliveryRule() { + // Setup: Flag is ON + FeatureGates.localHoldouts = true + + // Create local holdout targeting delivery rule + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = [deliveryRuleId] + config.project.holdouts = [holdout] + config.holdoutConfig.allHoldouts = [holdout] + + // Mock bucketer to bucket user into holdout + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService( + userProfileService: OTUtils.createClearUserProfileService(), + bucketer: mockBucketer + ) + optimizely.decisionService = mockDecisionService + + // Execute decision + let user = optimizely.createUserContext(userId: userId) + let decision = user.decide(key: flagKey) + + // Verify: User DID get holdout variation (flag is on) + XCTAssertEqual(decision.variationKey, "holdout_variation_key", + "Local holdout should be evaluated when FeatureGates.localHoldouts = true") + XCTAssertEqual(decision.ruleKey, "holdout_test_key", + "Should get holdout, not delivery rule") + XCTAssertFalse(decision.enabled, "Holdout variation has featureEnabled: false") + } + + // MARK: - Global Holdouts (Flag State Should Not Matter) + + func testGlobalHoldoutsWorkRegardlessOfFlagState() { + // Create global holdout (no includedRules) + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedRules = nil // Global holdout + config.project.holdouts = [holdout] + config.holdoutConfig.allHoldouts = [holdout] + + // Mock bucketer to bucket user into holdout + let mockBucketer = MockBucketer(mockBucketValue: 2500) + let mockDecisionService = DefaultDecisionService( + userProfileService: OTUtils.createClearUserProfileService(), + bucketer: mockBucketer + ) + optimizely.decisionService = mockDecisionService + + // Test with flag OFF + FeatureGates.localHoldouts = false + let user1 = optimizely.createUserContext(userId: userId) + let decision1 = user1.decide(key: flagKey) + + XCTAssertEqual(decision1.variationKey, "holdout_variation_key", + "Global holdout should work when flag is OFF") + XCTAssertEqual(decision1.ruleKey, "holdout_test_key", + "Should get global holdout regardless of flag state") + + // Test with flag ON + FeatureGates.localHoldouts = true + let user2 = optimizely.createUserContext(userId: userId + "_2") + let decision2 = user2.decide(key: flagKey) + + XCTAssertEqual(decision2.variationKey, "holdout_variation_key", + "Global holdout should work when flag is ON") + XCTAssertEqual(decision2.ruleKey, "holdout_test_key", + "Should get global holdout regardless of flag state") + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index a21ca3c0..b4993be6 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -16,7 +16,7 @@ import XCTest -class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { +class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { let kUserId = "tester" var optimizely: OptimizelyClient! var eventDispatcher = MockEventDispatcher() @@ -45,13 +45,16 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { override func setUp() { super.setUp() - optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, eventDispatcher: eventDispatcher, userProfileService: OTUtils.createClearUserProfileService()) - + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) } + + override func tearDown() { + super.tearDown() + } func test_decide_with_global_holdout_audience_matched() { let featureKey = "feature_1" diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift index f05d7ba0..6e57e7bd 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -16,7 +16,7 @@ import XCTest -class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { +class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests { let kUserId = "tester" var optimizely: OptimizelyClient! @@ -44,12 +44,16 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { override func setUp() { super.setUp() - + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, userProfileService: OTUtils.createClearUserProfileService()) - + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) } + + override func tearDown() { + super.tearDown() + } /// Test when user is bucketed into the global holdout func testDecideReasons_userBucketedIntoGlobalHoldout() {