diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index 62b701a7..b6da58cb 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -76,6 +76,10 @@ extension Holdout { return status == .running } + /// True when this holdout applies to every flag (global scope). + /// Scope is set by the datafile section (`holdouts` vs `localHoldouts`); + /// HoldoutConfig strips `includedRules` from `holdouts`-section entries so + /// this property stays consistent with section membership. var isGlobal: Bool { return includedRules == nil } diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index b97764c6..e92bc328 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -1,5 +1,5 @@ // -// Copyright 2025, Optimizely, Inc. and contributors +// Copyright 2025-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,58 +16,73 @@ import Foundation +/// Holds parsed holdout entries and exposes per-rule and global lookups. +/// +/// Two top-level datafile sections drive holdout scoping: +/// - `holdouts` → global (applied to every flag); `includedRules` is stripped at parse time. +/// - `localHoldouts` → rule-scoped via `includedRules`; entries without rules are excluded. struct HoldoutConfig { - var allHoldouts: [Holdout] { - didSet { - updateHoldoutMapping() - } - } + private let logger = OPTLoggerFactory.getLogger() + private(set) var global: [Holdout] = [] private(set) var holdoutIdMap: [String: Holdout] = [:] private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:] - - init(allholdouts: [Holdout] = []) { - self.allHoldouts = allholdouts - updateHoldoutMapping() + + // MARK: - Init + + init() {} + + init(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { + applySections(globalHoldouts: globalHoldouts, localHoldouts: localHoldouts) } - - /// Updates internal mappings of holdouts including the id map, global list, and per-rule maps. - mutating func updateHoldoutMapping() { - holdoutIdMap = { - var map = [String: Holdout]() - allHoldouts.forEach { map[$0.id] = $0 } - return map - }() - global = [] - ruleHoldoutsMap = [:] + // MARK: - Section application - for holdout in allHoldouts { - if holdout.isGlobal { - // includedRules == nil → global holdout - global.append(holdout) - } else { - // includedRules == [ruleId, ...] → local holdout - for ruleId in holdout.includedRules! { - ruleHoldoutsMap[ruleId, default: []].append(holdout) - } + private mutating func applySections(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { + var newIdMap: [String: Holdout] = [:] + var newGlobal: [Holdout] = [] + var newRuleMap: [String: [Holdout]] = [:] + + for var holdout in globalHoldouts { + if holdout.includedRules != nil { + logger.w("Global holdout '\(holdout.key)' (id: \(holdout.id)) has 'includedRules' which will be ignored; global holdouts apply to all flags.") } + holdout.includedRules = nil + newIdMap[holdout.id] = holdout + newGlobal.append(holdout) } + + // Local entries must carry a non-empty `includedRules`; invalid ones are excluded. + for holdout in localHoldouts { + guard let rules = holdout.includedRules, !rules.isEmpty else { + logger.e( + "Local holdout '\(holdout.key)' (id: \(holdout.id)) is missing or has empty 'includedRules'; skipping." + ) + continue + } + newIdMap[holdout.id] = holdout + for ruleId in rules { + newRuleMap[ruleId, default: []].append(holdout) + } + } + + self.holdoutIdMap = newIdMap + self.global = newGlobal + self.ruleHoldoutsMap = newRuleMap } - + + // MARK: - Lookups + /// Returns local holdouts targeting a specific rule. - /// - Parameter ruleId: The rule identifier. - /// - Returns: An array of `Holdout` objects targeting the given rule. func getHoldoutsForRule(ruleId: String) -> [Holdout] { return ruleHoldoutsMap[ruleId] ?? [] } /// Returns all global holdouts. - /// - Returns: An array of global `Holdout` objects. func getGlobalHoldouts() -> [Holdout] { return global } - + /// Get a Holdout object for an Id. func getHoldout(id: String) -> Holdout? { return holdoutIdMap[id] diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index 95d58248..8c00728f 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -52,8 +52,9 @@ struct Project: Codable, Equatable { var sendFlagDecisions: Bool? var sdkKey: String? var environmentKey: String? - // Holdouts + // Holdouts: `holdouts` = global, `localHoldouts` = rule-scoped. var holdouts: [Holdout] + var localHoldouts: [Holdout] // Region var region: Region? let logger = OPTLoggerFactory.getLogger() @@ -65,7 +66,7 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, region + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, localHoldouts, region } init(from decoder: Decoder) throws { @@ -96,6 +97,9 @@ struct Project: Codable, Equatable { environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey) // Holdouts - defaults to empty array if key is not present holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? [] + // Local holdouts (FSSDK-12760) - new top-level section; defaults to empty when absent + // so old datafiles continue to parse unchanged. + localHoldouts = try container.decodeIfPresent([Holdout].self, forKey: .localHoldouts) ?? [] // Region - defaults to US if not present region = try container.decodeIfPresent(Region.self, forKey: .region) } @@ -103,12 +107,13 @@ struct Project: Codable, Equatable { // Required since logger is not equatable static func == (lhs: Project, rhs: Project) -> Bool { return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && lhs.holdouts == rhs.holdouts && + lhs.localHoldouts == rhs.localHoldouts && lhs.audiences == rhs.audiences && lhs.groups == rhs.groups && lhs.attributes == rhs.attributes && lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision && lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts && lhs.integrations == rhs.integrations && lhs.typedAudiences == rhs.typedAudiences && - lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && - lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && + lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && + lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && lhs.environmentKey == rhs.environmentKey && lhs.region == rhs.region } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index 83c59375..8c875849 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -81,7 +81,16 @@ class ProjectConfig { // variation into any experiment with type == .featureRollout injectFeatureRolloutVariations() - holdoutConfig.allHoldouts = project.holdouts + // Holdout sections are partitioned by the datafile (FSSDK-12760): + // - `holdouts` -> global holdouts (applied to every flag) + // - `localHoldouts` -> rule-scoped local holdouts + // HoldoutConfig enforces the partition: `includedRules` on global-section + // entries is stripped, and local-section entries without `includedRules` + // are logged and excluded. + holdoutConfig = HoldoutConfig( + globalHoldouts: project.holdouts, + localHoldouts: project.localHoldouts + ) self.experimentKeyMap = { var map = [String: Experiment]() diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index fdf1e871..b1c829ab 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -497,24 +497,23 @@ extension BatchEventBuilderTests_Events { try! optimizely.start(datafile: datafile) let holdout: Holdout = try! OTUtils.model(from: sampleHoldout) - optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] - + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let exp = expectation(description: "Wait for event to dispatch") let user = optimizely.createUserContext(userId: userId) _ = user.decide(key: featureKey) - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() } - + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) if result == XCTWaiter.Result.completed { let event = getFirstEventJSON(client: optimizely)! let visitor = (event["visitors"] as! Array>)[0] let snapshot = (visitor["snapshots"] as! Array>)[0] let decision = (snapshot["decisions"] as! Array>)[0] - + let metaData = decision["metadata"] as! Dictionary XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.holdout.rawValue) XCTAssertEqual(metaData["rule_key"] as! String, "holdout_key") @@ -524,9 +523,9 @@ extension BatchEventBuilderTests_Events { } else { XCTFail("No event found") } - + } - + func testImpressionEvent_UserInHoldout_IncludedFlags() { let eventDispatcher2 = MockEventDispatcher() var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "12345", eventDispatcher: eventDispatcher2) @@ -535,15 +534,14 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = ["10390977673"] // exp_no_audience rule in feature_1 - optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] - + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let exp = expectation(description: "Wait for event to dispatch") - + let user = optimizely.createUserContext(userId: userId) _ = user.decide(key: featureKey) - - + + // Add a delay before evaluating getFirstEventJSON DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() // Fulfill the expectation after the delay @@ -577,8 +575,7 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) - optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") @@ -618,8 +615,7 @@ extension BatchEventBuilderTests_Events { /// Set traffic allocation to gero holdout.trafficAllocation[0].endOfRange = 0 holdout.includedRules = ["10390977673"] // exp_with_audience rule in feature_1 - optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift index 0470af28..0d2f52bc 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -75,7 +75,7 @@ class DecisionListenerTests_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) self.notificationCenter = self.optimizely.notificationCenter! } @@ -119,8 +119,7 @@ class DecisionListenerTests_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) @@ -142,8 +141,7 @@ class DecisionListenerTests_Holdouts: XCTestCase { func testDecisionListenerDecideWithExcludedFlags() { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) @@ -172,8 +170,7 @@ class DecisionListenerTests_Holdouts: XCTestCase { // Include all rules in feature_1: experiment + delivery rules holdout_2.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout, holdout_2] - optimizely.config!.holdoutConfig.allHoldouts = [holdout, holdout_2] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout, holdout_2]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index 31f2e857..87bf4a98 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -208,10 +208,9 @@ class DecisionServiceTests_Holdouts: XCTestCase { featureFlag = try! OTUtils.model(from: sampleFeatureFlagData) self.config.project.featureFlags = [featureFlag] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) } - + } // MARK: - Test doesMeetAudienceConditions() @@ -226,49 +225,47 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = try! OTUtils.model(from: ["or", kAudienceIdCountry]) holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "attribute should be matched to audienceConditions") - + // (2) matching false result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result XCTAssertFalse(result, "attribute should be matched to audienceConditions") - + // (3) other attribute result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result XCTAssertFalse(result, "no matching attribute provided") } - + func testDoesMeetAudienceConditionsWithAudienceIds() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) - + // (1) matching true - + holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [kAudienceIdCountry] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "attribute should be matched to audienceConditions") - + // (2) matching false result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result XCTAssertFalse(result, "attribute should be matched to audienceConditions") - + // (3) other attribute result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, @@ -281,29 +278,27 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = try! OTUtils.model(from: []) holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let result: Bool! = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "empty conditions is true always") } - + func testDoesMeetAudienceConditionsWithAudienceIdsEmpty() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "empty conditions is true always") } - + func testDoesMeetAudienceConditionsWithCornerCases() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) holdout = try! OTUtils.model(from: sampleHoldout) @@ -314,36 +309,33 @@ extension DecisionServiceTests_Holdouts { var array: [ConditionHolder] = try! OTUtils.model(from: [kAudienceIdCountry]) holdout.audienceConditions = array[0] holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result) - + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesEmpty)).result XCTAssertFalse(result) - + // (2) invalid string in "audienceConditions" array = try! OTUtils.model(from: ["and"]) holdout.audienceConditions = array[0] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result) - + // (2) invalid string in "audienceConditions" holdout.audienceConditions = nil holdout.audienceIds = [] - self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result @@ -403,8 +395,7 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldout modifiedHoldoutData["status"] = "Draft" let inactiveHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [inactiveHoldout] - self.config.holdoutConfig.allHoldouts = [inactiveHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [inactiveHoldout]) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -422,8 +413,7 @@ extension DecisionServiceTests_Holdouts { func testGetVariationForFeatureExperiment_NoHoldouts() { // Remove holdouts - self.config.project.holdouts = [] - self.config.holdoutConfig.allHoldouts = [] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -447,8 +437,7 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout - self.config.project.holdouts = [globalHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) let mockBucketer = MockBucketer(mockBucketValue: 400) @@ -476,8 +465,7 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout - self.config.project.holdouts = [globalHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) let mockBucketer = MockBucketer(mockBucketValue: 400) @@ -496,27 +484,6 @@ extension DecisionServiceTests_Holdouts { XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } - func testGetVariationForFeatureExperiment_HoldoutExcludedFlag() { - // Modify holdout to exclude the feature flag - var modifiedHoldoutData = sampleHoldout - modifiedHoldoutData["includedRules"] = [] // Empty array = local holdout targeting no rules (excludes flag_id_1234) - let excludedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [excludedHoldout] - self.config.holdoutConfig.allHoldouts = [excludedHoldout] - - let decision = mockDecisionService.getVariationForFeature( - config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) - ).result - - // Should skip holdout and bucket into experiment - XCTAssertNotNil(decision, "Decision should not be nil") - XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") - XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") - } - func testGetVariationForFeatureExperiment_MultipleHoldoutsWithOrdering() { // Setup multiple holdouts: global, included, excluded let tfAllocationRange = 1500 @@ -528,9 +495,8 @@ extension DecisionServiceTests_Holdouts { var excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout excludedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange - self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout, excludedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) + // Mock bucketer to bucket into the first valid holdout (global) let mockBucketer = MockBucketer(mockBucketValue: 1000) // Within all holdout ranges let mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -553,9 +519,8 @@ extension DecisionServiceTests_Holdouts { // Setup multiple holdouts let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) + // Mock bucketer to fail global holdout bucketing, succeed for included let mockBucketer = MockBucketer(mockBucketValue: 700) // Outside global range, within included range mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -578,9 +543,8 @@ extension DecisionServiceTests_Holdouts { let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout let excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout, excludedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) + // Mock bucketer to fail all holdout bucketing let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside all holdout ranges mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -603,15 +567,14 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldoutGlobal modifiedHoldoutData["trafficAllocation"] = [] let noTrafficHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [noTrafficHoldout] - self.config.holdoutConfig.allHoldouts = [noTrafficHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noTrafficHoldout], localHoldouts: []) + let decision = mockDecisionService.getVariationForFeature( config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) ).result - + // Holdout has no traffic allocation, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) @@ -627,9 +590,8 @@ extension DecisionServiceTests_Holdouts { let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout // Requires country: "us" - self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) + // Mock bucketer to fail included holdout bucketing let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside included holdout range mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -651,8 +613,7 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldoutGlobal modifiedHoldoutData["variations"] = [] let noVariationsHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [noVariationsHoldout] - self.config.holdoutConfig.allHoldouts = [noVariationsHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noVariationsHoldout], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -671,9 +632,8 @@ extension DecisionServiceTests_Holdouts { // Setup multiple holdouts let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) + // First call let decision1 = mockDecisionService.getVariationForFeature( config: config, diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift index 0d39787a..781971fa 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift @@ -69,8 +69,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create global holdout (includedRules: nil) var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user buckets into holdout (50% traffic = endOfRange 5000) let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -92,8 +91,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create global holdout var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user MISSES holdout (50% traffic = endOfRange 5000) let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -116,8 +114,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting specific experiment rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Target the experiment rule - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -139,8 +136,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // 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] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -162,8 +158,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] holdout.audienceIds = ["13389130056"] // Audience from decide_datafile - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF audience matched let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -187,8 +182,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting delivery rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] // Target delivery rule from rollout - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -210,8 +204,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // 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] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -245,8 +238,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { holdout2.variations[0].id = "holdout_2_var_id" holdout2.variations[0].key = "holdout_2_variation" - config.project.holdouts = [holdout1, holdout2] - config.holdoutConfig.allHoldouts = [holdout1, holdout2] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer to ensure user buckets into both let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -273,8 +265,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { holdout2.includedRules = [deliveryRuleId] holdout2.variations[0].id = "holdout_2_var_id" - config.project.holdouts = [holdout1, holdout2] - config.holdoutConfig.allHoldouts = [holdout1, holdout2] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -297,8 +288,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting specific rule in feature_1 var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Only targets experiment in feature_1 - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -335,8 +325,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { localHoldout.variations[0].id = "local_var_id" localHoldout.variations[0].key = "local_variation" - config.project.holdouts = [globalHoldout, localHoldout] - config.holdoutConfig.allHoldouts = [globalHoldout, localHoldout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [localHoldout]) // Mock bucketer to ensure user buckets into both let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -357,8 +346,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -384,7 +372,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Test that local holdout targeting non-existent rule doesn't break evaluation // Expected: Empty array returned from getHoldoutsForRule(), normal evaluation continues - let config = HoldoutConfig(allholdouts: []) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) let result = config.getHoldoutsForRule(ruleId: "nonexistent_rule") XCTAssertTrue(result.isEmpty, "Non-existent rule should return empty array") @@ -398,8 +386,7 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.status = .draft // Not running holdout.includedRules = [experimentRuleId] - config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF holdout was active let mockBucketer = MockBucketer(mockBucketValue: 2500) diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index a21ca3c0..0633e9ca 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -61,9 +61,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) // Call decide with reasons @@ -90,10 +89,9 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) let decisions = user.decide(keys: featureKeys) @@ -121,9 +119,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) let decisions = user.decide(keys: featureKeys) @@ -144,13 +141,12 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { func testDecide_with_holdout_options_excludeVariables() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - - + + let featureKey = "feature_1" let user = optimizely.createUserContext(userId: kUserId) @@ -166,14 +162,13 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) - + var user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) var decision = user.decide(key: featureKey) @@ -189,9 +184,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { defaultDecideOptions: [.excludeVariables]) try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + user = optimizely.createUserContext(userId: kUserId) decision = user.decide(key: featureKey) @@ -206,15 +200,14 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId) - + let decision1 = user.decide(key: featureKey1) XCTAssert(decision1 == OptimizelyDecision(variationKey: "key_holdout_variation", @@ -234,16 +227,15 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId) - + let decisions = user.decide(keys: [featureKey1, featureKey2]) XCTAssert(decisions.count == 2) @@ -275,16 +267,15 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let featureKey3 = "feature_3" let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let variablesExpected3 = OptimizelyJSON.createEmpty() - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll() @@ -321,21 +312,20 @@ extension OptimizelyUserContextTests_Decide_Holdouts { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let variablesExpected3 = OptimizelyJSON.createEmpty() - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll() - + XCTAssert(decisions.count == 3) - + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "a", enabled: true, variables: variablesExpected1, @@ -368,8 +358,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Local holdout targeting all feature_1 rules (experiment + delivery rules), excludes feature_2 holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -422,8 +411,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { /// Local holdout applicable to feature_2 experiment rule includedHoldout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [gHoldout, includedHoldout] - optimizely.config!.holdoutConfig.allHoldouts = [gHoldout, includedHoldout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [gHoldout], localHoldouts: [includedHoldout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 1000)) optimizely.decisionService = mockDecisionService @@ -463,12 +451,11 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecideAll_with_holdouts_options_enabledFlagsOnly() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll(options: [.enabledFlagsOnly]) @@ -481,19 +468,18 @@ extension OptimizelyUserContextTests_Decide_Holdouts { extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression() { let featureKey = "feature_2" - + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) let decision = user.decide(key: featureKey) - + optimizely.eventLock.sync{} - + XCTAssertEqual(decision.variationKey, "key_holdout_variation") XCTAssertFalse(decision.enabled) XCTAssertFalse(eventDispatcher.events.isEmpty) @@ -518,19 +504,18 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecideError_doNotSendImpression() { let featureKey = "invalid" // invalid flag - + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) let decision = user.decide(key: featureKey) - + optimizely.eventLock.sync{} - + XCTAssertNil(decision.variationKey) XCTAssertFalse(decision.enabled) XCTAssert(eventDispatcher.events.isEmpty) @@ -538,12 +523,11 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_with_disable_tracking() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let featureKey = "feature_2" let user = optimizely.createUserContext(userId: kUserId) @@ -556,13 +540,13 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_withSendFlagDecisionsOff() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + optimizely.config?.project.sendFlagDecisions = false + optimizely.config!.project.holdouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let featureKey = "feature_2" diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift index f05d7ba0..11aeedf2 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -56,12 +56,11 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let featureKey = "feature_1" let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -71,7 +70,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) } - + /// Test when user is bucketed into the included flags holdout for feature_1 func testDecideReasons_userBucketedIntoIncludedHoldout() { let featureKey = "feature_1" @@ -80,9 +79,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -115,8 +113,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { // Bucket valud outside global holdout range but inside second holdout range let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 600)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout1, holdout2] - optimizely.config!.holdoutConfig.allHoldouts = [holdout1, holdout2] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout1], localHoldouts: [holdout2]) let user = optimizely.createUserContext(userId: kUserId) // Call decide with reasons @@ -133,8 +130,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.status = .draft - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -163,10 +159,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -188,9 +182,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift index acc9f804..9afe5871 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -18,7 +18,7 @@ import XCTest class HoldoutConfigTests: XCTestCase { func testEmptyHoldouts_shouldHaveEmptyMaps() { - let config = HoldoutConfig(allholdouts: []) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) XCTAssertTrue(config.holdoutIdMap.isEmpty) XCTAssertTrue(config.global.isEmpty) @@ -30,8 +30,7 @@ class HoldoutConfigTests: XCTestCase { let localHoldout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) let localHoldout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithDifferentRules) - let allHoldouts = [globalHoldout, localHoldout1, localHoldout2] - let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + let holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [localHoldout1, localHoldout2]) // Verify holdoutIdMap XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.includedRules, nil) @@ -56,8 +55,7 @@ class HoldoutConfigTests: XCTestCase { var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithDifferentRules) holdout2.id = "22222" - let allHoldouts = [holdout0, holdout1, holdout2] - let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + let holdoutConfig = HoldoutConfig(globalHoldouts: [holdout0], localHoldouts: [holdout1, holdout2]) XCTAssertEqual(holdoutConfig.getHoldout(id: "00000"), holdout0) XCTAssertEqual(holdoutConfig.getHoldout(id: "11111"), holdout1) @@ -74,7 +72,7 @@ class HoldoutConfigTests: XCTestCase { var local: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) local.id = "l1" - let config = HoldoutConfig(allholdouts: [local, global1, global2]) + let config = HoldoutConfig(globalHoldouts: [global1, global2], localHoldouts: [local]) let result = config.getGlobalHoldouts() XCTAssertEqual(result.count, 2) @@ -95,7 +93,7 @@ class HoldoutConfigTests: XCTestCase { var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) global.id = "g1" - let config = HoldoutConfig(allholdouts: [local1, local2, global]) + let config = HoldoutConfig(globalHoldouts: [global], localHoldouts: [local1, local2]) // Rule1 should only have local1 XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule1"), [local1]) @@ -137,7 +135,7 @@ class HoldoutConfigTests: XCTestCase { local3.id = "l3" local3.includedRules = ["shared_rule", "other_rule"] - let config = HoldoutConfig(allholdouts: [local1, local2, local3]) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: [local1, local2, local3]) let sharedRuleHoldouts = config.getHoldoutsForRule(ruleId: "shared_rule") XCTAssertEqual(sharedRuleHoldouts.count, 3) @@ -147,11 +145,10 @@ class HoldoutConfigTests: XCTestCase { } func testUpdateHoldoutMappingTriggeredOnAllHoldoutsChange() { - var config = HoldoutConfig(allholdouts: []) + var config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) XCTAssertTrue(config.global.isEmpty) XCTAssertTrue(config.ruleHoldoutsMap.isEmpty) - // When allHoldouts changes, updateHoldoutMapping() should be called automatically var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) global.id = "g1" @@ -159,10 +156,169 @@ class HoldoutConfigTests: XCTestCase { local.id = "l1" local.includedRules = ["rule1"] - config.allHoldouts = [global, local] + config = HoldoutConfig(globalHoldouts: [global], localHoldouts: [local]) // Verify maps were updated XCTAssertEqual(config.global.count, 1) XCTAssertEqual(config.ruleHoldoutsMap["rule1"]?.count, 1) } + + // MARK: - FSSDK-12760: localHoldouts section semantics + + /// Section-aware init: entries in the global section are classified as + /// global regardless of any `includedRules` field on them. + func testSectionAwareInit_globalSectionEntriesAreGlobal() { + var globalWithStrayRules: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + globalWithStrayRules.id = "g_stray" + globalWithStrayRules.includedRules = ["rule_should_be_ignored"] + + let config = HoldoutConfig( + globalHoldouts: [globalWithStrayRules], + localHoldouts: [] + ) + + // Must be classified as global + XCTAssertEqual(config.global.count, 1) + XCTAssertEqual(config.global.first?.id, "g_stray") + // `includedRules` must be stripped — section membership is the sole signal + XCTAssertNil(config.global.first?.includedRules) + XCTAssertTrue(config.global.first!.isGlobal) + // The stray rule must NOT be registered in the rule map + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "rule_should_be_ignored").isEmpty) + // Entity is still retrievable by id + XCTAssertNotNil(config.getHoldout(id: "g_stray")) + } + + /// Section-aware init: entries in the local section register under each + /// rule in their `includedRules` list and never appear in `global`. + func testSectionAwareInit_localSectionEntriesAreLocal() { + var local: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + local.id = "l1" + local.includedRules = ["rule_x", "rule_y"] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [local] + ) + + XCTAssertTrue(config.global.isEmpty) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_x"), [local]) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_y"), [local]) + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "rule_z").isEmpty) + } + + /// Local-section entries with `includedRules == nil` are invalid per spec. + /// They must be excluded from every map and must NOT fall back to global. + func testLocalSection_missingIncludedRules_isExcluded() { + var invalid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + invalid.id = "h_invalid" + invalid.includedRules = nil // missing — invalid for local section + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [invalid] + ) + + // Not applied as global + XCTAssertTrue(config.global.isEmpty) + // Not applied as local for any rule + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "any_rule").isEmpty) + // Not retrievable by id either + XCTAssertNil(config.getHoldout(id: "h_invalid")) + } + + /// Local-section entries with an empty `includedRules` list are invalid per + /// spec (they target no rules). They must be excluded entirely. + func testLocalSection_emptyIncludedRules_isExcluded() { + var invalid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + invalid.id = "h_empty" + invalid.includedRules = [] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [invalid] + ) + + XCTAssertTrue(config.global.isEmpty) + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "any_rule").isEmpty) + XCTAssertNil(config.getHoldout(id: "h_empty")) + } + + /// Both sections present: entries never cross over. Global stays global, + /// local stays local, even when both sections share a rule id space. + func testBothSections_partitionEnforced() { + var g1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g1.id = "g1" + var g2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g2.id = "g2" + + var l1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + l1.id = "l1" + l1.includedRules = ["rule_a"] + var l2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + l2.id = "l2" + l2.includedRules = ["rule_b"] + + let config = HoldoutConfig( + globalHoldouts: [g1, g2], + localHoldouts: [l1, l2] + ) + + // Global section + let globalIds = Set(config.getGlobalHoldouts().map { $0.id }) + XCTAssertEqual(globalIds, Set(["g1", "g2"])) + + // Local section — each rule resolves to its own holdout, never to a global one + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_a").map { $0.id }, ["l1"]) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_b").map { $0.id }, ["l2"]) + + // Both sections are retrievable by id + XCTAssertNotNil(config.getHoldout(id: "g1")) + XCTAssertNotNil(config.getHoldout(id: "l1")) + } + + /// Backward compatibility: when the datafile has no `localHoldouts` section + /// (passed as an empty list), every entry in the `holdouts` section is + /// treated as global — exactly matching pre-FSSDK-12760 behavior. + func testBackwardCompat_noLocalHoldoutsSection() { + var g1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g1.id = "g1" + + let config = HoldoutConfig( + globalHoldouts: [g1], + localHoldouts: [] + ) + + XCTAssertEqual(config.getGlobalHoldouts().count, 1) + XCTAssertEqual(config.getGlobalHoldouts().first?.id, "g1") + XCTAssertTrue(config.ruleHoldoutsMap.isEmpty) + } + + /// Mixed-validity local section: valid entries are kept, invalid entries + /// are excluded without affecting the valid ones. + func testLocalSection_invalidEntriesDoNotAffectValidOnes() { + var valid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + valid.id = "valid" + valid.includedRules = ["rule_x"] + + var invalidNil: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + invalidNil.id = "invalid_nil" + invalidNil.includedRules = nil + + var invalidEmpty: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + invalidEmpty.id = "invalid_empty" + invalidEmpty.includedRules = [] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [valid, invalidNil, invalidEmpty] + ) + + // Only the valid local holdout is registered + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_x").map { $0.id }, ["valid"]) + XCTAssertNotNil(config.getHoldout(id: "valid")) + XCTAssertNil(config.getHoldout(id: "invalid_nil")) + XCTAssertNil(config.getHoldout(id: "invalid_empty")) + XCTAssertTrue(config.global.isEmpty) + } } diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index 9389e70a..978825c1 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift @@ -98,10 +98,12 @@ class ProjectConfigTests: XCTestCase { var holdout2 = HoldoutTests.sampleData var holdout3 = HoldoutTests.sampleData var holdout4 = HoldoutTests.sampleData - - holdout0["id"] = "3000" // Global holdout (includedRules == nil) - holdout1["id"] = "3001" // Global holdout (includedRules == nil) - holdout2["id"] = "3002" // Global holdout (includedRules == nil) + + // FSSDK-12760: scope is determined by datafile section, not by `includedRules`. + // Entries in `holdouts` are global; entries in `localHoldouts` are local. + holdout0["id"] = "3000" // Global holdout (in `holdouts` section) + holdout1["id"] = "3001" // Global holdout (in `holdouts` section) + holdout2["id"] = "3002" // Global holdout (in `holdouts` section) holdout3["id"] = "3003" // Local holdout targeting rules in feature 2000 and 2002 holdout4["id"] = "3004" // Local holdout targeting rules NOT in feature 2001 @@ -135,7 +137,9 @@ class ProjectConfigTests: XCTestCase { var projectData = ProjectTests.sampleData projectData["experiments"] = [exp0, exp1, exp2, exp3, exp4] projectData["featureFlags"] = [feature0, feature1, feature2, feature3] - projectData["holdouts"] = [holdout0, holdout1, holdout2, holdout3, holdout4] + // FSSDK-12760: globals go into `holdouts`, locals into `localHoldouts`. + projectData["holdouts"] = [holdout0, holdout1, holdout2] + projectData["localHoldouts"] = [holdout3, holdout4] // check experimentFeatureMap extracted properly diff --git a/Tests/OptimizelyTests-DataModel/ProjectTests.swift b/Tests/OptimizelyTests-DataModel/ProjectTests.swift index e0d10fb9..4a01e82a 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectTests.swift @@ -215,13 +215,39 @@ extension ProjectTests { func testDecodeSuccessWithMissingHoldouts() { var data: [String: Any] = ProjectTests.sampleData data["holdouts"] = nil - + let model: Project = try! OTUtils.model(from: data) XCTAssertNotNil(model) XCTAssertEqual(model.holdouts, []) - + } - + + // MARK: - FSSDK-12760: top-level `localHoldouts` section + + /// Old datafiles without a `localHoldouts` key must continue to parse; + /// `localHoldouts` defaults to an empty array. + func testDecodeSuccessWithMissingLocalHoldouts() { + var data: [String: Any] = ProjectTests.sampleData + data["localHoldouts"] = nil + + let model: Project = try! OTUtils.model(from: data) + XCTAssertEqual(model.localHoldouts, []) + } + + /// New datafiles emit a `localHoldouts` array alongside `holdouts`. + /// Both sections must be decoded into their respective fields. + func testDecodeSuccessWithLocalHoldoutsSection() { + var data: [String: Any] = ProjectTests.sampleData + data["localHoldouts"] = [HoldoutTests.sampleDataWithIncludedRules] + + let model: Project = try! OTUtils.model(from: data) + XCTAssertEqual(model.localHoldouts.count, 1) + XCTAssertEqual(model.localHoldouts.first?.id, "55555") + XCTAssertEqual(model.localHoldouts.first?.includedRules, ["4444", "5555"]) + // Global section must not be affected + XCTAssertEqual(model.holdouts, [try! OTUtils.model(from: HoldoutTests.sampleData)]) + } + } // MARK: - Encode