From b40adda25834b1180f5e7d9a8829b62abdcecb52 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 18 Jun 2025 20:40:54 +0600 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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.