diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index ae0344f1..a442597c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2014,6 +2014,12 @@ 98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; }; 982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; 982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; + 9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; + 984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; + 984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; }; + 984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; }; + 984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; }; + 984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; }; 984E2FDC2B27199B001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; @@ -2569,6 +2575,9 @@ 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; + 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Async.swift; sourceTree = ""; }; + 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_CMAB.swift; sourceTree = ""; }; + 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_BucketToEntity.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = ""; }; @@ -3093,6 +3102,7 @@ 6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */, 6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */, 6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */, + 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */, 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */, 6E75198422C5211100B2B157 /* BucketTests_Others.swift */, 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, @@ -3126,6 +3136,8 @@ 6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */, 6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */, 6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */, + 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */, + 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */, 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */, 6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */, 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */, @@ -5005,6 +5017,7 @@ 6E7518C122C520D400B2B157 /* Variable.swift in Sources */, 6E75170F22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6EC6DD4C24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, + 984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */, 8464087D28130D3200CCF97D /* Integration.swift in Sources */, 6E7517E922C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, @@ -5012,6 +5025,7 @@ 6E9B116A22C5487100C22D81 /* BucketTests_Base.swift in Sources */, 6E9B115F22C5487100C22D81 /* MurmurTests.swift in Sources */, 6E9B116022C5487100C22D81 /* DecisionServiceTests_Experiments.swift in Sources */, + 984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */, 6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5025,6 +5039,7 @@ 980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */, 6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */, 8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */, + 984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */, 84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */, 6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190922C520D500B2B157 /* Attribute.swift in Sources */, @@ -5297,6 +5312,7 @@ 84861800286CF33700B7F41B /* OdpEvent.swift in Sources */, 6EC6DD4524ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E7517CB22C520D400B2B157 /* DefaultBucketer.swift in Sources */, + 984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */, 845945C1287758A000D13E11 /* OdpConfig.swift in Sources */, 8464087528130D3200CCF97D /* Integration.swift in Sources */, 989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, @@ -5304,6 +5320,7 @@ 6E9B115022C5486E00C22D81 /* BucketTests_Base.swift in Sources */, 6E9B114522C5486E00C22D81 /* MurmurTests.swift in Sources */, 6E9B114622C5486E00C22D81 /* DecisionServiceTests_Experiments.swift in Sources */, + 984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */, 6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5317,6 +5334,7 @@ 6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */, 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6E652305278E688B00954EA1 /* LruCache.swift in Sources */, diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index e0eb9580..c74205e8 100644 --- a/Sources/CMAB/CmabService.swift +++ b/Sources/CMAB/CmabService.swift @@ -30,6 +30,10 @@ struct CmabCacheValue { typealias CmabDecisionCompletionHandler = (Result) -> Void protocol CmabService { + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption]) -> Result func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, @@ -37,7 +41,7 @@ protocol CmabService { completion: @escaping CmabDecisionCompletionHandler) } -class DefaultCmabService { +class DefaultCmabService: CmabService { typealias UserAttributes = [String : Any?] private let cmabClient: CmabClient @@ -49,6 +53,22 @@ class DefaultCmabService { self.cmabCache = cmabCache } + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption]) -> Result { + var result: Result! + let semaphore = DispatchSemaphore(value: 0) + getDecision(config: config, + userContext: userContext, + ruleId: ruleId, options: options) { _result in + result = _result + semaphore.signal() + } + semaphore.wait() + return result + } + func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, @@ -155,3 +175,12 @@ class DefaultCmabService { return filteredUserAttributes } } + +extension DefaultCmabService { + static func createDefault() -> DefaultCmabService { + let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds + let DEFAULT_CMAB_CACHE_SIZE = 1000 + let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) + return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) + } +} diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index 87cecd9e..bfe8418a 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -71,4 +71,7 @@ extension Experiment { return status == .running } + var isCmab: Bool { + return cmab != nil + } } diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 7f616eeb..cdf5a6c1 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -37,43 +37,16 @@ class DefaultBucketer: OPTBucketer { bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() - var mutexAllowed = true - - // check for mutex - - let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first - - if let group = group { - switch group.policy { - case .overlapping: - break - case .random: - let decisionResponse = bucketToExperiment(config: config, - group: group, - bucketingId: bucketingId) - reasons.merge(decisionResponse.reasons) - if let mutexExperiment = decisionResponse.result { - if mutexExperiment.id == experiment.id { - mutexAllowed = true - - let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - logger.i(info) - reasons.addInfo(info) - } else { - mutexAllowed = false - - let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - logger.i(info) - reasons.addInfo(info) - } - } else { - mutexAllowed = false - - let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) - logger.i(info) - reasons.addInfo(info) - } - } + // Check mutex rules + let mutexAllowed = checkMutexRules( + config: config, + experiment: experiment, + bucketingId: bucketingId, + reasons: reasons + ) + + if !mutexAllowed { + return DecisionResponse(result: nil, reasons: reasons) } if !mutexAllowed { return DecisionResponse(result: nil, reasons: reasons) } @@ -120,6 +93,83 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } + /// Checks if an experiment is allowed to run based on mutex rules + /// - Parameters: + /// - config: The project configuration + /// - experiment: The experiment to check + /// - bucketingId: The bucketing ID for the user + /// - reasons: Decision reasons to track the mutex check process + /// - Returns: A boolean indicating if the experiment is allowed to run + private func checkMutexRules( + config: ProjectConfig, + experiment: Experiment, + bucketingId: String, + reasons: DecisionReasons + ) -> Bool { + // Find the group containing this experiment + let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first + + guard let group = group else { + return true // No group found, experiment is allowed + } + + switch group.policy { + case .overlapping: + return true // Overlapping experiments are always allowed + + case .random: + let decisionResponse = bucketToExperiment( + config: config, + group: group, + bucketingId: bucketingId + ) + reasons.merge(decisionResponse.reasons) + + guard let mutexExperiment = decisionResponse.result else { + let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) + logger.i(info) + reasons.addInfo(info) + return false + } + + let isAllowed = mutexExperiment.id == experiment.id + let info = isAllowed + ? LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + : LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + + logger.i(info) + reasons.addInfo(info) + return isAllowed + } + } + + func bucketToEntityId(config: ProjectConfig, + experiment: Experiment, + bucketingId: String, + trafficAllocation: [TrafficAllocation]) -> DecisionResponse { + + let reasons = DecisionReasons() + + // Check mutex rules + let mutexAllowed = checkMutexRules( + config: config, + experiment: experiment, + bucketingId: bucketingId, + reasons: reasons + ) + + if !mutexAllowed { + return DecisionResponse(result: nil, reasons: reasons) + } + + let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) + let bucketValue = self.generateBucketValue(bucketingId: hashId) + + let entityId = allocateTraffic(trafficAllocation: trafficAllocation, bucketValue: bucketValue) + + return DecisionResponse(result: entityId, reasons: reasons) + } + func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() @@ -153,7 +203,7 @@ class DefaultBucketer: OPTBucketer { for bucket in trafficAllocation where bucketValue < bucket.endOfRange { return bucket.entityId } - + return nil } diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 003cc04f..d3fb64e2 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -18,15 +18,30 @@ import Foundation struct FeatureDecision { var experiment: ExperimentCore? - let variation: Variation + let variation: Variation? let source: String + var cmabUUID: String? } +struct VariationDecision { + var variation: Variation? + var cmabError: Bool = false + var cmabUUID: String? +} + +enum OperationType { + case async + case sync +} + +typealias OPType = OperationType typealias UserProfile = OPTUserProfileService.UPProfile class DefaultDecisionService: OPTDecisionService { let bucketer: OPTBucketer let userProfileService: OPTUserProfileService + let cmabService: CmabService + let group: DispatchGroup = DispatchGroup() // thread-safe lazy logger load (after HandlerRegisterService ready) private let threadSafeLogger = ThreadSafeLogger() @@ -37,14 +52,90 @@ class DefaultDecisionService: OPTDecisionService { return threadSafeLogger.logger } - init(userProfileService: OPTUserProfileService) { + init(userProfileService: OPTUserProfileService, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService + self.cmabService = cmabService } - init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { + init(userProfileService: OPTUserProfileService, + bucketer: OPTBucketer, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = bucketer self.userProfileService = userProfileService + self.cmabService = cmabService + } + + // MARK: - CMAB decision + + /// Get decision for CMAB Experiment + /// - Parameters: + /// - config: The project configuration containing experiment and feature details. + /// - experiment: The CMAB experiment to evaluate. + /// - user: The user context containing user ID and attributes. + /// - options: Optional decision options (e.g., ignore user profile service). + /// - Returns: A `CMABDecisionResult` containing the CMAB decisions( variation id, cmabUUID) with reasons + + private func getDecisionForCmabExperiment(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + bucketingId: String, + opType: OPType, + options: [OptimizelyDecideOption]?) -> DecisionResponse { + let reasons = DecisionReasons(options: options) + guard let cmab = experiment.cmab else { + logger.e("The experiment isn't a CMAB experiment") + return DecisionResponse(result: nil, reasons: reasons) + } + + guard opType == .async else { + let info = LogMessage.cmabNotSupportedInSyncMode + logger.w(info) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + let dummyEntityId = "$" + let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation) + let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(config: config, experiment: experiment, bucketingId: bucketingId, trafficAllocation: [cmabTrafficAllocation]) + + if let _reasons = bucketedResponse?.reasons { + reasons.merge(_reasons) + } + + let entityId = bucketedResponse?.result + + // this means the user is not in the cmab experiment + if entityId == nil { + let info = LogMessage.userNotInCmabExperiment(user.userId, experiment.key) + logger.d(info) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + /// Fetch CMAB decision + let response = cmabService.getDecision(config: config, userContext: user, ruleId: experiment.id, options: options ?? []) + var cmabDecision: CmabDecision? + switch response { + case .success(let dicision): + cmabDecision = dicision + case .failure: + let info = LogMessage.cmabFetchFailed(experiment.key) + self.logger.e(info) + reasons.addInfo(info) + let nilVariation = VariationDecision(variation: nil, cmabError: true, cmabUUID: nil) + return DecisionResponse(result: nilVariation, reasons: reasons) + } + + if let cmabDecision = cmabDecision, + let experiment = config.getExperiment(id: experiment.id), + let bucketedVariation = experiment.getVariation(id: cmabDecision.variationId) { + let variationDecision = VariationDecision(variation: bucketedVariation, cmabUUID: cmabDecision.cmabUUID) + return DecisionResponse(result: variationDecision, reasons: reasons) + } + + return DecisionResponse(result: nil, reasons: reasons) } // MARK: - Experiment Decision @@ -90,14 +181,31 @@ class DefaultDecisionService: OPTDecisionService { user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, userProfileTracker: UserProfileTracker?) -> DecisionResponse { + let decisionResponse = self.getVariation(config: config, experiment: experiment, user: user, opType: .sync, userProfileTracker: userProfileTracker) + + return DecisionResponse(result: decisionResponse.result?.variation, reasons: decisionResponse.reasons) + } + + /// Determines the variation for a user in an experiment, considering user profile and decision rules. + /// - Parameters: + /// - config: The project configuration. + /// - experiment: The experiment to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - opType: Operation type, either sync or async + /// - userProfileTracker: Optional tracker for user profile data. + /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons. + func getVariation(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + opType: OPType, + userProfileTracker: UserProfileTracker?) -> DecisionResponse { let reasons = DecisionReasons(options: options) let userId = user.userId let attributes = user.attributes let experimentId = experiment.id - // Acquire bucketingId . - let bucketingId = getBucketingId(userId: userId, attributes: attributes) - // ---- check if the experiment is running ---- if !experiment.isActivated { let info = LogMessage.experimentNotRunning(experiment.key) @@ -113,7 +221,8 @@ class DefaultDecisionService: OPTDecisionService { if let variationId = decisionResponse.result?.id, let variation = experiment.getVariation(id: variationId) { - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } // ---- check to see if user is white-listed for a certain variation ---- @@ -122,7 +231,8 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.forcedVariationFound(variationKey, userId) logger.i(info) reasons.addInfo(info) - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } // mapped to invalid variation - ignore and continue for other deciesions @@ -139,10 +249,12 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId) logger.i(info) reasons.addInfo(info) - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } - var bucketedVariation: Variation? + + var variationDecision: VariationDecision? // ---- check if the user passes audience targeting before bucketing ---- let audienceResponse = doesMeetAudienceConditions(config: config, experiment: experiment, @@ -150,15 +262,30 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(audienceResponse.reasons) if audienceResponse.result ?? false { - // bucket user into a variation - let decisionResponse = bucketer.bucketExperiment(config: config, - experiment: experiment, - bucketingId: bucketingId) - reasons.merge(decisionResponse.reasons) + // Acquire bucketingId + let bucketingId = getBucketingId(userId: userId, attributes: attributes) - bucketedVariation = decisionResponse.result + if experiment.isCmab { + let cmabDecisionResponse = getDecisionForCmabExperiment(config: config, + experiment: experiment, + user: user, + bucketingId: bucketingId, + opType: opType, + options: options) + reasons.merge(cmabDecisionResponse.reasons) + variationDecision = cmabDecisionResponse.result + } else { + /// bucket user into a variation + let decisionResponse = bucketer.bucketExperiment(config: config, + experiment: experiment, + bucketingId: bucketingId) + reasons.merge(decisionResponse.reasons) + if let variation = decisionResponse.result { + variationDecision = VariationDecision(variation: variation) + } + } - if let variation = bucketedVariation { + if let variation = variationDecision?.variation { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) @@ -175,7 +302,7 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - return DecisionResponse(result: bucketedVariation, reasons: reasons) + return DecisionResponse(result: variationDecision, reasons: reasons) } // MARK: - Feature Flag Decision @@ -192,7 +319,16 @@ class DefaultDecisionService: OPTDecisionService { user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, options: options).first + self.getVariationForFeature(config: config, featureFlag: featureFlag, user: user, opType: .sync, options: options) + } + + func getVariationForFeature(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + opType: OPType, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + + let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, opType: opType, options: options).first guard response?.result != nil else { let reasons = response?.reasons ?? DecisionReasons(options: options) @@ -212,6 +348,7 @@ class DefaultDecisionService: OPTDecisionService { func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, + opType: OPType = .sync, options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { let userId = user.userId @@ -225,7 +362,7 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, opType: opType, options: options) decisions.append(flagDecisionResponse) } @@ -249,6 +386,7 @@ class DefaultDecisionService: OPTDecisionService { featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, + opType: OPType, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -257,7 +395,8 @@ class DefaultDecisionService: OPTDecisionService { let holdoutDecision = getVariationForHoldout(config: config, flagKey: featureFlag.key, holdout: holdout, - user: user) + user: user, + options: options) reasons.merge(holdoutDecision.reasons) if let variation = holdoutDecision.result { let featureDicision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) @@ -265,14 +404,14 @@ class DefaultDecisionService: OPTDecisionService { } } - let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker) + let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, opType: opType, options: options) reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { return DecisionResponse(result: decision, reasons: reasons) } - let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) + let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options) reasons.merge(rolloutDecision.reasons) if let decision = rolloutDecision.result { @@ -291,10 +430,11 @@ class DefaultDecisionService: OPTDecisionService { /// - options: Optional decision options. /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeatureExperiments(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - userProfileTracker: UserProfileTracker? = nil, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, + opType: OPType = .sync, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) let experimentIds = featureFlag.experimentIds @@ -313,11 +453,14 @@ class DefaultDecisionService: OPTDecisionService { rule: experiment, user: user, userProfileTracker: userProfileTracker, + opType: opType, options: options) reasons.merge(decisionResponse.reasons) - if let variation = decisionResponse.result { - let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) - return DecisionResponse(result: featureDecision, reasons: reasons) + if let result = decisionResponse.result { + if result.cmabError || result.variation != nil { + let featureDecision = FeatureDecision(experiment: experiment, variation: result.variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID) + return DecisionResponse(result: featureDecision, reasons: reasons) + } } } } @@ -471,7 +614,8 @@ class DefaultDecisionService: OPTDecisionService { rule: Experiment, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker?, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + opType: OPType = .sync, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) // check forced-decision first let forcedDecisionResponse = findValidatedForcedDecision(config: config, @@ -480,16 +624,19 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(forcedDecisionResponse.reasons) if let variation = forcedDecisionResponse.result { - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } let decisionResponse = getVariation(config: config, experiment: rule, user: user, + options: options, + opType: opType, userProfileTracker: userProfileTracker) - let variation = decisionResponse.result + let variationResult = decisionResponse.result reasons.merge(decisionResponse.reasons) - return DecisionResponse(result: variation, reasons: reasons) + return DecisionResponse(result: variationResult, reasons: reasons) } /// Determines the variation for a delivery rule in a rollout. diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index eda0d38a..6e88860d 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -77,20 +77,75 @@ extension OptimizelyClient { var allOptions = defaultDecideOptions + (options ?? []) allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreDefaultOptions: true) + let decisionMap = decide(user: user, keys: [key], options: allOptions, opType: .sync, ignoreDefaultOptions: true) return decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) } + func decideAsync(user: OptimizelyUserContext, + key: String, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideCompletion) { + decisionQueue.async { + guard let config = self.config else { + let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .sdkNotReady) + completion(decision) + return + } + + guard let _ = config.getFeatureFlag(key: key) else { + let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) + completion(decision) + return + } + + var allOptions = self.defaultDecideOptions + (options ?? []) + allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) + + let decisionMap = self.decide(user: user, keys: [key], options: allOptions, opType: .async, ignoreDefaultOptions: true) + let decision = decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) + completion(decision) + } + } + func decide(user: OptimizelyUserContext, keys: [String], options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - return decide(user: user, keys: keys, options: options, ignoreDefaultOptions: false) + return decide(user: user, keys: keys, options: options, opType: .sync, ignoreDefaultOptions: false) + } + + func decideAsync(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + decisionQueue.async { + let decisions = self.decide(user: user, keys: keys, options: options, opType: .async, ignoreDefaultOptions: false) + completion(decisions) + } } func decide(user: OptimizelyUserContext, keys: [String], options: [OptimizelyDecideOption]? = nil, ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { + return self.decide(user: user, keys: keys, options: options, opType: .sync, ignoreDefaultOptions: ignoreDefaultOptions) + } + + func decideAsync(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + ignoreDefaultOptions: Bool, + completion: @escaping DecideForKeysCompletion) { + decisionQueue.async { + let decisions = self.decide(user: user, keys: keys, options: options, opType: .async, ignoreDefaultOptions: ignoreDefaultOptions) + completion(decisions) + } + } + + private func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + opType: OPType, + ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { guard let config = self.config else { logger.e(OptimizelyError.sdkNotReady) return [:] @@ -132,7 +187,7 @@ extension OptimizelyClient { } } - let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, options: allOptions) + let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, opType: opType, options: allOptions) for index in 0.. [String: OptimizelyDecision] { + guard let config = self.config else { + logger.e(OptimizelyError.sdkNotReady) + return [:] + } + + return decide(user: user, keys: config.featureFlagKeys, options: options) + } + + func decideAllAsync(user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + + decisionQueue.async { + guard let config = self.config else { + self.logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + let decision = self.decide(user: user, keys: config.featureFlagKeys, options: options, opType: .async, ignoreDefaultOptions: false) + completion(decision) + } + } + private func createOptimizelyDecision(flagKey: String, user: OptimizelyUserContext, flagDecision: FeatureDecision?, @@ -179,7 +260,7 @@ extension OptimizelyClient { let userId = user.userId let attributes = user.attributes - let flagEnabled = flagDecision?.variation.featureEnabled ?? false + let flagEnabled = flagDecision?.variation?.featureEnabled ?? false logger.i("Feature \(flagKey) is enabled for user \(userId) \(flagEnabled)") @@ -231,7 +312,7 @@ extension OptimizelyClient { reasons: reasonsToReport, decisionEventDispatched: decisionEventDispatched)) - return OptimizelyDecision(variationKey: flagDecision?.variation.key, + return OptimizelyDecision(variationKey: flagDecision?.variation?.key, enabled: flagEnabled, variables: optimizelyJSON, ruleKey: ruleKey, @@ -240,16 +321,6 @@ extension OptimizelyClient { reasons: reasonsToReport) } - func decideAll(user: OptimizelyUserContext, - options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - guard let config = self.config else { - logger.e(OptimizelyError.sdkNotReady) - return [:] - } - - return decide(user: user, keys: config.featureFlagKeys, options: options) - } - } // MARK: - Utils diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index e21c16d6..70959baf 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext.swift @@ -16,6 +16,9 @@ import Foundation +public typealias DecideCompletion = (OptimizelyDecision) -> Void +public typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void + /// An object for user contexts that the SDK will use to make decisions for. public class OptimizelyUserContext { weak var optimizely: OptimizelyClient? @@ -121,6 +124,45 @@ public class OptimizelyUserContext { return optimizely.decide(user: clone, key: key, options: options) } + /// Asynchronously makes a feature decision for a given feature key. + /// + /// - Parameters: + /// - key: The feature key to make a decision for + /// - options: Optional array of decision options that will be used for this decision only + /// - completion: A callback that receives the resulting OptimizelyDecision + /// + /// - Note: + /// - If the SDK is not ready, this method will immediately return an error decision through the completion handler. + /// - The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAsync(key: String, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideCompletion) { + + guard let optimizely = self.optimizely, let clone = self.clone else { + let decision = OptimizelyDecision.errorDecision(key: key, user: self, error: .sdkNotReady) + completion(decision) + return + } + optimizely.decideAsync(user: clone, key: key, options: options, completion: completion) + } + + /// Returns a decision result asynchronously for a given flag key + /// - Parameters: + /// - key: A flag key for which a decision will be made + /// - options: An array of options for decision-making + /// - Returns: A decision result + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAsync(key: String, + options: [OptimizelyDecideOption]? = nil) async -> OptimizelyDecision { + return await withCheckedContinuation { continuation in + decideAsync(key: key, options: options) { decision in + continuation.resume(returning: decision) + } + } + } + /// Returns a key-map of decision results for multiple flag keys and a user context. /// /// - If the SDK finds an error (__flagKeyInvalid__, etc) for a key, the response will include a decision for the key showing `reasons` for the error (regardless of __includeReasons__ in options). @@ -141,6 +183,46 @@ public class OptimizelyUserContext { return optimizely.decide(user: clone, keys: keys, options: options) } + /// Asynchronously decides variations for multiple feature flags. + /// + /// - Parameters: + /// - keys: An array of feature flag keys. + /// - options: An array of options for decision-making. + /// - completion: A callback that receives a dictionary mapping each feature flag key to its corresponding decision result. + /// + /// - Note: + /// - If the SDK is not ready, this method will immediately return an error decision through the completion handler. + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAsync(keys: [String], + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + + guard let optimizely = self.optimizely, let clone = self.clone else { + logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + optimizely.decideAsync(user: clone, keys: keys, options: options, completion: completion) + } + + /// Returns decisions for multiple flag keys asynchronously + /// - Parameters: + /// - keys: An array of flag keys for which decisions will be made + /// - options: An array of options for decision-making + /// - Returns: A dictionary of all decision results, mapped by flag keys + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAsync(keys: [String], + options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] { + return await withCheckedContinuation { continuation in + decideAsync(keys: keys, options: options) { decisions in + continuation.resume(returning: decisions) + } + } + } + /// Returns a key-map of decision results for all active flag keys. /// /// - Parameters: @@ -155,6 +237,38 @@ public class OptimizelyUserContext { return optimizely.decideAll(user: clone, options: options) } + /// Asynchronously makes a decision for all features and experiments for this user. + /// + /// - Parameters: + /// - options: An array of decision options. If not provided, the default options will be used. + /// - completion: A closure that will be called with the decision results for all keys. + /// The closure takes a dictionary of feature/experiment keys to their corresponding decision results. + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAllAsync(options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) { + guard let optimizely = self.optimizely, let clone = self.clone else { + logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + optimizely.decideAllAsync(user: clone, options: options, completion: completion) + } + + /// Returns decisions for all active flag keys asynchronously + /// - Parameter options: An array of options for decision-making + /// - Returns: A dictionary of all decision results, mapped by flag keys + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAllAsync(options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] { + return await withCheckedContinuation { continuation in + decideAllAsync(options: options) { decisions in + continuation.resume(returning: decisions) + } + } + } + /// Tracks an event. /// /// - Parameters: diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 7c7179a4..092691f3 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -48,6 +48,7 @@ open class OptimizelyClient: NSObject { } let eventLock = DispatchQueue(label: "com.optimizely.client") + let decisionQueue = DispatchQueue(label: "com.optimizely.decisionQueue") // MARK: - Customizable Services @@ -107,11 +108,13 @@ open class OptimizelyClient: NSObject { let logger = logger ?? DefaultLogger() type(of: logger).logLevel = defaultLogLevel ?? .info + let cmabService = DefaultCmabService.createDefault() + self.registerServices(sdkKey: sdkKey, logger: logger, eventDispatcher: eventDispatcher ?? DefaultEventDispatcher.sharedInstance, datafileHandler: datafileHandler ?? DefaultDatafileHandler(), - decisionService: DefaultDecisionService(userProfileService: userProfileService), + decisionService: DefaultDecisionService(userProfileService: userProfileService, cmabService: cmabService), notificationCenter: DefaultNotificationCenter()) self.logger = HandlerRegistryService.shared.injectLogger() @@ -435,7 +438,7 @@ open class OptimizelyClient: NSObject { options: nil).result let source = pair?.source ?? Constants.DecisionSource.rollout.rawValue - let featureEnabled = pair?.variation.featureEnabled ?? false + let featureEnabled = pair?.variation?.featureEnabled ?? false if featureEnabled { logger.i(.featureEnabledForUser(featureKey, userId)) } else { @@ -585,8 +588,8 @@ open class OptimizelyClient: NSObject { user: makeInternalUserContext(userId: userId, attributes: attributes), options: nil).result if let decision = decision { - if let featureVariable = decision.variation.variables?.filter({$0.id == variable.id}).first { - if let featureEnabled = decision.variation.featureEnabled, featureEnabled { + if let featureVariable = decision.variation?.variables?.filter({$0.id == variable.id}).first { + if let featureEnabled = decision.variation?.featureEnabled, featureEnabled { featureValue = featureVariable.value logger.i(.userReceivedVariableValue(featureValue, variableKey, featureKey)) } else { @@ -675,7 +678,7 @@ open class OptimizelyClient: NSObject { featureFlag: featureFlag, user: makeInternalUserContext(userId: userId, attributes: attributes), options: nil).result - if let featureEnabled = decision?.variation.featureEnabled { + if let featureEnabled = decision?.variation?.featureEnabled { enabled = featureEnabled if featureEnabled { logger.i(.featureEnabledForUser(featureKey, userId)) @@ -688,7 +691,7 @@ open class OptimizelyClient: NSObject { for v in featureFlag.variables { var featureValue = v.defaultValue ?? "" - if enabled, let variable = decision?.variation.getVariable(id: v.id) { + if enabled, let variable = decision?.variation?.getVariable(id: v.id) { featureValue = variable.value } diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 4ce7c08a..2c0d39e0 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -47,6 +47,8 @@ enum LogMessage { case userHasNoForcedVariation(_ userId: String) case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String) case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String) + case userBucketedIntoEntity(_ entityId: String) + case userNotBucketedIntoAnyEntity case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String) case userNotBucketedIntoVariation(_ userId: String) case userBucketedIntoInvalidVariation(_ id: String) @@ -56,6 +58,7 @@ enum LogMessage { case userNotBucketedIntoAnyExperimentInGroup(_ userId: String, _ group: String) case userBucketedIntoInvalidExperiment(_ id: String) case userNotInExperiment(_ userId: String, _ expKey: String) + case userNotInCmabExperiment(_ userId: String, _ expKey: String) case userReceivedDefaultVariableValue(_ userId: String, _ feature: String, _ variable: String) case userReceivedAllDefaultVariableValues(_ userId: String, _ feature: String) case featureNotEnabledReturnDefaultVariableValue(_ userId: String, _ feature: String, _ variable: String) @@ -73,6 +76,8 @@ enum LogMessage { case failedToAssignValue case valueForKeyNotFound(_ key: String) case lowPeriodicDownloadInterval + case cmabFetchFailed(_ expKey: String) + case cmabNotSupportedInSyncMode } extension LogMessage: CustomStringConvertible { @@ -114,6 +119,8 @@ extension LogMessage: CustomStringConvertible { case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map." case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map." case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))" + case .userBucketedIntoEntity(let entityId): message = "User bucketed into entity (\(entityId))" + case .userNotBucketedIntoAnyEntity: message = "User not bucketed into any entity" case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))" case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation." case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation." @@ -123,6 +130,7 @@ extension LogMessage: CustomStringConvertible { case .userNotBucketedIntoAnyExperimentInGroup(let userId, let group): message = "User (\(userId)) is not in any experiment of group (\(group))." case .userBucketedIntoInvalidExperiment(let id): message = "Bucketed into an invalid experiment id (\(id))" case .userNotInExperiment(let userId, let expKey): message = "User (\(userId)) does not meet conditions to be in experiment (\(expKey))." + case .userNotInCmabExperiment(let userId, let expKey): message = "User (\(userId)) does not fall into cmab taffic allocation in experiment (\(expKey))." case .userReceivedDefaultVariableValue(let userId, let feature, let variable): message = "User (\(userId)) is not in any variation or rollout rule. Returning default value for variable (\(variable)) of feature flag (\(feature))." case .userReceivedAllDefaultVariableValues(let userId, let feature): message = "User (\(userId)) is not in any variation or rollout rule. Returning default value for all variables of feature flag (\(feature))." case .featureNotEnabledReturnDefaultVariableValue(let userId, let feature, let variable): message = "Feature (\(feature)) is not enabled for user (\(userId)). Returning the default variable value (\(variable))." @@ -140,6 +148,8 @@ extension LogMessage: CustomStringConvertible { case .failedToAssignValue: message = "Value for path could not be assigned to provided type." case .valueForKeyNotFound(let key): message = "Value for JSON key (\(key)) not found." case .lowPeriodicDownloadInterval: message = "Polling intervals below 30 seconds are not recommended." + case .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment: \(key)." + case .cmabNotSupportedInSyncMode: message = "CMAB is not supported in sync mode." } return message diff --git a/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift b/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift new file mode 100644 index 00000000..7fbf5315 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift @@ -0,0 +1,189 @@ +// +// 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 BucketTests_BucketToEntity: XCTestCase { + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var bucketer: DefaultBucketer! + + var kUserId = "12345" + var kGroupId = "333333" + var kExperimentId = "444444" + + var kExperimentKey = "countryExperiment" + + var kVariationKeyA = "a" + var kVariationKeyB = "b" + var kVariationKeyC = "c" + var kVariationKeyD = "d" + + var kVariationIdA = "a11" + var kVariationIdB = "b11" + var kVariationIdC = "c11" + var kVariationIdD = "d11" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var experiment: Experiment! + + // MARK: - Sample datafile data + + var sampleExperimentData: [String: Any] { return + [ + "status": "Running", + "id": kExperimentId, + "key": kExperimentKey, + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": kVariationIdA, "endOfRange": 2500], + ["entityId": kVariationIdB, "endOfRange": 5000], + ["entityId": kVariationIdC, "endOfRange": 7500], + ["entityId": kVariationIdD, "endOfRange": 10000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": kVariationIdA, + "key": kVariationKeyA + ], + [ + "variables": [], + "id": kVariationIdB, + "key": kVariationKeyB + ], + [ + "variables": [], + "id": kVariationIdC, + "key": kVariationKeyC + ], + [ + "variables": [], + "id": kVariationIdD, + "key": kVariationKeyD + ] + ], + "forcedVariations": [:] + ] + } + + var sampleGroupData: [String: Any] { return + ["id": kGroupId, + "policy": "random", + "trafficAllocation": [ + ["entityId": kExperimentId, "endOfRange": 10000] + ], + "experiments": [sampleExperimentData] + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config + self.bucketer = ((optimizely.decisionService as! DefaultDecisionService).bucketer as! DefaultBucketer) + } + + func testBucketToEntityWithEmptyGroup() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + self.config.project.groups = [] + + let fullAllocation = TrafficAllocation(entityId: "entity_123", endOfRange: 10000) + let bucketedEntityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: "id_123", trafficAllocation: [fullAllocation]).result + XCTAssertEqual(bucketedEntityId, "entity_123") + + let zeroAllocation = TrafficAllocation(entityId: "entity_123", endOfRange: 0) + let nilEntityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: "id_123", trafficAllocation: [zeroAllocation]).result + XCTAssertEqual(nilEntityId, nil) + } + + func testBucketToEntityWithGroupMatched() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + let group: Group = try! OTUtils.model(from: sampleGroupData) + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + var entityId: String! + + for (idx, test) in tests.enumerated() { + entityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]).result + XCTAssertEqual(test["expect"], entityId, "test[\(idx)] failed") + } + } + + func testBucketToEntityWithGroupNotMatched() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + var group: Group = try! OTUtils.model(from: sampleGroupData) + group.trafficAllocation[0].endOfRange = 0 + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + for (_, test) in tests.enumerated() { + let response: DecisionResponse = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]) + XCTAssertEqual(response.result, nil) + } + } + + func testBucketToEntityWithNoRandoomGroup() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + var group: Group = try! OTUtils.model(from: sampleGroupData) + group.policy = .overlapping + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + var entityId: String! + + for (idx, test) in tests.enumerated() { + entityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]).result + XCTAssertEqual(test["expect"], entityId, "test[\(idx)] failed") + } + } + +} diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift index 10c042cc..f79d2f16 100644 --- a/Tests/OptimizelyTests-Common/CmabServiceTests.swift +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -462,3 +462,198 @@ class DefaultCmabServiceTests: XCTestCase { } } +extension DefaultCmabServiceTests { + func testSyncFetchDecision() { + cmabClient.fetchDecisionResult = .success("variation-123") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "variation-123") + XCTAssertEqual(self.cmabClient.lastRuleId, "exp-123") + XCTAssertEqual(self.cmabClient.lastUserId, "test-user") + XCTAssertEqual(self.cmabClient.lastAttributes?.count, 2) + XCTAssertEqual(self.cmabClient.lastAttributes?["age"] as? Int, 25) + XCTAssertEqual(self.cmabClient.lastAttributes?["location"] as? String, "San Francisco") + + // Verify it was cached + let cacheKey = "9-test-user-exp-123" + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncCachedDecision() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "cached-variation") + XCTAssertEqual(decision.cmabUUID, "cached-uuid") + XCTAssertFalse(self.cmabClient.fetchDecisionCalled, "Should not call API when cache hit") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncFailedFetch() { + let testError = CmabClientError.fetchFailed("Test error") + cmabClient.fetchDecisionResult = .failure(testError) + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success: + XCTFail("Expected failure but got success") + + case .failure(let error): + XCTAssertEqual((error as? CmabClientError)?.message, "Test error") + + // Verify no caching of failed results + let cacheKey = "9-test-user-exp-123" + XCTAssertNil(self.cmabCache.lookup(key: cacheKey)) + } + } + + func testSyncIgnoreCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.ignoreCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should always call API when ignoreCmabCache option is set") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncResetCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + // Also add another item to verify it's cleared + let otherCacheKey = "other-key" + cmabCache.save(key: otherCacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.resetCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after resetting cache") + + // Verify the entire cache was reset + XCTAssertNil(self.cmabCache.lookup(key: otherCacheKey)) + + // But the new decision should be cached + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncInvalidateUserCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let userCacheKey = "9-test-user-exp-123" + let otherUserCacheKey = "other-user-key" + + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + + // Cache for both current user and another user + cmabCache.save(key: userCacheKey, value: cacheValue) + cmabCache.save(key: otherUserCacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.invalidateUserCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after invalidating user cache") + + // Verify only the specific user's cache was invalidated + XCTAssertNotNil(self.cmabCache.lookup(key: otherUserCacheKey), "Other users' cache should remain intact") + + // The new decision should be cached for the current user + XCTAssertNotNil(self.cmabCache.lookup(key: userCacheKey)) + XCTAssertEqual(self.cmabCache.lookup(key: userCacheKey)?.variationId, "new-variation") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + +} diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index 157bed45..d4330d7e 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1251,7 +1251,7 @@ class FakeDecisionService: DefaultDecisionService { var variation: Variation? var source: String! - override init(userProfileService: OPTUserProfileService) { + override init(userProfileService: OPTUserProfileService, cmabService: CmabService = DefaultCmabService.createDefault()) { super.init(userProfileService: DefaultUserProfileService()) } @@ -1267,7 +1267,7 @@ class FakeDecisionService: DefaultDecisionService { return DecisionResponse.responseNoReasons(result: featureDecision) } - override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, opType: OPType = .sync, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { guard let experiment = self.experiment, let tmpVariation = self.variation else { return DecisionResponse.nilNoReasons() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index 210b2ffb..fbced002 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -704,6 +704,158 @@ extension DecisionServiceTests_Experiments { } } +// MARK: - CMAB + +extension DecisionServiceTests_Experiments { + func testGetVariationWithCMABTrafficAllocation() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "10389729780" // kVariationKeyA + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + opType: .async, + userProfileTracker: nil) + let variation = decision.result?.variation + XCTAssertNotNil(variation) + XCTAssertEqual(variation?.key, kVariationKeyA) + } + + func testGetVariationWithCMABZeroTrafficAllocation() { + // Test when traffic allocation is 0% + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: [ + "trafficAllocation": 0, // 0% traffic + "attributeIds": ["10389729780"] + ]) + self.config.project.experiments = [cmabExperiment] + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.userNotInCmabExperiment(user.userId, cmabExperiment.key)) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + opType: .async, + userProfileTracker: nil) + XCTAssertNil(decision.result, "Should return nil for 0% traffic allocation") + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testGetVariationWithCMABFetchError() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: [ + "trafficAllocation": 10000, + "attributeIds": ["10389729780"] + ]) + self.config.project.experiments = [cmabExperiment] + + let mockCmabService = MockCmabService() + mockCmabService.error = CmabClientError.fetchFailed("Test error") + + self.decisionService = DefaultDecisionService( + userProfileService: DefaultUserProfileService(), + cmabService: mockCmabService + ) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.cmabFetchFailed(cmabExperiment.key)) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + opType: .async, + userProfileTracker: nil) + + XCTAssertNotNil(decision.result) + XCTAssertEqual(decision.result?.variation, nil) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testCmabNotSupportedInSyncMode() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "10389729780" // kVariationKeyA + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.cmabNotSupportedInSyncMode) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + opType: .sync, + userProfileTracker: nil) + XCTAssertNil(decision.result?.variation) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testGetVariationWhenUserHasNoVariation() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "unknown_var_id" + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + opType: .async, + userProfileTracker: nil) + XCTAssertNil(decision.result?.variation) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } +} + // MARK: - Test getBucketingId() extension DecisionServiceTests_Experiments { @@ -725,3 +877,25 @@ extension DecisionServiceTests_Experiments { } } + +fileprivate struct MockError: Error { + var message: String? +} + +fileprivate class MockCmabService: DefaultCmabService { + var error: Error? + var variationId: String? + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + if let variationId = self.variationId { + let cmabUUID = UUID().uuidString + return .success(CmabDecision(variationId: variationId, cmabUUID: cmabUUID)) + } else { + return .failure(self.error ?? MockError()) + } + } +} diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 6a4b3eeb..b14ea3bb 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -263,7 +263,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(pair?.experiment?.key == kExperimentKey) - XCTAssert(pair?.variation.key == kVariationKeyD) + XCTAssert(pair?.variation?.key == kVariationKeyD) XCTAssert(pair?.source == Constants.DecisionSource.featureTest.rawValue) } @@ -339,7 +339,7 @@ extension DecisionServiceTests_Features { XCTAssertEqual(mockProfileService.saveCount, 1) XCTAssertEqual(pair.count, 3) XCTAssert(pair[0].result?.experiment?.key == kExperimentKey) - XCTAssert(pair[0].result?.variation.key == kVariationKeyD) + XCTAssert(pair[0].result?.variation?.key == kVariationKeyD) XCTAssert(pair[0].result?.source == Constants.DecisionSource.featureTest.rawValue) } } @@ -361,7 +361,7 @@ extension DecisionServiceTests_Features { attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId) - XCTAssert(pair?.variation.key == kRolloutVariationKeyA) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyA) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -412,7 +412,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair?.variation.key == kRolloutVariationKeyC) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyC) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -427,7 +427,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge2Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId2) - XCTAssert(pair?.variation.key == kRolloutVariationKeyB) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyB) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -443,7 +443,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId) - XCTAssert(pair?.variation.key == kRolloutVariationKeyA) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyA) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -489,7 +489,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(pair?.experiment?.key == kExperimentKey) - XCTAssert(pair?.variation.key == kVariationKeyD) + XCTAssert(pair?.variation?.key == kVariationKeyD) } func testGetVariationForFeatureWhenExperimentNotMatchAndRolloutNotExist() { @@ -512,9 +512,9 @@ extension DecisionServiceTests_Features { attributes: kAttributesCountryNotMatch)).result if let pair = pair { XCTAssert(pair.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair.variation.key == kRolloutVariationKeyC) + XCTAssert(pair.variation!.key == kRolloutVariationKeyC) XCTAssert(pair.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair.variation.key == kRolloutVariationKeyC) + XCTAssert(pair.variation?.key == kRolloutVariationKeyC) } else { XCTFail() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index c18096c9..f7476354 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -365,7 +365,7 @@ extension DecisionServiceTests_Holdouts { XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -394,7 +394,7 @@ extension DecisionServiceTests_Holdouts { // Should fall back to experiment and bucket into variation D 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?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -415,7 +415,7 @@ extension DecisionServiceTests_Holdouts { // 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?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -433,7 +433,7 @@ extension DecisionServiceTests_Holdouts { // Should bucket into experiment XCTAssertNotNil(decision, "Decision should not personally identifiable informationnil") XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -453,7 +453,7 @@ extension DecisionServiceTests_Holdouts { // Should return holdout decision XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -473,7 +473,7 @@ extension DecisionServiceTests_Holdouts { // Should return holdout decision XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -494,7 +494,7 @@ extension DecisionServiceTests_Holdouts { // 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?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") } @@ -524,7 +524,7 @@ extension DecisionServiceTests_Holdouts { // Should select global holdout first (ordering: global > included) XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, globalHoldout.id, "Should select global holdout first") - XCTAssertEqual(decision?.variation.key, "global_variation") + XCTAssertEqual(decision?.variation?.key, "global_variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) } @@ -548,7 +548,7 @@ extension DecisionServiceTests_Holdouts { // Global holdout fails bucketing, should select included holdout XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, includedHoldout.id, "Should select included holdout") - XCTAssertEqual(decision?.variation.key, "included_variation") + XCTAssertEqual(decision?.variation?.key, "included_variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) } @@ -572,7 +572,7 @@ extension DecisionServiceTests_Holdouts { // All holdouts fail, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -592,7 +592,7 @@ extension DecisionServiceTests_Holdouts { // Holdout has no traffic allocation, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -638,7 +638,7 @@ extension DecisionServiceTests_Holdouts { // Holdout has no variations, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -667,8 +667,8 @@ extension DecisionServiceTests_Holdouts { XCTAssertNotNil(decision2) XCTAssertEqual(decision1?.experiment?.id, includedHoldout.id) XCTAssertEqual(decision2?.experiment?.id, includedHoldout.id) - XCTAssertEqual(decision1?.variation.key, "included_variation") - XCTAssertEqual(decision2?.variation.key, "included_variation") + XCTAssertEqual(decision1?.variation?.key, "included_variation") + XCTAssertEqual(decision2?.variation?.key, "included_variation") XCTAssertEqual(decision1?.source, Constants.DecisionSource.holdout.rawValue) XCTAssertEqual(decision2?.source, Constants.DecisionSource.holdout.rawValue) } diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift new file mode 100644 index 00000000..70dc3d6b --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift @@ -0,0 +1,511 @@ +// +// 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 OptimizelyUserContextTests_Decide_Async: XCTestCase { + + let kUserId = "tester" + + var optimizely: OptimizelyClient! + var eventDispatcher = MockEventDispatcher() + var decisionService: DefaultDecisionService! + var ups: OPTUserProfileService! + + override func setUp() { + super.setUp() + + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + decisionService = (optimizely.decisionService as! DefaultDecisionService) + ups = decisionService.userProfileService + try! optimizely.start(datafile: datafile) + } + + func testDecideAsync() { + let expectation = XCTestExpectation(description: "Async decision completed") + let featureKey = "feature_2" + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(key: featureKey) { decision in + XCTAssertEqual(decision.variationKey, "variation_with_traffic") + XCTAssertTrue(decision.enabled) + XCTAssertTrue(NSDictionary(dictionary: decision.variables.toMap()).isEqual(to: variablesExpected.toMap())) + XCTAssertEqual(decision.ruleKey, "exp_no_audience") + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + XCTAssert(decision.reasons.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + } + + func testDecideAsyncCompletionOrder() { + let expectation = XCTestExpectation(description: "Async decision completed") + let featureKey = "feature_2" + let user = optimizely.createUserContext(userId: kUserId) + var operationOrder: [String] = [] + + operationOrder.append("before") + + user.decideAsync(key: featureKey) { decision in + operationOrder.append("during") + expectation.fulfill() + } + operationOrder.append("after") + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(operationOrder, ["before", "after", "during"]) + } + + func testDecideForKeys_twoFeaturesAsync() { + // Create expectation + let expectation = XCTestExpectation(description: "Multiple features decision completed") + + // Setup test data + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + // Make async decision + user.decideAsync(keys: featureKeys) { decisions in + // Verify number of decisions + XCTAssertEqual(decisions.count, 2) + + // Verify first feature decision + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + // Verify second feature decision + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + // Wait for async operation to complete + wait(for: [expectation], timeout: 1) + } + + //MARK: - Decide All Async + + func testDecideAllAsync() { + let expectation = XCTestExpectation(description: "All decisions completed") + + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKey3 = "feature_3" + + 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"]) + + user.decideAllAsync { decisions in + XCTAssertEqual(decisions.count, 3) + + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + XCTAssertNotNil(decisions[featureKey3]) + XCTAssertEqual(decisions[featureKey3]!, OptimizelyDecision( + variationKey: nil, + enabled: false, + variables: variablesExpected3, + ruleKey: nil, + flagKey: featureKey3, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAllAsync_enabledOnly() { + let expectation = XCTestExpectation(description: "Enabled flags decisions completed") + + let featureKey1 = "feature_1" + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + user.decideAllAsync(options: [.enabledFlagsOnly]) { decisions in + XCTAssertEqual(decisions.count, 2) + + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +extension OptimizelyUserContextTests_Decide_Async { + + func testDecideAsync_sdkNotReady() { + let expectation = XCTestExpectation(description: "SDK not ready decision") + let featureKey = "feature_1" + + self.optimizely = OptimizelyClient(sdkKey: "12345", + userProfileService: OTUtils.createClearUserProfileService()) + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + XCTAssertNil(decision.ruleKey) + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAsync_sdkNotReady_optimizelyReleased() { + let expectation = XCTestExpectation(description: "SDK released decision") + let featureKey = "feature_1" + + var optimizelyClient: OptimizelyClient! = OptimizelyClient(sdkKey: OTUtils.randomSdkKey) + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + try! optimizelyClient.start(datafile: datafile) + + let user = optimizelyClient.createUserContext(userId: kUserId) + + // Release client to simulate weak reference becoming nil + optimizelyClient = nil + + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAsync_invalidFeatureKey() { + let expectation = XCTestExpectation(description: "Invalid feature key decision") + let featureKey = "invalid_key" + + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + XCTAssertNil(decision.ruleKey) + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.featureKeyInvalid(featureKey).reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + // MARK: - Decide For Keys Async + + func testDecideForKeysAsync_sdkNotReady() { + let expectation = XCTestExpectation(description: "SDK not ready multiple decisions") + let featureKeys = ["feature_1"] + + self.optimizely = OptimizelyClient(sdkKey: "12345", + userProfileService: OTUtils.createClearUserProfileService()) + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 0) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideForKeysAsync_sdkNotReady_optimizelyReleased() { + let expectation = XCTestExpectation(description: "SDK released multiple decisions") + let featureKeys = ["feature_1"] + + var optimizelyClient: OptimizelyClient! = OptimizelyClient(sdkKey: OTUtils.randomSdkKey) + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + try! optimizelyClient.start(datafile: datafile) + + let user = optimizelyClient.createUserContext(userId: kUserId) + + optimizelyClient = nil + + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 0) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideForKeysAsync_errorDecisionIncluded() { + let expectation = XCTestExpectation(description: "Error decision included") + let featureKey1 = "feature_2" + let featureKey2 = "invalid_key" + let featureKeys = [featureKey1, featureKey2] + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 2) + + XCTAssertEqual(decisions[featureKey1], OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_no_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + XCTAssertEqual(decisions[featureKey2], OptimizelyDecision.errorDecision( + key: featureKey2, + user: user, + error: .featureKeyInvalid(featureKey2) + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +extension OptimizelyUserContextTests_Decide_Async { + // MARK: - Concurrent Tests + + func testDecideAsync_multipleConcurrentRequests() { + let expectations = [ + XCTestExpectation(description: "First decision"), + XCTestExpectation(description: "Second decision"), + XCTestExpectation(description: "Third decision") + ] + + let featureKeys = ["feature_1", "feature_2", "feature_3"] + let user = optimizely.createUserContext(userId: kUserId) + + // Make concurrent requests + for (index, key) in featureKeys.enumerated() { + user.decideAsync(key: key) { _ in + expectations[index].fulfill() + } + } + + wait(for: expectations, timeout: 1) + } + + // MARK: - Memory Tests + + func testDecideAsync_memoryLeak() { + let expectation = XCTestExpectation(description: "Memory leak check") + weak var weakUser: OptimizelyUserContext? + + autoreleasepool { + let user = optimizely.createUserContext(userId: kUserId) + weakUser = user + + user.decideAsync(key: "feature_1") { _ in + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1) + XCTAssertNil(weakUser, "User context should be deallocated") + } + + // MARK: - Edge Cases + + func testDecideAsync_emptyFeatureKey() { + let expectation = XCTestExpectation(description: "Empty key decision") + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(key: "") { decision in + XCTAssertFalse(decision.enabled) + XCTAssertEqual(decision.reasons.first, OptimizelyError.featureKeyInvalid("").reason) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension OptimizelyUserContextTests_Decide_Async { + + func testDecideAsyncAwait() async { + let featureKey = "feature_2" + let variablesExpected = try! optimizely.getAllFeatureVariables( + featureKey: featureKey, + userId: kUserId + ) + + let user = optimizely.createUserContext(userId: kUserId) + let decision = await user.decideAsync(key: featureKey) + + XCTAssertEqual(decision.variationKey, "variation_with_traffic") + XCTAssertTrue(decision.enabled) + XCTAssertTrue(NSDictionary(dictionary: decision.variables.toMap()) + .isEqual(to: variablesExpected.toMap())) + XCTAssertEqual(decision.ruleKey, "exp_no_audience") + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + XCTAssert(decision.reasons.isEmpty) + } + + func testDecideForKeysAsyncAwait() async { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f"] + ) + + let decisions = await user.decideAsync(keys: featureKeys) + XCTAssertEqual(decisions.count, 2) + + XCTAssertEqual(decisions[featureKey1]?.variationKey, "a") + XCTAssertEqual(decisions[featureKey2]?.variationKey, "variation_with_traffic") + } + + func testDecideAllAsyncAwait() async { + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f"] + ) + + let decisions = await user.decideAllAsync() + XCTAssertEqual(decisions.count, 3) + + XCTAssertEqual(decisions["feature_1"]?.variationKey, "a") + XCTAssertEqual(decisions["feature_2"]?.variationKey, "variation_with_traffic") + XCTAssertNil(decisions["feature_3"]?.variationKey) + } + + func testDecideAsyncAwait_sdkNotReady() async { + self.optimizely = OptimizelyClient(sdkKey: "12345") + let user = optimizely.createUserContext(userId: kUserId) + + let decision = await user.decideAsync(key: "feature_1") + XCTAssertNil(decision.variationKey) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + } +} + +fileprivate class MockCmabService: DefaultCmabService { + var variationId: String? + var error: Error? + var decisionCalled = false + var decisionCallCount = 0 + var lastRuleKey: String? + var ignoreCacheUsed = false + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + decisionCalled = true + decisionCallCount += 1 + lastRuleKey = ruleId + ignoreCacheUsed = options.contains(.ignoreCmabCache) + + if let error = error { + return .failure(error) + } + + if let variationId = variationId { + return .success(CmabDecision( + variationId: variationId, + cmabUUID: "test-uuid" + )) + } + + return .failure(CmabClientError.fetchFailed("No variation set")) + } +} + diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift new file mode 100644 index 00000000..8e46eb03 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -0,0 +1,287 @@ +// +// 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 OptimizelyUserContextTests_Decide_CMAB: XCTestCase { + + let kUserId = "tester" + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var eventDispatcher = MockEventDispatcher() + var decisionService: DefaultDecisionService! + fileprivate var mockCmabService: MockCmabService! + + override func setUp() { + super.setUp() + + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + mockCmabService = MockCmabService() + decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mockCmabService) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + optimizely.decisionService = decisionService + self.config = self.optimizely.config + try! optimizely.start(datafile: datafile) + } + + override func tearDown() { + optimizely = nil + mockCmabService = nil + decisionService = nil + } + + func testDecideAsync_withCmabExperiment() { + let expectation = XCTestExpectation(description: "CMAB decision completed") + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + // Run the decision + user.decideAsync(key: "feature_1") { decision in + // Verify decision + XCTAssertEqual(decision.variationKey, "a", "Expected variation key 'a' but got \(String(describing: decision.variationKey))") + XCTAssertTrue(decision.enabled, "Expected feature to be enabled but was disabled") + XCTAssertEqual(decision.ruleKey, "exp_with_audience", "Expected rule id 'exp_with_audience' but got \(String(describing: decision.ruleKey))") + + // Verify CMAB service was called + XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") + XCTAssertEqual(self.mockCmabService.lastRuleId, "10390977673", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) // Increased timeout for reliability + } + + func testDecideAsync_multipleCmabExperiments() { + let expectation = XCTestExpectation(description: "CMAB decision completed") + + // Set up multiple CMAB experiments + // First experiment with zero traffic allocation - user won't be bucketed into this experiment + let cmab1: Cmab = try! OTUtils.model(from: ["trafficAllocation": 0, "attributeIds": ["10389729780"]]) + // Second experiment with full traffic allocation - user should be bucketed into this experiment + let cmab2: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10418551353"]]) + + // Update project configuration with CMAB experiments + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab1 + experiments[1].cmab = cmab2 + optimizely.config?.project.experiments = experiments + + // Configure mock CMAB service to return specific variation + mockCmabService.variationId = "10418551353" // corresponds to variation "a" + + // Define feature keys to test with + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + // Pre-fetch expected variable values for validation + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + + // Create test user context with attributes + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + // Test multiple decisions with decideAsync + user.decideAsync(keys: featureKeys, options: [.ignoreUserProfileService]) { decisions in + + // Verify correct number of decisions were returned + XCTAssertEqual(decisions.count, 2) + + // Verify CMAB service was called the correct number of times + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + + // Verify first feature decision matches expected values + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "18257766532", + enabled: true, + variables: variablesExpected1, + ruleKey: "18322080788", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + // Verify second feature decision matches expected values + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + // Verify CMAB service was correctly called with the expected parameters + XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") + XCTAssertEqual(self.mockCmabService.lastRuleId, "10420810910", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + + expectation.fulfill() + } + + // Wait for async operations to complete + wait(for: [expectation], timeout: 5) // Increased timeout for reliability + } + + func testDecideAsync_cmabWithUserProfileCahing() { + let expectation1 = XCTestExpectation(description: "First CMAB decision") + let expectation2 = XCTestExpectation(description: "Second CMAB decision") + + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + // First decision cache into user profile + user.decideAsync(key: "feature_1") { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + expectation1.fulfill() + + // Second decision (should use cache) + user.decideAsync(key: "feature_1") { decision in + XCTAssertEqual(decision.variationKey, "a") + // Call count should still be 1 (cached) + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1) + } + + func testDecideAsync_cmabCacheOptions() { + let exp1 = XCTestExpectation(description: "First call") + let exp2 = XCTestExpectation(description: "Second call") + let exp3 = XCTestExpectation(description: "Third call") + + + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .ignoreCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.ignoreCacheUsed) + exp1.fulfill() + } + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .resetCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.resetCacheCache) + exp2.fulfill() + } + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .invalidateUserCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.invalidateUserCmabCache) + exp3.fulfill() + } + wait(for: [exp1, exp2, exp3], timeout: 1) + + } + + func testDecideAsync_cmabError() { + let expectation = XCTestExpectation(description: "CMAB error handling") + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + mockCmabService.error = CmabClientError.fetchFailed("Test error") + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .includeReasons]) { decision in + XCTAssertTrue(decision.reasons.contains(LogMessage.cmabFetchFailed("exp_with_audience").reason)) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + +} + +fileprivate class MockCmabService: DefaultCmabService { + var variationId: String? + var error: Error? + var decisionCalled = false + var decisionCallCount = 0 + var lastRuleId: String? + var ignoreCacheUsed = false + var resetCacheCache = false + var invalidateUserCmabCache = false + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + decisionCalled = true + lastRuleId = ruleId + ignoreCacheUsed = options.contains(.ignoreCmabCache) + resetCacheCache = options.contains(.resetCmabCache) + invalidateUserCmabCache = options.contains(.invalidateUserCmabCache) + decisionCallCount += 1 + if let error = error { + return .failure(error) + } + + if let variationId = variationId { + return .success(CmabDecision( + variationId: variationId, + cmabUUID: "test-uuid" + )) + } + + return .failure(CmabClientError.fetchFailed("No variation set")) + } +}