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 3444cf6e..1e69c3d9 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..e0eb9580 --- /dev/null +++ b/Sources/CMAB/CmabService.swift @@ -0,0 +1,157 @@ +// +// 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. +// 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) { + 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) + } + + let attributesHash = hashAttributes(filteredAttributes) + + 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) + } + + 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.logger.i("Featched CMAB decision and cached it.") + 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): + 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)) + } + } + } + + 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/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift index 6a98502e..31094a25 100644 --- a/Tests/OptimizelyTests-Common/CMABClientTests.swift +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -218,7 +218,6 @@ class DefaultCmabClientTests: XCTestCase { expectation.fulfill() } waitForExpectations(timeout: 2) - } private func verifyRequest(ruleId: String, userId: String, attributes: [String: Any], cmabUUID: String) { @@ -268,6 +267,7 @@ extension DefaultCmabClientTests { with request: URLRequest, completionHandler: @escaping CompletionHandler ) -> URLSessionDataTask { + self.lastRequest = request let idx = callCount callCount += 1 diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift new file mode 100644 index 00000000..10c042cc --- /dev/null +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -0,0 +1,464 @@ +// +// 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. +// 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) + } +} +