From b40adda25834b1180f5e7d9a8829b62abdcecb52 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 18 Jun 2025 20:40:54 +0600 Subject: [PATCH 01/20] wip: cmab client done --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 48 ++++ Sources/CMAB/CmabClient.swift | 196 +++++++++++++++ .../CMABClientTests.swift | 230 ++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 Sources/CMAB/CmabClient.swift create mode 100644 Tests/OptimizelyTests-Common/CMABClientTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 62dee985..bc8c718c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2092,6 +2092,24 @@ 98F28A2C2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; 98F28A2E2E01968000A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; 98F28A3E2E01AC0700A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2545,6 +2563,8 @@ 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; 98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = ""; }; 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; + 98F28A402E02DD6D00A86546 /* CmabClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabClient.swift; sourceTree = ""; }; + 98F28A512E02E81500A86546 /* CMABClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMABClientTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2781,6 +2801,7 @@ 6E75165D22C520D400B2B157 /* Sources */ = { isa = PBXGroup; children = ( + 98F28A3F2E02DD4D00A86546 /* CMAB */, 6E75166622C520D400B2B157 /* Optimizely */, 6EC6DD3F24ABF8180017D296 /* Optimizely+Decide */, 6E75165E22C520D400B2B157 /* Customization */, @@ -3076,6 +3097,7 @@ 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, + 98F28A512E02E81500A86546 /* CMABClientTests.swift */, 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, @@ -3244,6 +3266,14 @@ name = Frameworks; sourceTree = ""; }; + 98F28A3F2E02DD4D00A86546 /* CMAB */ = { + isa = PBXGroup; + children = ( + 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + ); + path = CMAB; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4315,6 +4345,7 @@ 6E14CD842423F9A100010234 /* BatchEventBuilder.swift in Sources */, 6E14CD6E2423F85E00010234 /* EventDispatcherTests_Batch.swift in Sources */, 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1F24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E14CD882423F9A100010234 /* AttributeValue.swift in Sources */, 84E2E9492852A378001114AB /* VuidManager.swift in Sources */, @@ -4381,6 +4412,7 @@ 6E424CF726324B620081004A /* DefaultDecisionService.swift in Sources */, 6E424CF826324B620081004A /* DecisionReasons.swift in Sources */, 6E424CF926324B620081004A /* DecisionResponse.swift in Sources */, + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E2E9782855875E001114AB /* OdpEventManager.swift in Sources */, 6E424CFA26324B620081004A /* DataStoreMemory.swift in Sources */, 6E424CFB26324B620081004A /* DataStoreUserDefaults.swift in Sources */, @@ -4519,6 +4551,7 @@ 6E75177322C520D400B2B157 /* SDKVersion.swift in Sources */, 6E75179722C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7518DD22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75187D22C520D400B2B157 /* TrafficAllocation.swift in Sources */, 98F28A252E01940500A86546 /* Cmab.swift in Sources */, C78CAFA524486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -4636,6 +4669,7 @@ 6E75195C22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518E422C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE2424BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E75183022C520D400B2B157 /* BatchEvent.swift in Sources */, 84E2E94E2852A378001114AB /* VuidManager.swift in Sources */, @@ -4692,6 +4726,7 @@ 6E7517CC22C520D400B2B157 /* DefaultBucketer.swift in Sources */, 6E75178E22C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E75172E22C520D400B2B157 /* Constants.swift in Sources */, + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E7ABC327D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B11E022C548A200C22D81 /* OptimizelyClientTests_Group.swift in Sources */, 6E75187422C520D400B2B157 /* Variation.swift in Sources */, @@ -4819,6 +4854,7 @@ 6E75175522C520D400B2B157 /* LogMessage.swift in Sources */, C78CAF602445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E623F0B253F9045000617D0 /* DecisionInfo.swift in Sources */, + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75193722C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75191322C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 84E7ABC627D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, @@ -4951,6 +4987,7 @@ 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75194522C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185522C520D400B2B157 /* ProjectConfig.swift in Sources */, 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, @@ -5002,6 +5039,7 @@ 6E7518CD22C520D400B2B157 /* Audience.swift in Sources */, 980CC90C2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E96E28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E9B117322C5487100C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11B622C5489600C22D81 /* OTUtils.swift in Sources */, 6E75183122C520D400B2B157 /* BatchEvent.swift in Sources */, @@ -5188,6 +5226,7 @@ 6E7516F822C520D400B2B157 /* OptimizelyError.swift in Sources */, 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 848617E82863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E424C09263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E75189E22C520D400B2B157 /* Experiment.swift in Sources */, 6E75178822C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, @@ -5237,6 +5276,7 @@ 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75190322C520D500B2B157 /* Attribute.swift in Sources */, 6E75192722C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516F122C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -5288,6 +5328,7 @@ 6E7517BF22C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E9B115922C5486E00C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11AA22C5489200C22D81 /* OTUtils.swift in Sources */, + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518D322C520D400B2B157 /* AttributeValue.swift in Sources */, 6E0A72D426C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift in Sources */, 6EF41A332522BE1900EAADF1 /* OptimizelyUserContextTests_Decide.swift in Sources */, @@ -5414,6 +5455,7 @@ 6E5D12242638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 6E75183922C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E9B118A22C5488100C22D81 /* ExperimentTests.swift in Sources */, + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7516E722C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75181522C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EF8DE2124BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5522,6 +5564,7 @@ 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E94C2852A378001114AB /* VuidManager.swift in Sources */, 6E7518FA22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5629,6 +5672,7 @@ 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E9512852A378001114AB /* VuidManager.swift in Sources */, 6E7518FF22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5744,6 +5788,7 @@ 6E75188822C520D400B2B157 /* Project.swift in Sources */, 6E7518D022C520D400B2B157 /* AttributeValue.swift in Sources */, 6E75181C22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518DC22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 98F28A1F2E01940500A86546 /* Cmab.swift in Sources */, C78CAFA424486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -5861,6 +5906,7 @@ 6E75188A22C520D400B2B157 /* Project.swift in Sources */, 6E75195622C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1D24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7518EA22C520D400B2B157 /* ConditionHolder.swift in Sources */, 84E2E9462852A378001114AB /* VuidManager.swift in Sources */, @@ -5993,6 +6039,7 @@ 75C71A4125E454460084187E /* MurmurHash3.swift in Sources */, 848617ED2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 848617FE286CF33700B7F41B /* OdpEvent.swift in Sources */, + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */, 75C71A4225E454460084187E /* HandlerRegistryService.swift in Sources */, 75C71A4325E454460084187E /* LogMessage.swift in Sources */, 75C71A4425E454460084187E /* AtomicProperty.swift in Sources */, @@ -6049,6 +6096,7 @@ BD6485572491474500F30986 /* Project.swift in Sources */, BD6485582491474500F30986 /* AttributeValue.swift in Sources */, BD6485592491474500F30986 /* BatchEventBuilder.swift in Sources */, + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */, BD64855A2491474500F30986 /* ConditionLeaf.swift in Sources */, 98F28A222E01940500A86546 /* Cmab.swift in Sources */, BD64855B2491474500F30986 /* OptimizelyJSON+ObjC.swift in Sources */, diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift new file mode 100644 index 00000000..61e8b659 --- /dev/null +++ b/Sources/CMAB/CmabClient.swift @@ -0,0 +1,196 @@ +// +// 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 Foundation + +enum CmabClientError: Error, Equatable { + case fetchFailed(String) + case invalidResponse + case decodingError + + var message: String { + switch self { + case .fetchFailed(let message): + return message + case .invalidResponse: + return "Invalid response from CMA-B server" + case .decodingError: + return "Error decoding CMA-B response" + } + } +} + +struct CmabRetryConfig { + var maxRetries: Int = 3 + var initialBackoff: TimeInterval = 0.1 // seconds + var maxBackoff: TimeInterval = 10.0 // seconds + var backoffMultiplier: Double = 2.0 +} + +protocol CmabClient { + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) +} + +class DefaultCmabClient: CmabClient { + let session: URLSession + let retryConfig: CmabRetryConfig + let maxWaitTime: TimeInterval + let cmabQueue = DispatchQueue(label: "com.optimizley.cmab") + let logger = OPTLoggerFactory.getLogger() + + init(session: URLSession = .shared, + retryConfig: CmabRetryConfig = CmabRetryConfig(), + maxWaitTime: TimeInterval = 10.0 + ) { + self.session = session + self.retryConfig = retryConfig + self.maxWaitTime = maxWaitTime + } + + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) { + let urlString = "https://prediction.cmab.optimizely.com/predict/\(ruleId)" + guard let url = URL(string: urlString) else { + completion(.failure(CmabClientError.fetchFailed("Invalid URL"))) + return + } + let attrType = "custom_attribute" + let cmabAttributes = attributes.map { (key, value) in + ["id": key, "value": value, "type": attrType] + } + + let requestBody: [String: Any] = [ + "instances": [[ + "visitorId": userId, + "experimentId": ruleId, + "attributes": cmabAttributes, + "cmabUUID": cmabUUID + ]] + ] + + doFetchWithRetry( + url: url, + requestBody: requestBody, + timeout: maxWaitTime, + completion: completion + ) + } + + private func doFetchWithRetry( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var attempt = 0 + var backoff = retryConfig.initialBackoff + + func attemptFetch() { + doFetch(url: url, requestBody: requestBody, timeout: timeout) { result in + switch result { + case .success(let variationId): + completion(.success(variationId)) + case .failure(let error): + self.logger.e((error as? CmabClientError)?.message ?? "") + if let cmabError = error as? CmabClientError { + if case .invalidResponse = cmabError { + // Don't retry on invalid response + completion(.failure(cmabError)) + return + } + } + if attempt < self.retryConfig.maxRetries { + attempt += 1 + self.cmabQueue.asyncAfter(deadline: .now() + backoff) { + backoff = min(backoff * pow(self.retryConfig.backoffMultiplier, Double(attempt)), self.retryConfig.maxBackoff) + attemptFetch() + } + } else { + completion(.failure(CmabClientError.fetchFailed("Exhausted all retries for CMAB request. Last error: \(error)"))) + } + } + } + } + attemptFetch() + } + + private func doFetch( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else { + completion(.failure(CmabClientError.fetchFailed("Failed to encode request body"))) + return + } + request.httpBody = httpBody + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(CmabClientError.fetchFailed(error.localizedDescription))) + return + } + guard let httpResponse = response as? HTTPURLResponse, let data = data, (200...299).contains(httpResponse.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + completion(.failure(CmabClientError.fetchFailed("HTTP error code: \(code)"))) + return + } + do { + if + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + self.validateResponse(body: json), + let predictions = json["predictions"] as? [[String: Any]], + let variationId = predictions.first?["variation_id"] as? String + { + completion(.success(variationId)) + } else { + completion(.failure(CmabClientError.invalidResponse)) + } + } catch { + completion(.failure(CmabClientError.decodingError)) + } + } + task.resume() + } + + private func validateResponse(body: [String: Any]) -> Bool { + if + let predictions = body["predictions"] as? [[String: Any]], + predictions.count > 0, + predictions.first?["variation_id"] != nil + { + return true + } + return false + } +} diff --git a/Tests/OptimizelyTests-Common/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift new file mode 100644 index 00000000..bcb768f2 --- /dev/null +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -0,0 +1,230 @@ +// +// 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 DefaultCmabClientTests: XCTestCase { + var client: DefaultCmabClient! + var mockSession: MockURLSession! + + override func setUp() { + super.setUp() + mockSession = MockURLSession() + client = DefaultCmabClient(session: mockSession) + } + + override func tearDown() { + client = nil + mockSession = nil + super.tearDown() + } + + func testFetchDecisionSuccess() { + let expectedVariationId = "variation-123" + let responseJSON: [String: Any] = [ + "predictions": [ + ["variation_id": expectedVariationId] + ] + ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + mockSession.nextData = responseData + mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil) + mockSession.nextError = nil + + let expectation = self.expectation(description: "Completion called") + + client.fetchDecision( + ruleId: "abc", + userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + switch result { + case .success(let variationId): + XCTAssertEqual(variationId, expectedVariationId) + case .failure(let error): + XCTFail("Expected success, got failure: \(error)") + } + expectation.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testFetchDecisionHttpError() { + mockSession.nextData = Data() + mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil) + mockSession.nextError = nil + + let expectation = self.expectation(description: "Completion called") + + client.fetchDecision( + ruleId: "abc", + userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + switch result { + case .success(_): + XCTFail("Expected failure, got success") + case .failure(let error): + XCTAssertTrue("\(error)".contains("HTTP error code")) + } + expectation.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testFetchDecisionInvalidJson() { + mockSession.nextData = Data("not a json".utf8) + mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil) + mockSession.nextError = nil + + let expectation = self.expectation(description: "Completion called") + + client.fetchDecision( + ruleId: "abc", + userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + switch result { + case .success(_): + XCTFail("Expected failure, got success") + case .failure(let error): + XCTAssertTrue(error is CmabClientError) + } + expectation.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testFetchDecisionInvalidResponseStructure() { + let responseJSON: [String: Any] = [ + "not_predictions": [] + ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + mockSession.nextData = responseData + mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil) + mockSession.nextError = nil + + let expectation = self.expectation(description: "Completion called") + + client.fetchDecision( + ruleId: "abc", + userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + switch result { + case .success(_): + XCTFail("Expected failure, got success") + case .failure(let error): + XCTAssertEqual(error as? CmabClientError, .invalidResponse) + } + expectation.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testFetchDecisionRetriesOnFailure() { + let expectedVariationId = "variation-retry" + var callCount = 0 + + let responseJSON: [String: Any] = [ + "predictions": [ + ["variation_id": expectedVariationId] + ] + ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + + mockSession.onRequest = { _ in + callCount += 1 + if callCount == 1 { + self.mockSession.nextData = Data() + self.mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil) + self.mockSession.nextError = nil + } else { + self.mockSession.nextData = responseData + self.mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil) + self.mockSession.nextError = nil + } + } + + let expectation = self.expectation(description: "Completion called") + + client.fetchDecision( + ruleId: "abc", + userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + switch result { + case .success(let variationId): + XCTAssertEqual(variationId, expectedVariationId) + XCTAssertTrue(callCount >= 2) + case .failure(let error): + XCTFail("Expected success, got failure: \(error)") + } + expectation.fulfill() + } + + waitForExpectations(timeout: 3, handler: nil) + } +} + +extension DefaultCmabClientTests { + class MockURLSessionDataTask: URLSessionDataTask { + private let closure: () -> Void + override var state: URLSessionTask.State { .completed } + init(closure: @escaping () -> Void) { + self.closure = closure + } + + override func resume() { + closure() + } + } + + class MockURLSession: URLSession { + typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void + + var nextData: Data? + var nextResponse: URLResponse? + var nextError: Error? + var onRequest: ((URLRequest) -> Void)? + + override func dataTask( + with request: URLRequest, + completionHandler: @escaping CompletionHandler + ) -> URLSessionDataTask { + onRequest?(request) + return MockURLSessionDataTask { + completionHandler(self.nextData, self.nextResponse, self.nextError) + } + } + } + +} From 9c9b9b05aacdecad150fed941926b1e4c101fd2b Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 18 Jun 2025 20:51:54 +0600 Subject: [PATCH 02/20] Update test cases --- Sources/CMAB/CmabClient.swift | 6 +- .../CMABClientTests.swift | 258 +++++++++--------- 2 files changed, 134 insertions(+), 130 deletions(-) diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift index 61e8b659..dda17086 100644 --- a/Sources/CMAB/CmabClient.swift +++ b/Sources/CMAB/CmabClient.swift @@ -19,7 +19,6 @@ import Foundation enum CmabClientError: Error, Equatable { case fetchFailed(String) case invalidResponse - case decodingError var message: String { switch self { @@ -27,8 +26,7 @@ enum CmabClientError: Error, Equatable { return message case .invalidResponse: return "Invalid response from CMA-B server" - case .decodingError: - return "Error decoding CMA-B response" + } } } @@ -177,7 +175,7 @@ class DefaultCmabClient: CmabClient { completion(.failure(CmabClientError.invalidResponse)) } } catch { - completion(.failure(CmabClientError.decodingError)) + completion(.failure(CmabClientError.invalidResponse)) } } task.resume() diff --git a/Tests/OptimizelyTests-Common/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift index bcb768f2..8f7b256e 100644 --- a/Tests/OptimizelyTests-Common/CMABClientTests.swift +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -19,212 +19,218 @@ import XCTest class DefaultCmabClientTests: XCTestCase { var client: DefaultCmabClient! var mockSession: MockURLSession! + var shortRetryConfig: CmabRetryConfig! override func setUp() { super.setUp() mockSession = MockURLSession() - client = DefaultCmabClient(session: mockSession) + shortRetryConfig = CmabRetryConfig(maxRetries: 2, initialBackoff: 0.01, maxBackoff: 0.05, backoffMultiplier: 1.0) + client = DefaultCmabClient(session: mockSession, retryConfig: shortRetryConfig) } override func tearDown() { client = nil mockSession = nil + shortRetryConfig = nil super.tearDown() } - func testFetchDecisionSuccess() { - let expectedVariationId = "variation-123" - let responseJSON: [String: Any] = [ + // MARK: - Helpers + + func makeSuccessResponse(variationId: String) -> (Data, URLResponse, Error?) { + let json: [String: Any] = [ "predictions": [ - ["variation_id": expectedVariationId] + ["variation_id": variationId] ] ] - let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) - mockSession.nextData = responseData - mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 200, httpVersion: nil, headerFields: nil) - mockSession.nextError = nil + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response, nil) + } + + func makeFailureResponse() -> (Data, URLResponse, Error?) { + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response, nil) + } + + // MARK: - Test Cases + + func testFetchDecision_SuccessOnFirstTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-123") + mockSession.responses = [(successData, successResponse, nil)] let expectation = self.expectation(description: "Completion called") - client.fetchDecision( - ruleId: "abc", - userId: "user1", - attributes: ["foo": "bar"], - cmabUUID: "uuid" + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" ) { result in - switch result { - case .success(let variationId): - XCTAssertEqual(variationId, expectedVariationId) - case .failure(let error): - XCTFail("Expected success, got failure: \(error)") + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-123") + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected success result") } expectation.fulfill() } - - waitForExpectations(timeout: 2, handler: nil) + waitForExpectations(timeout: 1) } - func testFetchDecisionHttpError() { - mockSession.nextData = Data() - mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 500, httpVersion: nil, headerFields: nil) - mockSession.nextError = nil + func testFetchDecision_SuccessOnSecondTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-retry") + let fail = makeFailureResponse() + mockSession.responses = [fail, (successData, successResponse, nil)] let expectation = self.expectation(description: "Completion called") - client.fetchDecision( - ruleId: "abc", - userId: "user1", - attributes: ["foo": "bar"], - cmabUUID: "uuid" + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" ) { result in - switch result { - case .success(_): - XCTFail("Expected failure, got success") - case .failure(let error): - XCTAssertTrue("\(error)".contains("HTTP error code")) + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-retry") + XCTAssertEqual(self.mockSession.callCount, 2) + } else { + XCTFail("Expected success after retry") } expectation.fulfill() } - - waitForExpectations(timeout: 2, handler: nil) + waitForExpectations(timeout: 2) } - func testFetchDecisionInvalidJson() { - mockSession.nextData = Data("not a json".utf8) - mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 200, httpVersion: nil, headerFields: nil) - mockSession.nextError = nil + func testFetchDecision_SuccessOnThirdTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "success-third") + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, (successData, successResponse, nil)] let expectation = self.expectation(description: "Completion called") - client.fetchDecision( - ruleId: "abc", - userId: "user1", - attributes: ["foo": "bar"], - cmabUUID: "uuid" + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" ) { result in - switch result { - case .success(_): - XCTFail("Expected failure, got success") - case .failure(let error): - XCTAssertTrue(error is CmabClientError) + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "success-third") + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected success after two retries") } expectation.fulfill() } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_ExhaustsAllRetries() { + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, fail] - waitForExpectations(timeout: 2, handler: nil) + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("Exhausted all retries")) + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected failure after all retries") + } + expectation.fulfill() + } + waitForExpectations(timeout: 2) } - func testFetchDecisionInvalidResponseStructure() { - let responseJSON: [String: Any] = [ - "not_predictions": [] + func testFetchDecision_HttpError() { + mockSession.responses = [ + (Data(), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil), nil) ] - let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) - mockSession.nextData = responseData - mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 200, httpVersion: nil, headerFields: nil) - mockSession.nextError = nil let expectation = self.expectation(description: "Completion called") - client.fetchDecision( - ruleId: "abc", - userId: "user1", - attributes: ["foo": "bar"], - cmabUUID: "uuid" + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" ) { result in - switch result { - case .success(_): - XCTFail("Expected failure, got success") - case .failure(let error): - XCTAssertEqual(error as? CmabClientError, .invalidResponse) + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("HTTP error code")) + } else { + XCTFail("Expected failure on HTTP error") } expectation.fulfill() } - - waitForExpectations(timeout: 2, handler: nil) + waitForExpectations(timeout: 2) } - func testFetchDecisionRetriesOnFailure() { - let expectedVariationId = "variation-retry" - var callCount = 0 - - let responseJSON: [String: Any] = [ - "predictions": [ - ["variation_id": expectedVariationId] - ] + func testFetchDecision_InvalidJson() { + mockSession.responses = [ + (Data("not a json".utf8), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) ] - let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) - mockSession.onRequest = { _ in - callCount += 1 - if callCount == 1 { - self.mockSession.nextData = Data() - self.mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 500, httpVersion: nil, headerFields: nil) - self.mockSession.nextError = nil + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue(error is CmabClientError) + XCTAssertEqual(self.mockSession.callCount, 1) } else { - self.mockSession.nextData = responseData - self.mockSession.nextResponse = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, - statusCode: 200, httpVersion: nil, headerFields: nil) - self.mockSession.nextError = nil + XCTFail("Expected failure on invalid JSON") } + expectation.fulfill() } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_Invalid_Response_Structure() { + let responseJSON: [String: Any] = [ "not_predictions": [] ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + mockSession.responses = [ + (responseData, HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) + ] let expectation = self.expectation(description: "Completion called") - client.fetchDecision( - ruleId: "abc", - userId: "user1", - attributes: ["foo": "bar"], - cmabUUID: "uuid" + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], cmabUUID: "uuid" ) { result in - switch result { - case .success(let variationId): - XCTAssertEqual(variationId, expectedVariationId) - XCTAssertTrue(callCount >= 2) - case .failure(let error): - XCTFail("Expected success, got failure: \(error)") + if case let .failure(error) = result { + XCTAssertEqual(error as? CmabClientError, .invalidResponse) + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected failure on invalid response structure") } expectation.fulfill() } - - waitForExpectations(timeout: 3, handler: nil) + waitForExpectations(timeout: 2) } } +// MARK: - MockURLSession for ordered responses + extension DefaultCmabClientTests { class MockURLSessionDataTask: URLSessionDataTask { private let closure: () -> Void override var state: URLSessionTask.State { .completed } - init(closure: @escaping () -> Void) { - self.closure = closure - } - - override func resume() { - closure() - } + init(closure: @escaping () -> Void) { self.closure = closure } + override func resume() { closure() } } class MockURLSession: URLSession { typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void - - var nextData: Data? - var nextResponse: URLResponse? - var nextError: Error? - var onRequest: ((URLRequest) -> Void)? - + var responses: [(Data?, URLResponse?, Error?)] = [] + var callCount = 0 + override func dataTask( with request: URLRequest, completionHandler: @escaping CompletionHandler ) -> URLSessionDataTask { - onRequest?(request) - return MockURLSessionDataTask { - completionHandler(self.nextData, self.nextResponse, self.nextError) - } + + let idx = callCount + callCount += 1 + let tuple = idx < responses.count ? responses[idx] : (nil, nil, nil) + return MockURLSessionDataTask { completionHandler(tuple.0, tuple.1, tuple.2) } } } - } From d998e0b63a4c788a7ed12a047a1946da455c4b8e Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 20 Jun 2025 19:39:00 +0600 Subject: [PATCH 03/20] wip: add test cases for cmab services --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 40 ++ Sources/CMAB/CmabClient.swift | 4 +- Sources/CMAB/CmabService.swift | 149 ++++++ .../OptimizelyDecideOption.swift | 9 + .../CmabServiceTests.swift | 464 ++++++++++++++++++ 5 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 Sources/CMAB/CmabService.swift create mode 100644 Tests/OptimizelyTests-Common/CmabServiceTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index bc8c718c..ae0344f1 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2110,6 +2110,24 @@ 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; + 98F28A562E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A572E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A582E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A592E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5A2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5B2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5C2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5D2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5E2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5F2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A602E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A612E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A622E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A632E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A642E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A652E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A672E05220300A86546 /* CmabServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A662E05220300A86546 /* CmabServiceTests.swift */; }; + 98F28A682E05220300A86546 /* CmabServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A662E05220300A86546 /* CmabServiceTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2565,6 +2583,8 @@ 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; 98F28A402E02DD6D00A86546 /* CmabClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabClient.swift; sourceTree = ""; }; 98F28A512E02E81500A86546 /* CMABClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMABClientTests.swift; sourceTree = ""; }; + 98F28A552E0451CC00A86546 /* CmabService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabService.swift; sourceTree = ""; }; + 98F28A662E05220300A86546 /* CmabServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabServiceTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -3098,6 +3118,7 @@ 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, 98F28A512E02E81500A86546 /* CMABClientTests.swift */, + 98F28A662E05220300A86546 /* CmabServiceTests.swift */, 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, @@ -3270,6 +3291,7 @@ isa = PBXGroup; children = ( 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + 98F28A552E0451CC00A86546 /* CmabService.swift */, ); path = CMAB; sourceTree = ""; @@ -4343,6 +4365,7 @@ 6E14CD752423F97600010234 /* OptimizelyConfig+ObjC.swift in Sources */, 6E14CD712423F96800010234 /* OptimizelyClient.swift in Sources */, 6E14CD842423F9A100010234 /* BatchEventBuilder.swift in Sources */, + 98F28A5E2E0451CC00A86546 /* CmabService.swift in Sources */, 6E14CD6E2423F85E00010234 /* EventDispatcherTests_Batch.swift in Sources */, 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -4416,6 +4439,7 @@ 84E2E9782855875E001114AB /* OdpEventManager.swift in Sources */, 6E424CFA26324B620081004A /* DataStoreMemory.swift in Sources */, 6E424CFB26324B620081004A /* DataStoreUserDefaults.swift in Sources */, + 98F28A652E0451CC00A86546 /* CmabService.swift in Sources */, 6E6522E4278E4F3800954EA1 /* OdpManager.swift in Sources */, 6E424CFC26324B620081004A /* DataStoreFile.swift in Sources */, 6E424CFD26324B620081004A /* DataStoreQueueStackImpl.swift in Sources */, @@ -4574,6 +4598,7 @@ C78CAF592445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 848617C92863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E7518E922C520D400B2B157 /* ConditionHolder.swift in Sources */, + 98F28A622E0451CC00A86546 /* CmabService.swift in Sources */, 6E75184122C520D400B2B157 /* Event.swift in Sources */, 6E7517C922C520D400B2B157 /* DefaultBucketer.swift in Sources */, 6E75181D22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, @@ -4667,6 +4692,7 @@ 6E7516DE22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75189022C520D400B2B157 /* Project.swift in Sources */, 6E75195C22C520D500B2B157 /* OPTBucketer.swift in Sources */, + 98F28A572E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518E422C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -4830,6 +4856,7 @@ C78CAF7424482C86009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift in Sources */, 6EC6DD3724ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, 6E7516E622C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 98F28A612E0451CC00A86546 /* CmabService.swift in Sources */, 6EF8DE3724BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6EC6DD4724ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E7518F822C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -4904,6 +4931,7 @@ 6E75188F22C520D400B2B157 /* Project.swift in Sources */, 6E75195B22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518E322C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A5D2E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518EF22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E75182F22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E75191F22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, @@ -4959,6 +4987,7 @@ 6E9B117422C5487100C22D81 /* DecisionServiceTests_Others.swift in Sources */, 6E9B116E22C5487100C22D81 /* LoggerTests.swift in Sources */, 84F6BADE27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, + 98F28A5C2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75180D22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E75178722C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E75179F22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, @@ -5007,6 +5036,7 @@ 6E75192D22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516D322C520D400B2B157 /* OPTLogger.swift in Sources */, 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, + 98F28A672E05220300A86546 /* CmabServiceTests.swift in Sources */, 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, 98F28A242E01940500A86546 /* Cmab.swift in Sources */, @@ -5153,6 +5183,7 @@ 6E9B119622C5488300C22D81 /* AudienceTests.swift in Sources */, 6E7518B622C520D400B2B157 /* Group.swift in Sources */, 6E7516D422C520D400B2B157 /* OPTLogger.swift in Sources */, + 98F28A602E0451CC00A86546 /* CmabService.swift in Sources */, 6E75183222C520D400B2B157 /* BatchEvent.swift in Sources */, 6E7518DA22C520D400B2B157 /* AttributeValue.swift in Sources */, 84640882281320F000CCF97D /* IntegrationTests.swift in Sources */, @@ -5248,6 +5279,7 @@ 6E9B115422C5486E00C22D81 /* LoggerTests.swift in Sources */, 6E7518DF22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E75172D22C520D400B2B157 /* Constants.swift in Sources */, + 98F28A5A2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75172122C520D400B2B157 /* OptimizelyResult.swift in Sources */, 6E75186722C520D400B2B157 /* Rollout.swift in Sources */, 6E424C01263228FD0081004A /* AtomicDictionary.swift in Sources */, @@ -5296,6 +5328,7 @@ 6E75193322C520D500B2B157 /* OPTDataStore.swift in Sources */, 84861811286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */, 6E7517EF22C520D400B2B157 /* DataStoreMemory.swift in Sources */, + 98F28A682E05220300A86546 /* CmabServiceTests.swift in Sources */, 6E75194B22C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, 6E75195722C520D500B2B157 /* OPTBucketer.swift in Sources */, 98F28A2A2E01940500A86546 /* Cmab.swift in Sources */, @@ -5437,6 +5470,7 @@ 6E86CEA924FDC847005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B118322C5488100C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */, + 98F28A582E0451CC00A86546 /* CmabService.swift in Sources */, 6E9B118E22C5488100C22D81 /* ProjectTests.swift in Sources */, 6E9B118022C5488100C22D81 /* AudienceTests.swift in Sources */, 6E7518B122C520D400B2B157 /* Group.swift in Sources */, @@ -5563,6 +5597,7 @@ 6E7517DA22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, + 98F28A5B2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, @@ -5671,6 +5706,7 @@ 6E7517DF22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, + 98F28A562E0451CC00A86546 /* CmabService.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, @@ -5811,6 +5847,7 @@ C78CAF582445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 848617C82863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75170622C520D400B2B157 /* OptimizelyClient.swift in Sources */, + 98F28A642E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518A022C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E75174222C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 6E75187022C520D400B2B157 /* Variation.swift in Sources */, @@ -5904,6 +5941,7 @@ 6E7517E222C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E7516D822C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75188A22C520D400B2B157 /* Project.swift in Sources */, + 98F28A5F2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75195622C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -6017,6 +6055,7 @@ 75C71A2E25E454460084187E /* Project.swift in Sources */, 75C71A2F25E454460084187E /* Experiment.swift in Sources */, 75C71A3025E454460084187E /* FeatureFlag.swift in Sources */, + 98F28A592E0451CC00A86546 /* CmabService.swift in Sources */, 75C71A3125E454460084187E /* Group.swift in Sources */, 75C71A3225E454460084187E /* Variable.swift in Sources */, 848617DD2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, @@ -6119,6 +6158,7 @@ BD6485662491474500F30986 /* OptimizelyJSON.swift in Sources */, 848617CA2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, BD6485672491474500F30986 /* OptimizelyClient.swift in Sources */, + 98F28A632E0451CC00A86546 /* CmabService.swift in Sources */, BD6485682491474500F30986 /* FeatureFlag.swift in Sources */, BD6485692491474500F30986 /* HandlerRegistryService.swift in Sources */, BD64856A2491474500F30986 /* Variation.swift in Sources */, diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift index dda17086..42873308 100644 --- a/Sources/CMAB/CmabClient.swift +++ b/Sources/CMAB/CmabClient.swift @@ -42,7 +42,7 @@ protocol CmabClient { func fetchDecision( ruleId: String, userId: String, - attributes: [String: Any], + attributes: [String: Any?], cmabUUID: String, completion: @escaping (Result) -> Void ) @@ -67,7 +67,7 @@ class DefaultCmabClient: CmabClient { func fetchDecision( ruleId: String, userId: String, - attributes: [String: Any], + attributes: [String: Any?], cmabUUID: String, completion: @escaping (Result) -> Void ) { diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift new file mode 100644 index 00000000..be0024ad --- /dev/null +++ b/Sources/CMAB/CmabService.swift @@ -0,0 +1,149 @@ +// +// 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 Foundation + +struct CmabDecision { + let variationId: String + let cmabUUID: String +} + +struct CmabCacheValue { + let attributesHash: String + let variationId: String + let cmabUUID: String +} + +typealias CmabDecisionCompletionHandler = (Result) -> Void + +protocol CmabService { + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption], + completion: @escaping CmabDecisionCompletionHandler) +} + +class DefaultCmabService { + typealias UserAttributes = [String : Any?] + + private let cmabClient: CmabClient + private let cmabCache: LruCache + private let logger = OPTLoggerFactory.getLogger() + + init(cmabClient: CmabClient, cmabCache: LruCache) { + self.cmabClient = cmabClient + self.cmabCache = cmabCache + } + + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption], + completion: @escaping CmabDecisionCompletionHandler) { + + let filteredAttributes = filterAttributes(config: config, attributes: userContext.attributes, ruleId: ruleId) + + let userId = userContext.userId + + if options.contains(.ignoreCmabCache) { + fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes, completion: completion) + return + } + + if options.contains(.resetCmabCache) { + cmabCache.reset() + } + + let cacheKey = getCacheKey(userId: userId, ruleId: ruleId) + + if options.contains(.invalidateUserCmabCache) { + self.cmabCache.remove(key: cacheKey) + } + + let attributesHash = hashAttributes(filteredAttributes) + + if let cachedValue = cmabCache.lookup(key: cacheKey), cachedValue.attributesHash == attributesHash { + let decision = CmabDecision(variationId: cachedValue.variationId, cmabUUID: cachedValue.cmabUUID) + completion(.success(decision)) + return + } else { + cmabCache.remove(key: cacheKey) + } + + fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes) { result in + if case .success(let decision) = result { + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: decision.variationId, + cmabUUID: decision.cmabUUID + ) + self.cmabCache.save(key: cacheKey, value: cacheValue) + } + completion(result) + } + } + + private func fetchDecision(ruleId: String, + userId: String, + attributes: UserAttributes, + completion: @escaping CmabDecisionCompletionHandler) { + let cmabUUID = UUID().uuidString + cmabClient.fetchDecision(ruleId: ruleId, userId: userId, attributes: attributes, cmabUUID: cmabUUID) { result in + switch result { + case .success(let variaitonId): + let decision = CmabDecision(variationId: variaitonId, cmabUUID: cmabUUID) + completion(.success(decision)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func getCacheKey(userId: String, ruleId: String) -> String { + return "\(userId.count)-\(userId)-\(ruleId)" + } + + func hashAttributes(_ attributes: UserAttributes) -> String { + // Sort and serialize as array of [key, value] pairs for deterministic output + let sortedPairs = attributes.sorted { $0.key < $1.key } + .map { [$0.key, $0.value] } + guard let data = try? JSONSerialization.data(withJSONObject: sortedPairs, options: []) else { + return "" + } + let hash = MurmurHash3.hash32Bytes(key: [UInt8](data), maxBytes: data.count) + return String(format: "%08x", hash) + } + + private func filterAttributes(config: ProjectConfig, + attributes: UserAttributes, + ruleId: String) -> UserAttributes { + let userAttributes = attributes + var filteredUserAttributes: UserAttributes = [:] + + guard let experiment = config.getExperiment(id: ruleId), let cmab = experiment.cmab else { + return filteredUserAttributes + } + + let cmabAttributeIds = cmab.attributeIds + for attributeId in cmabAttributeIds { + if let attribute = config.getAttribute(id: attributeId), let value = userAttributes[attribute.key] { + filteredUserAttributes[attribute.key] = value + } + } + return filteredUserAttributes + } +} diff --git a/Sources/Optimizely+Decide/OptimizelyDecideOption.swift b/Sources/Optimizely+Decide/OptimizelyDecideOption.swift index 763460a3..f8661986 100644 --- a/Sources/Optimizely+Decide/OptimizelyDecideOption.swift +++ b/Sources/Optimizely+Decide/OptimizelyDecideOption.swift @@ -32,4 +32,13 @@ import Foundation /// exclude variable values from the decision result. case excludeVariables + + /// ignoreCmabCache instructs the SDK to ignore the CMAB cache and make a fresh request + case ignoreCmabCache + + /// resetCmabCache instructs the SDK to reset the entire CMAB cache + case resetCmabCache + + /// InvalidateUserCMABCache instructs the SDK to invalidate CMAB cache entries for the current user + case invalidateUserCmabCache } diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift new file mode 100644 index 00000000..837be63d --- /dev/null +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -0,0 +1,464 @@ +// +// 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 + +fileprivate class MockCmabClient: CmabClient { + var fetchDecisionResult: Result = .success("variation-1") + var fetchDecisionCalled = false + var lastRuleId: String? + var lastUserId: String? + var lastAttributes: [String: Any?]? + var lastCmabUUID: String? + + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any?], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) { + fetchDecisionCalled = true + lastRuleId = ruleId + lastUserId = userId + lastAttributes = attributes + lastCmabUUID = cmabUUID + completion(fetchDecisionResult) + } + + func reset() { + fetchDecisionCalled = false + lastRuleId = nil + lastUserId = nil + lastAttributes = nil + lastCmabUUID = nil + } +} + +fileprivate class MockProjectConfig: ProjectConfig { + override init() { + super.init() + let data: [String: Any] = ["id": "11111", + "key": "empty", + "status": "Running", + "layerId": "22222", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "forcedVariations": ["12345": "1234567890"]] + + let cmab = Cmab(trafficAllocation: 1000, attributeIds: ["attr1", "attr2"]) + + var model1: Experiment = try! OTUtils.model(from: data) + model1.id = "exp-123" + model1.cmab = cmab + + var model2: Experiment = try! OTUtils.model(from: data) + model2.id = "exp-124" + + allExperiments = [model1, model2] + updateProjectDependentProps() + + } + + override func updateProjectDependentProps() { + self.experimentKeyMap = { + var map = [String: Experiment]() + allExperiments.forEach { exp in + map[exp.key] = exp + } + return map + }() + + self.experimentIdMap = { + var map = [String: Experiment]() + allExperiments.forEach { map[$0.id] = $0 } + return map + }() + + let attribute1 = Attribute(id: "attr1", key: "age") + let attribute2 = Attribute(id: "attr2", key: "location") + + attributeIdMap["attr1"] = attribute1 + attributeIdMap["attr2"] = attribute2 + attributeKeyMap["age"] = attribute1 + attributeIdMap["location"] = attribute2 + } + +} + +class MockUserContext: OptimizelyUserContext { + convenience init(userId: String, attributes: [String: Any?]) { + let client = OptimizelyClient(sdkKey: "sdk-key-123") + self.init(optimizely: client, userId: userId, attributes: attributes) + } +} + + +class DefaultCmabServiceTests: XCTestCase { + fileprivate var cmabClient: MockCmabClient! + fileprivate var config: MockProjectConfig! + var cmabCache: LruCache! + var cmabService: DefaultCmabService! + var userContext: OptimizelyUserContext! + let userAttributes: [String: Any] = ["age": 25, "location": "San Francisco"] + + override func setUp() { + super.setUp() + config = MockProjectConfig() + cmabClient = MockCmabClient() + cmabCache = LruCache(size: 10, timeoutInSecs: 10) + cmabService = DefaultCmabService(cmabClient: cmabClient, cmabCache: cmabCache) + // Set up user context + userContext = MockUserContext(userId: "test-user", attributes: userAttributes) + } + + override func tearDown() { + cmabClient = nil + cmabCache = nil + cmabService = nil + config = nil + userContext = nil + super.tearDown() + } + + func testHashAttributesDeterminism() { + // Different order, same attributes + let attributes1: [String: Any?] = ["c": 3, "a": 1, "b": 2] + let attributes2: [String: Any?] = ["a": 1, "b": 2, "c": 3] + + // Access private method for testing + let hash1 = cmabService.hashAttributes(attributes1) + let hash2 = cmabService.hashAttributes(attributes2) + + XCTAssertEqual(hash1, hash2, "Hashes should be deterministic regardless of attribute order") + + // Different attributes should have different hashes + let attributes3: [String: Any?] = ["a": 1, "b": 2, "c": 4] // Changed value + let hash3 = cmabService.hashAttributes(attributes3) + + XCTAssertNotEqual(hash1, hash3, "Different attributes should have different hashes") + } + + func testFilterAttributes() { + // Set up the user attributes that include both relevant and irrelevant ones + let userAttributes: [String: Any?] = [ + "age": 25, + "country": "USA", + "irrelevant": "value" + ] + + userContext = MockUserContext(userId: "test-user", attributes: userAttributes) + + let expectation = self.expectation(description: "fetchDecision") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { _ in + // Check that only the relevant attributes were passed to the client + XCTAssertEqual(self.cmabClient.lastAttributes?.count, 1) // Only 'age' is found in the config + XCTAssertEqual(self.cmabClient.lastAttributes?["age"] as? Int, 25) + XCTAssertNil(self.cmabClient.lastAttributes?["irrelevant"] ?? nil) + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testGetCacheKey() { + let userId = "test-user" + let ruleId = "exp-123" + + let cacheKey = cmabService.getCacheKey(userId: userId, ruleId: ruleId) + + XCTAssertEqual(cacheKey, "9-test-user-exp-123") + + // Test with a different user + let cacheKey2 = cmabService.getCacheKey(userId: "other-user", ruleId: ruleId) + + XCTAssertEqual(cacheKey2, "10-other-user-exp-123") + } + + + func testFetchDecision() { + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("variation-123") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "variation-123") + XCTAssertEqual(self.cmabClient.lastRuleId, "exp-123") + XCTAssertEqual(self.cmabClient.lastUserId, "test-user") + // We expect only the 'age' attribute as that's what's configured in the experiment + 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)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testCachedDecision() { + // 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 expectation = self.expectation(description: "fetchDecision") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + 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)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testCacheInvalidationWithChangedAttributes() { + // 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) + + // When attributes change, the hash should be different and the cache should be invalid + userContext = MockUserContext(userId: "test-user", attributes: ["age": 25]) + + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API when attributes change") + + // Verify cache was updated + let newCacheValue = self.cmabCache.lookup(key: cacheKey) + XCTAssertNotNil(newCacheValue) + XCTAssertEqual(newCacheValue?.variationId, "new-variation") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + + func testIgnoreCmabCacheOption() { + // 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 expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.ignoreCmabCache], + completion: { result in + + 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)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testResetCmabCacheOption() { + // 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 the cache to verify it's cleared too + let otherCacheKey = "other-key" + cmabCache.save(key: otherCacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.resetCmabCache], + completion: { result in + + 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)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testInvalidateUserCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let userCacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + cmabCache.save(key: userCacheKey, value: cacheValue) + + // Also add another user to the cache to verify it's NOT cleared + let otherUserCacheKey = "other-user-key" + cmabCache.save(key: otherUserCacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.invalidateUserCmabCache], + completion: { result in + + 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)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testFailedFetch() { + let expectation = self.expectation(description: "fetchDecision") + + let testError = CmabClientError.fetchFailed("Test error") + cmabClient.fetchDecisionResult = .failure(testError) + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + 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)) + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } +} + From 8e168f6bf00ac59abb567e8b2936d82d7594e0aa Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 24 Jun 2025 21:54:35 +0600 Subject: [PATCH 04/20] Added log for cmab decision --- Sources/CMAB/CmabService.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index be0024ad..7eb80378 100644 --- a/Sources/CMAB/CmabService.swift +++ b/Sources/CMAB/CmabService.swift @@ -60,17 +60,20 @@ class DefaultCmabService { let userId = userContext.userId if options.contains(.ignoreCmabCache) { + self.logger.i("Ignoring CMAB cache.") fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes, completion: completion) return } if options.contains(.resetCmabCache) { + self.logger.i("Resetting CMAB cache.") cmabCache.reset() } let cacheKey = getCacheKey(userId: userId, ruleId: ruleId) if options.contains(.invalidateUserCmabCache) { + self.logger.i("Invalidating user CMAB cache.") self.cmabCache.remove(key: cacheKey) } @@ -78,9 +81,11 @@ class DefaultCmabService { if let cachedValue = cmabCache.lookup(key: cacheKey), cachedValue.attributesHash == attributesHash { let decision = CmabDecision(variationId: cachedValue.variationId, cmabUUID: cachedValue.cmabUUID) + self.logger.i("Returning cached CMAB decision.") completion(.success(decision)) return } else { + self.logger.i("CMAB decision not found in cache.") cmabCache.remove(key: cacheKey) } @@ -91,6 +96,7 @@ class DefaultCmabService { variationId: decision.variationId, cmabUUID: decision.cmabUUID ) + self.logger.i("Featched CMAB decision and cached it.") self.cmabCache.save(key: cacheKey, value: cacheValue) } completion(result) @@ -105,9 +111,11 @@ class DefaultCmabService { cmabClient.fetchDecision(ruleId: ruleId, userId: userId, attributes: attributes, cmabUUID: cmabUUID) { result in switch result { case .success(let variaitonId): + self.logger.i("Fetched CMAB decision: \(variaitonId)") let decision = CmabDecision(variationId: variaitonId, cmabUUID: cmabUUID) completion(.success(decision)) case .failure(let error): + self.logger.e("Failed to fetch CMAB decision: \(error)") completion(.failure(error)) } } From 0c4d07251cb3799c3d4f1ccdb41e9245761b4e7b Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 24 Jun 2025 22:12:32 +0600 Subject: [PATCH 05/20] Update copyright date --- Sources/CMAB/CmabService.swift | 2 +- Tests/OptimizelyTests-Common/CmabServiceTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index 7eb80378..e0eb9580 100644 --- a/Sources/CMAB/CmabService.swift +++ b/Sources/CMAB/CmabService.swift @@ -1,5 +1,5 @@ // -// Copyright 2022, Optimizely, Inc. and contributors +// Copyright 2025, 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. diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift index 837be63d..10c042cc 100644 --- a/Tests/OptimizelyTests-Common/CmabServiceTests.swift +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2022, Optimizely, Inc. and contributors +// Copyright 2015, 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. From f342ae2c753170e633b4c3ee59782c6ae91ff99a Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Thu, 26 Jun 2025 17:12:02 +0600 Subject: [PATCH 06/20] CMAB decision implemented --- Sources/CMAB/CmabService.swift | 22 +- Sources/Data Model/Experiment.swift | 3 + Sources/Implementation/DefaultBucketer.swift | 48 +++++ .../DefaultDecisionService.swift | 188 +++++++++++++++--- .../OptimizelyClient+Decide.swift | 74 ++++++- Sources/Optimizely/OptimizelyClient.swift | 1 + Sources/Utils/LogMessage.swift | 6 + .../DecisionListenerTests.swift | 2 +- 8 files changed, 308 insertions(+), 36 deletions(-) diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index e0eb9580..b3134c3e 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, 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..59e71404 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -120,6 +120,54 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } + func bucketToEntityId(bucketingId: String, + experiment: Experiment, + trafficAllocation: [TrafficAllocation], + group: Group?) -> DecisionResponse { + let reasons = DecisionReasons() + + if let group = group, group.policy == .random { + let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: group.id) + let bucketValue = self.generateBucketValue(bucketingId: hashId) + + var matched = false + for allocation in group.trafficAllocation { + if bucketValue < allocation.endOfRange { + matched = true + if allocation.entityId != experiment.id { + let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + reasons.addInfo(info) + break + } + } + + if !matched { + let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + } + + let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) + let bucketValue = self.generateBucketValue(bucketingId: hashId) + + for allocation in trafficAllocation { + if bucketValue < allocation.endOfRange { + let info = LogMessage.userBucketedIntoEntity(allocation.entityId) + reasons.addInfo(info) + return DecisionResponse(result: allocation.entityId, reasons: reasons) + } + } + let info = LogMessage.userNotBucketedIntoAnyEntity + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 003cc04f..c093473a 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -20,13 +20,30 @@ struct FeatureDecision { var experiment: ExperimentCore? let variation: Variation let source: String + var cmabUUID: String? +} + +struct CMABDecisionResult { + var result: CmabDecision? + var error: Bool + var reasons: DecisionReasons +} + +struct VariationDecision { + var variation: Variation + var cmabUUID: String? } typealias UserProfile = OPTUserProfileService.UPProfile +let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds +let DEFAULT_CMAB_CACHE_SIZE = 1000 + 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() @@ -39,12 +56,76 @@ class DefaultDecisionService: OPTDecisionService { init(userProfileService: OPTUserProfileService) { self.bucketer = DefaultBucketer() + // fixme: cmab service need to inject from outside self.userProfileService = userProfileService + let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) + self.cmabService = DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) } init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { self.bucketer = bucketer self.userProfileService = userProfileService + let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) + self.cmabService = DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) + } + + // 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, + 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) + } + + let dummyEntityId = "$" + let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation) + let group = config.getGroup(id: experiment.id) + let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(bucketingId: bucketingId, + experiment: experiment, + trafficAllocation: [cmabTrafficAllocation], + group: group) + if let _reasons = bucketedResponse?.reasons { + reasons.merge(_reasons) + } + + let entityId = bucketedResponse?.result ?? "" + + if entityId != dummyEntityId { + return DecisionResponse(result: nil, reasons: reasons) + } + + var cmabDecision: CmabDecision? + /// Fetch CMAB decision + let response = cmabService.getDecision(config: config, userContext: user, ruleId: experiment.id, options: options ?? []) + if case let .success(decision) = response { + cmabDecision = decision + } else { + let info = LogMessage.cmabFetchFailed(experiment.key) + self.logger.e(info) + reasons.addInfo(info) + } + + 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 +171,31 @@ class DefaultDecisionService: OPTDecisionService { user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, userProfileTracker: UserProfileTracker?) -> DecisionResponse { + let decisionResponse = self.getVariation(config: config, experiment: experiment, user: user, ignoreCmab: true, 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. + /// - ignoreCmab: Flag to skip cmab decision + /// - userProfileTracker: Optional tracker for user profile data. + /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons. + private func getVariation(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + ignoreCmab: Bool, + 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 +211,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 +221,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 +239,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 +252,29 @@ 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, !ignoreCmab { + let cmabDecisionResponse = getDecisionForCmabExperiment(config: config, + experiment: experiment, + user: user, + bucketingId: bucketingId, + 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 +291,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 +308,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, ignoreCmab: true, options: options) + } + + func getVariationForFeature(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + ignoreCmab: Bool, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + + let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, ignoreCmab: ignoreCmab, options: options).first guard response?.result != nil else { let reasons = response?.reasons ?? DecisionReasons(options: options) @@ -212,6 +337,7 @@ class DefaultDecisionService: OPTDecisionService { func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, + ignoreCmab: Bool = true, options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { let userId = user.userId @@ -225,7 +351,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, ignoreCmab: ignoreCmab) decisions.append(flagDecisionResponse) } @@ -249,6 +375,7 @@ class DefaultDecisionService: OPTDecisionService { featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, + ignoreCmab: Bool, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -265,7 +392,7 @@ 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, ignoreCmab: ignoreCmab) reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { @@ -291,10 +418,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, + ignoreCmab: Bool = true, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) let experimentIds = featureFlag.experimentIds @@ -313,10 +441,11 @@ class DefaultDecisionService: OPTDecisionService { rule: experiment, user: user, userProfileTracker: userProfileTracker, + ignoreCmab: ignoreCmab, options: options) reasons.merge(decisionResponse.reasons) - if let variation = decisionResponse.result { - let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + if let result = decisionResponse.result { + let featureDecision = FeatureDecision(experiment: experiment, variation: result.variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID) return DecisionResponse(result: featureDecision, reasons: reasons) } } @@ -471,7 +600,8 @@ class DefaultDecisionService: OPTDecisionService { rule: Experiment, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker?, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + ignoreCmab: Bool = true, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) // check forced-decision first let forcedDecisionResponse = findValidatedForcedDecision(config: config, @@ -480,16 +610,18 @@ 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, + ignoreCmab: ignoreCmab, 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..71f0275b 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -16,6 +16,9 @@ import Foundation +typealias DecideCompletion = (OptimizelyDecision) -> Void +typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void + extension OptimizelyClient { /// Create a context of the user for which decision APIs will be called. @@ -77,20 +80,56 @@ 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, ignoreCmab: true, 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, ignoreCmab: false, 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, ignoreCmab: true, ignoreDefaultOptions: false) } - func decide(user: OptimizelyUserContext, + func decideAsync(user: OptimizelyUserContext, keys: [String], - options: [OptimizelyDecideOption]? = nil, - ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { + options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) { + decisionQueue.async { + let decisions = self.decide(user: user, keys: keys, options: options, ignoreCmab: false, ignoreDefaultOptions: false) + completion(decisions) + } + } + + private func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + ignoreCmab: Bool, + ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { guard let config = self.config else { logger.e(OptimizelyError.sdkNotReady) return [:] @@ -132,7 +171,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, ignoreCmab: ignoreCmab, options: allOptions) for index in 0.. [String: OptimizelyDecision] { + return self.decide(user: user, keys: keys, options: options, ignoreCmab: true, ignoreDefaultOptions: ignoreDefaultOptions) + } + private func createOptimizelyDecision(flagKey: String, user: OptimizelyUserContext, flagDecision: FeatureDecision?, @@ -250,6 +296,22 @@ extension OptimizelyClient { 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, ignoreCmab: false, ignoreDefaultOptions: false) + completion(decision) + } + } + } // MARK: - Utils diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 7c7179a4..a85380e0 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 diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 4ce7c08a..76354482 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) @@ -73,6 +75,7 @@ enum LogMessage { case failedToAssignValue case valueForKeyNotFound(_ key: String) case lowPeriodicDownloadInterval + case cmabFetchFailed(_ expKey: String) } extension LogMessage: CustomStringConvertible { @@ -114,6 +117,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." @@ -140,6 +145,7 @@ 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)" } return message diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index 157bed45..a0aaded0 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -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, ignoreCmab: Bool = true, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { guard let experiment = self.experiment, let tmpVariation = self.variation else { return DecisionResponse.nilNoReasons() } From ebbde3c86c46f085b18eb942edbcc0bce0fb8e2c Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 27 Jun 2025 20:15:25 +0600 Subject: [PATCH 07/20] CmabService testcases added for sync getDecision function --- .../CmabServiceTests.swift | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) 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)") + } + } + +} From 92d3140d5677fab1e91d90c8da893be484a1baea Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 27 Jun 2025 20:42:07 +0600 Subject: [PATCH 08/20] Add factory method for DefaultCmabService --- Sources/CMAB/CmabService.swift | 9 +++++++++ .../Implementation/DefaultDecisionService.swift | 14 ++++---------- Sources/Optimizely/OptimizelyClient.swift | 4 +++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index b3134c3e..c74205e8 100644 --- a/Sources/CMAB/CmabService.swift +++ b/Sources/CMAB/CmabService.swift @@ -175,3 +175,12 @@ class DefaultCmabService: CmabService { 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/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index c093473a..83e55ebe 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -36,9 +36,6 @@ struct VariationDecision { typealias UserProfile = OPTUserProfileService.UPProfile -let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds -let DEFAULT_CMAB_CACHE_SIZE = 1000 - class DefaultDecisionService: OPTDecisionService { let bucketer: OPTBucketer let userProfileService: OPTUserProfileService @@ -54,19 +51,16 @@ class DefaultDecisionService: OPTDecisionService { return threadSafeLogger.logger } - init(userProfileService: OPTUserProfileService) { + init(userProfileService: OPTUserProfileService, cmabService: CmabService) { self.bucketer = DefaultBucketer() - // fixme: cmab service need to inject from outside self.userProfileService = userProfileService - let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) - self.cmabService = DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) + self.cmabService = cmabService } - init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { + init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer, cmabService: CmabService) { self.bucketer = bucketer self.userProfileService = userProfileService - let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) - self.cmabService = DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) + self.cmabService = cmabService } // MARK: - CMAB decision diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index a85380e0..35381072 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -108,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() From c6507b0852a95c52b4467ef648b561da8d5dfaf3 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 27 Jun 2025 22:45:42 +0600 Subject: [PATCH 09/20] DefaultDecision initializer updated --- Sources/Implementation/DefaultDecisionService.swift | 7 +++++-- Tests/OptimizelyTests-Common/DecisionListenerTests.swift | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 83e55ebe..018f2f7f 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -51,13 +51,16 @@ class DefaultDecisionService: OPTDecisionService { return threadSafeLogger.logger } - init(userProfileService: OPTUserProfileService, cmabService: CmabService) { + init(userProfileService: OPTUserProfileService, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService self.cmabService = cmabService } - init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer, cmabService: CmabService) { + init(userProfileService: OPTUserProfileService, + bucketer: OPTBucketer, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = bucketer self.userProfileService = userProfileService self.cmabService = cmabService diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index a0aaded0..f2a85a22 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()) } From b51c2286f8b9717557712f5298603e2c8b237d16 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 27 Jun 2025 23:04:46 +0600 Subject: [PATCH 10/20] Add operation type enum --- .../DefaultDecisionService.swift | 40 ++++++++++++------- .../OptimizelyClient+Decide.swift | 16 ++++---- .../DecisionListenerTests.swift | 2 +- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 018f2f7f..461f8ac5 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -34,8 +34,15 @@ struct VariationDecision { 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 @@ -168,7 +175,7 @@ class DefaultDecisionService: OPTDecisionService { user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, userProfileTracker: UserProfileTracker?) -> DecisionResponse { - let decisionResponse = self.getVariation(config: config, experiment: experiment, user: user, ignoreCmab: true, userProfileTracker: userProfileTracker) + let decisionResponse = self.getVariation(config: config, experiment: experiment, user: user, opType: .sync, userProfileTracker: userProfileTracker) return DecisionResponse(result: decisionResponse.result?.variation, reasons: decisionResponse.reasons) } @@ -179,14 +186,14 @@ class DefaultDecisionService: OPTDecisionService { /// - experiment: The experiment to evaluate. /// - user: The user context. /// - options: Optional decision options. - /// - ignoreCmab: Flag to skip cmab decision + /// - 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. private func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, - ignoreCmab: Bool, + opType: OPType, userProfileTracker: UserProfileTracker?) -> DecisionResponse { let reasons = DecisionReasons(options: options) let userId = user.userId @@ -252,7 +259,10 @@ class DefaultDecisionService: OPTDecisionService { // Acquire bucketingId . let bucketingId = getBucketingId(userId: userId, attributes: attributes) - if experiment.isCmab, !ignoreCmab { + if experiment.isCmab { + if opType == .sync { + /// fixme + } let cmabDecisionResponse = getDecisionForCmabExperiment(config: config, experiment: experiment, user: user, @@ -305,16 +315,16 @@ class DefaultDecisionService: OPTDecisionService { user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - self.getVariationForFeature(config: config, featureFlag: featureFlag, user: user, ignoreCmab: true, options: options) + self.getVariationForFeature(config: config, featureFlag: featureFlag, user: user, opType: .sync, options: options) } func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, - ignoreCmab: Bool, + opType: OPType, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, ignoreCmab: ignoreCmab, options: options).first + 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) @@ -334,7 +344,7 @@ class DefaultDecisionService: OPTDecisionService { func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, - ignoreCmab: Bool = true, + opType: OPType = .sync, options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { let userId = user.userId @@ -348,7 +358,7 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, ignoreCmab: ignoreCmab) + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, opType: opType) decisions.append(flagDecisionResponse) } @@ -372,7 +382,7 @@ class DefaultDecisionService: OPTDecisionService { featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, - ignoreCmab: Bool, + opType: OPType, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -389,7 +399,7 @@ class DefaultDecisionService: OPTDecisionService { } } - let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, ignoreCmab: ignoreCmab) + let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, opType: opType) reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { @@ -418,7 +428,7 @@ class DefaultDecisionService: OPTDecisionService { featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, - ignoreCmab: Bool = true, + opType: OPType = .sync, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -438,7 +448,7 @@ class DefaultDecisionService: OPTDecisionService { rule: experiment, user: user, userProfileTracker: userProfileTracker, - ignoreCmab: ignoreCmab, + opType: opType, options: options) reasons.merge(decisionResponse.reasons) if let result = decisionResponse.result { @@ -597,7 +607,7 @@ class DefaultDecisionService: OPTDecisionService { rule: Experiment, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker?, - ignoreCmab: Bool = true, + opType: OPType = .sync, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) // check forced-decision first @@ -614,7 +624,7 @@ class DefaultDecisionService: OPTDecisionService { let decisionResponse = getVariation(config: config, experiment: rule, user: user, - ignoreCmab: ignoreCmab, + opType: opType, userProfileTracker: userProfileTracker) let variationResult = decisionResponse.result reasons.merge(decisionResponse.reasons) diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index 71f0275b..a4e61ccb 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -80,7 +80,7 @@ extension OptimizelyClient { var allOptions = defaultDecideOptions + (options ?? []) allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreCmab: true, 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) } @@ -104,7 +104,7 @@ extension OptimizelyClient { var allOptions = self.defaultDecideOptions + (options ?? []) allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let decisionMap = self.decide(user: user, keys: [key], options: allOptions, ignoreCmab: false, ignoreDefaultOptions: true) + 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) } @@ -113,14 +113,14 @@ extension OptimizelyClient { func decide(user: OptimizelyUserContext, keys: [String], options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - return decide(user: user, keys: keys, options: options, ignoreCmab: true, 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, ignoreCmab: false, ignoreDefaultOptions: false) + let decisions = self.decide(user: user, keys: keys, options: options, opType: .async, ignoreDefaultOptions: false) completion(decisions) } } @@ -128,7 +128,7 @@ extension OptimizelyClient { private func decide(user: OptimizelyUserContext, keys: [String], options: [OptimizelyDecideOption]? = nil, - ignoreCmab: Bool, + opType: OPType, ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { guard let config = self.config else { logger.e(OptimizelyError.sdkNotReady) @@ -171,7 +171,7 @@ extension OptimizelyClient { } } - let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, ignoreCmab: ignoreCmab, options: allOptions) + let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, opType: opType, options: allOptions) for index in 0.. [String: OptimizelyDecision] { - return self.decide(user: user, keys: keys, options: options, ignoreCmab: true, ignoreDefaultOptions: ignoreDefaultOptions) + return self.decide(user: user, keys: keys, options: options, opType: .sync, ignoreDefaultOptions: ignoreDefaultOptions) } private func createOptimizelyDecision(flagKey: String, @@ -307,7 +307,7 @@ extension OptimizelyClient { return } - let decision = self.decide(user: user, keys: config.featureFlagKeys, options: options, ignoreCmab: false, ignoreDefaultOptions: false) + let decision = self.decide(user: user, keys: config.featureFlagKeys, options: options, opType: .async, ignoreDefaultOptions: false) completion(decision) } } diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index f2a85a22..d4330d7e 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1267,7 +1267,7 @@ class FakeDecisionService: DefaultDecisionService { return DecisionResponse.responseNoReasons(result: featureDecision) } - override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, ignoreCmab: Bool = true, 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() } From e9d01ac19b28fff113fde83e46e66d0bba7cf156 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 27 Jun 2025 23:11:14 +0600 Subject: [PATCH 11/20] CMAB not supported in sync mode --- Sources/Implementation/DefaultDecisionService.swift | 12 +++++++++--- Sources/Utils/LogMessage.swift | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 461f8ac5..6de50323 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -87,6 +87,7 @@ class DefaultDecisionService: OPTDecisionService { experiment: Experiment, user: OptimizelyUserContext, bucketingId: String, + opType: OPType, options: [OptimizelyDecideOption]?) -> DecisionResponse { let reasons = DecisionReasons(options: options) guard let cmab = experiment.cmab else { @@ -94,6 +95,13 @@ class DefaultDecisionService: OPTDecisionService { 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 group = config.getGroup(id: experiment.id) @@ -260,13 +268,11 @@ class DefaultDecisionService: OPTDecisionService { let bucketingId = getBucketingId(userId: userId, attributes: attributes) if experiment.isCmab { - if opType == .sync { - /// fixme - } let cmabDecisionResponse = getDecisionForCmabExperiment(config: config, experiment: experiment, user: user, bucketingId: bucketingId, + opType: opType, options: options) reasons.merge(cmabDecisionResponse.reasons) variationDecision = cmabDecisionResponse.result diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 76354482..816036e3 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -76,6 +76,7 @@ enum LogMessage { case valueForKeyNotFound(_ key: String) case lowPeriodicDownloadInterval case cmabFetchFailed(_ expKey: String) + case cmabNotSupportedInSyncMode } extension LogMessage: CustomStringConvertible { @@ -146,6 +147,7 @@ extension LogMessage: CustomStringConvertible { 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 From e827b2716b47f0fd267417d87d674e562a645362 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Sat, 28 Jun 2025 00:00:36 +0600 Subject: [PATCH 12/20] Reuse experiment bucketing logic --- Sources/Implementation/DefaultBucketer.swift | 94 +++++++++---------- .../DefaultDecisionService.swift | 21 +++-- .../DecisionServiceTests_Experiments.swift | 8 ++ 3 files changed, 66 insertions(+), 57 deletions(-) diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 59e71404..58959229 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -120,53 +120,53 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } - func bucketToEntityId(bucketingId: String, - experiment: Experiment, - trafficAllocation: [TrafficAllocation], - group: Group?) -> DecisionResponse { - let reasons = DecisionReasons() - - if let group = group, group.policy == .random { - let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: group.id) - let bucketValue = self.generateBucketValue(bucketingId: hashId) - - var matched = false - for allocation in group.trafficAllocation { - if bucketValue < allocation.endOfRange { - matched = true - if allocation.entityId != experiment.id { - let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - reasons.addInfo(info) - return DecisionResponse(result: nil, reasons: reasons) - } - - let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - reasons.addInfo(info) - break - } - } - - if !matched { - let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) - reasons.addInfo(info) - return DecisionResponse(result: nil, reasons: reasons) - } - } - - let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) - let bucketValue = self.generateBucketValue(bucketingId: hashId) - - for allocation in trafficAllocation { - if bucketValue < allocation.endOfRange { - let info = LogMessage.userBucketedIntoEntity(allocation.entityId) - reasons.addInfo(info) - return DecisionResponse(result: allocation.entityId, reasons: reasons) - } - } - let info = LogMessage.userNotBucketedIntoAnyEntity - reasons.addInfo(info) - return DecisionResponse(result: nil, reasons: reasons) - } +// func bucketToEntityId(bucketingId: String, +// experiment: Experiment, +// trafficAllocation: [TrafficAllocation], +// group: Group?) -> DecisionResponse { +// let reasons = DecisionReasons() +// +// if let group = group, group.policy == .random { +// let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: group.id) +// let bucketValue = self.generateBucketValue(bucketingId: hashId) +// +// var matched = false +// for allocation in group.trafficAllocation { +// if bucketValue < allocation.endOfRange { +// matched = true +// if allocation.entityId != experiment.id { +// let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) +// reasons.addInfo(info) +// return DecisionResponse(result: nil, reasons: reasons) +// } +// +// let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) +// reasons.addInfo(info) +// break +// } +// } +// +// if !matched { +// let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) +// reasons.addInfo(info) +// return DecisionResponse(result: nil, reasons: reasons) +// } +// } +// +// let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) +// let bucketValue = self.generateBucketValue(bucketingId: hashId) +// +// for allocation in trafficAllocation { +// if bucketValue < allocation.endOfRange { +// let info = LogMessage.userBucketedIntoEntity(allocation.entityId) +// reasons.addInfo(info) +// return DecisionResponse(result: allocation.entityId, reasons: reasons) +// } +// } +// let info = LogMessage.userNotBucketedIntoAnyEntity +// reasons.addInfo(info) +// return DecisionResponse(result: nil, reasons: reasons) +// } func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 6de50323..7cb725d1 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -104,17 +104,18 @@ class DefaultDecisionService: OPTDecisionService { let dummyEntityId = "$" let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation) - let group = config.getGroup(id: experiment.id) - let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(bucketingId: bucketingId, - experiment: experiment, - trafficAllocation: [cmabTrafficAllocation], - group: group) - if let _reasons = bucketedResponse?.reasons { - reasons.merge(_reasons) - } + var cmabExp = experiment + // Replace the regular allocaion with cmab traffic allocation + // to reuse the experiment bucketing logic + cmabExp.trafficAllocation = [cmabTrafficAllocation] + + let bucketedResponse = bucketer.bucketExperiment(config: config, experiment: cmabExp, bucketingId: bucketingId) + + reasons.merge(bucketedResponse.reasons) - let entityId = bucketedResponse?.result ?? "" + let entityId = bucketedResponse.result?.id + // this means the user is not in the cmab experiment if entityId != dummyEntityId { return DecisionResponse(result: nil, reasons: reasons) } @@ -264,7 +265,7 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(audienceResponse.reasons) if audienceResponse.result ?? false { - // Acquire bucketingId . + // Acquire bucketingId let bucketingId = getBucketingId(userId: userId, attributes: attributes) if experiment.isCmab { diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index 210b2ffb..df2e50e0 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -704,6 +704,14 @@ extension DecisionServiceTests_Experiments { } } +// MARK: - CMAB + +extension DecisionServiceTests_Experiments { + func testGetVairationWithCMAB() { + + } +} + // MARK: - Test getBucketingId() extension DecisionServiceTests_Experiments { From 445006b9cbd86fec3ce6d936865be70d49c2b01a Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Mon, 30 Jun 2025 22:44:55 +0600 Subject: [PATCH 13/20] Add seperate method for group exlusion, add bucketToEntityId method --- Sources/Implementation/DefaultBucketer.swift | 172 ++++++++++--------- 1 file changed, 87 insertions(+), 85 deletions(-) diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 58959229..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,53 +93,82 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } -// func bucketToEntityId(bucketingId: String, -// experiment: Experiment, -// trafficAllocation: [TrafficAllocation], -// group: Group?) -> DecisionResponse { -// let reasons = DecisionReasons() -// -// if let group = group, group.policy == .random { -// let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: group.id) -// let bucketValue = self.generateBucketValue(bucketingId: hashId) -// -// var matched = false -// for allocation in group.trafficAllocation { -// if bucketValue < allocation.endOfRange { -// matched = true -// if allocation.entityId != experiment.id { -// let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) -// reasons.addInfo(info) -// return DecisionResponse(result: nil, reasons: reasons) -// } -// -// let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) -// reasons.addInfo(info) -// break -// } -// } -// -// if !matched { -// let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) -// reasons.addInfo(info) -// return DecisionResponse(result: nil, reasons: reasons) -// } -// } -// -// let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) -// let bucketValue = self.generateBucketValue(bucketingId: hashId) -// -// for allocation in trafficAllocation { -// if bucketValue < allocation.endOfRange { -// let info = LogMessage.userBucketedIntoEntity(allocation.entityId) -// reasons.addInfo(info) -// return DecisionResponse(result: allocation.entityId, reasons: reasons) -// } -// } -// let info = LogMessage.userNotBucketedIntoAnyEntity -// reasons.addInfo(info) -// 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 { @@ -201,7 +203,7 @@ class DefaultBucketer: OPTBucketer { for bucket in trafficAllocation where bucketValue < bucket.endOfRange { return bucket.entityId } - + return nil } From b9ce0f59ae9e4be9541794bf487aaa8b5cd4ae60 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Mon, 30 Jun 2025 23:06:26 +0600 Subject: [PATCH 14/20] Update bucketing logic for cmab experiment --- .../DefaultDecisionService.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 7cb725d1..326f2dc9 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -104,19 +104,19 @@ class DefaultDecisionService: OPTDecisionService { let dummyEntityId = "$" let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation) - var cmabExp = experiment - // Replace the regular allocaion with cmab traffic allocation - // to reuse the experiment bucketing logic - cmabExp.trafficAllocation = [cmabTrafficAllocation] + let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(config: config, experiment: experiment, bucketingId: bucketingId, trafficAllocation: [cmabTrafficAllocation]) - let bucketedResponse = bucketer.bucketExperiment(config: config, experiment: cmabExp, bucketingId: bucketingId) - - reasons.merge(bucketedResponse.reasons) - - let entityId = bucketedResponse.result?.id + if let _reasons = bucketedResponse?.reasons { + reasons.merge(_reasons) + } + + let entityId = bucketedResponse?.result ?? "" // this means the user is not in the cmab experiment if entityId != dummyEntityId { + let info = LogMessage.userNotInCmabExperiment(user.userId, experiment.key) + logger.d(info) + reasons.addInfo(info) return DecisionResponse(result: nil, reasons: reasons) } @@ -198,7 +198,7 @@ class DefaultDecisionService: OPTDecisionService { /// - 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. - private func getVariation(config: ProjectConfig, + func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, From 093e372e4540da0668923d749741e8532061e258 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Mon, 30 Jun 2025 23:06:50 +0600 Subject: [PATCH 15/20] Add test cases for cmab experiement --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 12 + .../OptimizelyClient+Decide.swift | 81 +-- .../OptimizelyUserContext.swift | 107 ++++ Sources/Utils/LogMessage.swift | 2 + .../DecisionServiceTests_Experiments.swift | 169 +++++- ...imizelyUserContextTests_Decide_Async.swift | 511 ++++++++++++++++++ ...timizelyUserContextTests_Decide_CMAB.swift | 279 ++++++++++ 7 files changed, 1123 insertions(+), 38 deletions(-) create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index ae0344f1..43e32974 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2014,6 +2014,10 @@ 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 */; }; 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 +2573,8 @@ 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 = ""; }; 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 = ""; }; @@ -3126,6 +3132,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 +5013,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 */, @@ -5025,6 +5034,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 +5307,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 */, @@ -5317,6 +5328,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/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index a4e61ccb..ab6c8aa5 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -16,9 +16,6 @@ import Foundation -typealias DecideCompletion = (OptimizelyDecision) -> Void -typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void - extension OptimizelyClient { /// Create a context of the user for which decision APIs will be called. @@ -117,14 +114,33 @@ extension OptimizelyClient { } func decideAsync(user: OptimizelyUserContext, - keys: [String], - options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) { + 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, @@ -205,13 +221,32 @@ extension OptimizelyClient { return decisionMap } - 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 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) } + 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?, @@ -286,32 +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) - } - - 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) - } - } - } // MARK: - Utils diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index e21c16d6..e8b302b1 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,42 @@ 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. + 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 + @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 +180,43 @@ 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 empty dictionary through 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 + @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 +231,37 @@ 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 + @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/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 816036e3..9a474c93 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -58,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) @@ -129,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))." diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index df2e50e0..0bbf2b29 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -707,8 +707,151 @@ extension DecisionServiceTests_Experiments { // MARK: - CMAB extension DecisionServiceTests_Experiments { - func testGetVairationWithCMAB() { - + 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) + + XCTAssertNil(decision.result, "Should return nil when CMAB service fails") + 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()) } } @@ -733,3 +876,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/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..24f760d2 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -0,0 +1,279 @@ +// +// 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_cmabWithCaching() { + 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 + 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) + } + // FixMe: Need to fix the test case +// func testDecideAsync_cmabWithIgnoreCache() { +// let expectation = XCTestExpectation(description: "CMAB ignore cache") +// // 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] +// ) +// +// // Make decision with ignoreCmabCache option +// user.decideAsync( +// key: "feature_1", +// options: [.ignoreCmabCache, .ignoreUserProfileService] +// ) { decision in +// XCTAssertEqual(decision.variationKey, "a") +// XCTAssertTrue(self.mockCmabService.decisionCalled) +// XCTAssertTrue(self.mockCmabService.ignoreCacheUsed) +// expectation.fulfill() +// } +// +// wait(for: [expectation], 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 + + 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 + lastRuleId = 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")) + } +} From f2cc7dfe26c16178e182660dc940efca871edab6 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 2 Jul 2025 18:05:05 +0600 Subject: [PATCH 16/20] Add test cases for cmab decision options --- .../DefaultDecisionService.swift | 10 ++- ...timizelyUserContextTests_Decide_CMAB.swift | 80 ++++++++++--------- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 326f2dc9..c601c783 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -365,7 +365,7 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, opType: opType) + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, opType: opType, options: options) decisions.append(flagDecisionResponse) } @@ -398,7 +398,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) @@ -406,14 +407,14 @@ class DefaultDecisionService: OPTDecisionService { } } - let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, opType: opType) + 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 { @@ -631,6 +632,7 @@ class DefaultDecisionService: OPTDecisionService { let decisionResponse = getVariation(config: config, experiment: rule, user: user, + options: options, opType: opType, userProfileTracker: userProfileTracker) let variationResult = decisionResponse.result diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift index 24f760d2..8e46eb03 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -152,7 +152,7 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { wait(for: [expectation], timeout: 5) // Increased timeout for reliability } - func testDecideAsync_cmabWithCaching() { + func testDecideAsync_cmabWithUserProfileCahing() { let expectation1 = XCTestExpectation(description: "First CMAB decision") let expectation2 = XCTestExpectation(description: "Second CMAB decision") @@ -169,7 +169,7 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { attributes: ["gender": "f", "age": 25] ) - // First decision + // First decision cache into user profile user.decideAsync(key: "feature_1") { decision in XCTAssertEqual(decision.variationKey, "a") XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) @@ -186,38 +186,44 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { wait(for: [expectation1, expectation2], timeout: 1) } - // FixMe: Need to fix the test case -// func testDecideAsync_cmabWithIgnoreCache() { -// let expectation = XCTestExpectation(description: "CMAB ignore cache") -// // 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] -// ) -// -// // Make decision with ignoreCmabCache option -// user.decideAsync( -// key: "feature_1", -// options: [.ignoreCmabCache, .ignoreUserProfileService] -// ) { decision in -// XCTAssertEqual(decision.variationKey, "a") -// XCTAssertTrue(self.mockCmabService.decisionCalled) -// XCTAssertTrue(self.mockCmabService.ignoreCacheUsed) -// expectation.fulfill() -// } -// -// wait(for: [expectation], 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 @@ -244,7 +250,6 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { } - fileprivate class MockCmabService: DefaultCmabService { var variationId: String? var error: Error? @@ -252,6 +257,8 @@ fileprivate class MockCmabService: DefaultCmabService { 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)) @@ -259,10 +266,11 @@ fileprivate class MockCmabService: DefaultCmabService { override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { decisionCalled = true - decisionCallCount += 1 lastRuleId = ruleId ignoreCacheUsed = options.contains(.ignoreCmabCache) - + resetCacheCache = options.contains(.resetCmabCache) + invalidateUserCmabCache = options.contains(.invalidateUserCmabCache) + decisionCallCount += 1 if let error = error { return .failure(error) } From 384bfa8e353e8ff304b311cc39f3ce6b39f59c08 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Thu, 3 Jul 2025 18:58:08 +0600 Subject: [PATCH 17/20] Add test cases for bucketToEntity --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 6 + .../BucketTests_BucketToEntity.swift | 189 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 43e32974..a442597c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2018,6 +2018,8 @@ 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 */; }; @@ -2575,6 +2577,7 @@ 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 = ""; }; @@ -3099,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 */, @@ -5021,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 */, @@ -5315,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 */, 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") + } + } + +} From 87bcecfce0f886bbc69b4d82800c7dd0dbb8242e Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 4 Jul 2025 22:25:40 +0600 Subject: [PATCH 18/20] Return feature dicision with nil variation for CMAB fetch error --- .../DefaultDecisionService.swift | 36 +++++++++---------- .../OptimizelyClient+Decide.swift | 4 +-- Sources/Optimizely/OptimizelyClient.swift | 10 +++--- Sources/Utils/LogMessage.swift | 2 +- .../DecisionServiceTests_Experiments.swift | 3 +- .../DecisionServiceTests_Features.swift | 18 +++++----- .../DecisionServiceTests_Holdouts.swift | 28 +++++++-------- 7 files changed, 51 insertions(+), 50 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index c601c783..c6f40f14 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -18,19 +18,14 @@ import Foundation struct FeatureDecision { var experiment: ExperimentCore? - let variation: Variation + let variation: Variation? let source: String var cmabUUID: String? } -struct CMABDecisionResult { - var result: CmabDecision? - var error: Bool - var reasons: DecisionReasons -} - struct VariationDecision { - var variation: Variation + var variation: Variation? + var cmabError: Bool = false var cmabUUID: String? } @@ -42,7 +37,6 @@ enum OperationType { typealias OPType = OperationType typealias UserProfile = OPTUserProfileService.UPProfile - class DefaultDecisionService: OPTDecisionService { let bucketer: OPTBucketer let userProfileService: OPTUserProfileService @@ -120,15 +114,19 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: nil, reasons: reasons) } - var cmabDecision: CmabDecision? + /// Fetch CMAB decision let response = cmabService.getDecision(config: config, userContext: user, ruleId: experiment.id, options: options ?? []) - if case let .success(decision) = response { - cmabDecision = decision - } else { - let info = LogMessage.cmabFetchFailed(experiment.key) - self.logger.e(info) - reasons.addInfo(info) + 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, @@ -460,8 +458,10 @@ class DefaultDecisionService: OPTDecisionService { options: options) reasons.merge(decisionResponse.reasons) if let result = decisionResponse.result { - let featureDecision = FeatureDecision(experiment: experiment, variation: result.variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID) - return DecisionResponse(result: featureDecision, reasons: reasons) + 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) + } } } } diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index ab6c8aa5..6e88860d 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -260,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)") @@ -312,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, diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 35381072..092691f3 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -438,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 { @@ -588,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 { @@ -678,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)) @@ -691,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 9a474c93..2c0d39e0 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -148,7 +148,7 @@ 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 .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment: \(key)." case .cmabNotSupportedInSyncMode: message = "CMAB is not supported in sync mode." } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index 0bbf2b29..fbced002 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -793,7 +793,8 @@ extension DecisionServiceTests_Experiments { opType: .async, userProfileTracker: nil) - XCTAssertNil(decision.result, "Should return nil when CMAB service fails") + XCTAssertNotNil(decision.result) + XCTAssertEqual(decision.result?.variation, nil) XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) } 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) } From d1067d601aa8f6349eb172c24935de92916c4ff5 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 4 Jul 2025 23:06:53 +0600 Subject: [PATCH 19/20] Add code doc and fix linting issue --- .../DefaultDecisionService.swift | 10 +++---- .../OptimizelyUserContext.swift | 29 ++++++++++++------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index c6f40f14..a7c4fc48 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -197,11 +197,11 @@ class DefaultDecisionService: OPTDecisionService { /// - 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 { + 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 diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index e8b302b1..70959baf 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext.swift @@ -131,10 +131,12 @@ public class OptimizelyUserContext { /// - 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. + /// - 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) { + 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) @@ -142,7 +144,6 @@ public class OptimizelyUserContext { return } optimizely.decideAsync(user: clone, key: key, options: options, completion: completion) - } /// Returns a decision result asynchronously for a given flag key @@ -150,9 +151,11 @@ public class OptimizelyUserContext { /// - 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 { + 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) @@ -187,8 +190,9 @@ public class OptimizelyUserContext { /// - 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 empty dictionary through the completion handler. - + /// - 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) { @@ -207,9 +211,11 @@ public class OptimizelyUserContext { /// - 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] { + 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) @@ -239,7 +245,6 @@ public class OptimizelyUserContext { /// 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) @@ -253,6 +258,8 @@ public class OptimizelyUserContext { /// 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 From 0d761c9b756f93bd844fa012a871d16d5a736109 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 4 Jul 2025 23:12:31 +0600 Subject: [PATCH 20/20] Update cmab entity id matching logic --- Sources/Implementation/DefaultDecisionService.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index a7c4fc48..d3fb64e2 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -104,17 +104,16 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(_reasons) } - let entityId = bucketedResponse?.result ?? "" + let entityId = bucketedResponse?.result // this means the user is not in the cmab experiment - if entityId != dummyEntityId { + 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?