From 022cb00389b73a65d9a54928182cd17284d19524 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 14 Jan 2026 09:40:40 +0600 Subject: [PATCH 1/4] fix: ignore UPS for cmab decision done --- DemoSwiftApp/Samples/SamplesForAPI.swift | 8 ++++---- ...timizelyUserContextTests_Decide_CMAB.swift | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/DemoSwiftApp/Samples/SamplesForAPI.swift b/DemoSwiftApp/Samples/SamplesForAPI.swift index 2ecb50c1..3d318a92 100644 --- a/DemoSwiftApp/Samples/SamplesForAPI.swift +++ b/DemoSwiftApp/Samples/SamplesForAPI.swift @@ -444,7 +444,7 @@ class SamplesForAPI { userId: "USER_123", attributes: ["country": "us"] ) - let options: [OptimizelyDecideOption] = [.ignoreCmabCache, .ignoreUserProfileService] + let options: [OptimizelyDecideOption] = [.ignoreCmabCache] let decision = await user.decideAsync(key: FLAG_KEY, options: options) print("CMAB decision: \(decision)") } @@ -462,7 +462,7 @@ class SamplesForAPI { userId: "USER_123", attributes: ["country": "us"] ) - let options: [OptimizelyDecideOption] = [.ignoreCmabCache, .ignoreUserProfileService] + let options: [OptimizelyDecideOption] = [.ignoreCmabCache] user.decideAsync(key: FLAG_KEY, options: options, completion: { decision in print("CMAB decision: \(decision)") }) @@ -494,7 +494,7 @@ class SamplesForAPI { userId: "USER_123", attributes: ["country": "us"] ) - let options: [OptimizelyDecideOption] = [.ignoreCmabCache, .ignoreUserProfileService] + let options: [OptimizelyDecideOption] = [.ignoreCmabCache] let decision = await user.decideAsync(key: FLAG_KEY, options: options) print("CMAB decision: \(decision)") } @@ -512,7 +512,7 @@ class SamplesForAPI { userId: "USER_123", attributes: ["country": "us"] ) - let options: [OptimizelyDecideOption] = [.ignoreCmabCache, .ignoreUserProfileService] + let options: [OptimizelyDecideOption] = [.ignoreCmabCache] user.decideAsync(key: FLAG_KEY, options: options, completion: { decision in print("CMAB decision: \(decision)") }) diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift index 84fa2401..eafb6fe9 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -132,7 +132,7 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) // Test multiple decisions with decideAsync - user.decideAsync(keys: featureKeys, options: [.ignoreUserProfileService]) { decisions in + user.decideAsync(keys: featureKeys) { decisions in // Verify correct number of decisions were returned XCTAssertEqual(decisions.count, 2) @@ -175,7 +175,7 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { wait(for: [expectation], timeout: 5) // Increased timeout for reliability } - func testDecideAsync_cmabWithUserProfileCahing() { + func testDecideAsync_cmabIgnoreUPSCacheing() { let expectation1 = XCTestExpectation(description: "First CMAB decision") let expectation2 = XCTestExpectation(description: "Second CMAB decision") @@ -192,17 +192,17 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { attributes: ["gender": "f", "age": 25] ) - // First decision cache into user profile + user.decideAsync(key: "feature_1") { decision in XCTAssertEqual(decision.variationKey, "a") XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) expectation1.fulfill() - // Second decision (should use cache) + // Second decision, ignore UPS, fetch decision again user.decideAsync(key: "feature_1") { decision in XCTAssertEqual(decision.variationKey, "a") - // Call count should still be 1 (cached) - XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + // Call count should be increased by 1 + XCTAssertEqual(self.mockCmabService.decisionCallCount, 2) expectation2.fulfill() } } @@ -228,17 +228,17 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { userId: kUserId, attributes: ["gender": "f", "age": 25] ) - user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .ignoreCmabCache]) { decision in + user.decideAsync(key: "feature_1", options: [.ignoreCmabCache]) { decision in XCTAssertEqual(decision.variationKey, "a") XCTAssertTrue(self.mockCmabService.ignoreCacheUsed) exp1.fulfill() } - user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .resetCmabCache]) { decision in + user.decideAsync(key: "feature_1", options: [.resetCmabCache]) { decision in XCTAssertEqual(decision.variationKey, "a") XCTAssertTrue(self.mockCmabService.resetCacheCache) exp2.fulfill() } - user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .invalidateUserCmabCache]) { decision in + user.decideAsync(key: "feature_1", options: [.invalidateUserCmabCache]) { decision in XCTAssertEqual(decision.variationKey, "a") XCTAssertTrue(self.mockCmabService.invalidateUserCmabCache) exp3.fulfill() @@ -263,7 +263,7 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { attributes: ["gender": "f", "age": 25] ) - user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .includeReasons]) { decision in + user.decideAsync(key: "feature_1", options: [.includeReasons]) { decision in XCTAssertTrue(decision.reasons.contains(LogMessage.cmabFetchFailed("exp_with_audience").reason)) expectation.fulfill() } From 6f0acc3a305dd05c21ec931daaaecc8d398c7eac Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Wed, 14 Jan 2026 09:41:43 +0600 Subject: [PATCH 2/4] fix: exclude cmab decision from ups --- Sources/Implementation/DefaultDecisionService.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index b4fa3ff2..002e66b4 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -249,6 +249,8 @@ class DefaultDecisionService: OPTDecisionService { user: user) reasons.merge(audienceResponse.reasons) + var ignoreUPS = false + if audienceResponse.result ?? false { // Acquire bucketingId let bucketingId = getBucketingId(userId: userId, attributes: attributes) @@ -262,6 +264,9 @@ class DefaultDecisionService: OPTDecisionService { options: options) reasons.merge(cmabDecisionResponse.reasons) variationDecision = cmabDecisionResponse.result + + // CMAB decision shouldn't be in the UPS + ignoreUPS = cmabDecisionResponse.result?.variation != nil } else { // bucket user into a variation let decisionResponse = bucketer.bucketExperiment(config: config, @@ -277,7 +282,10 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) - userProfileTracker?.updateProfile(experiment: experiment, variation: variation) + if !ignoreUPS { + userProfileTracker?.updateProfile(experiment: experiment, variation: variation) + } + } else { let info = LogMessage.userNotBucketedIntoVariation(userId) logger.i(info) From 823391842666a67539ef1f6d7aa348584ce3a695 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 16 Jan 2026 18:51:35 +0600 Subject: [PATCH 3/4] clean up --- Sources/Implementation/DefaultDecisionService.swift | 9 +++------ .../DecisionServiceTests_Experiments.swift | 6 +++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 002e66b4..7014e124 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -249,8 +249,6 @@ class DefaultDecisionService: OPTDecisionService { user: user) reasons.merge(audienceResponse.reasons) - var ignoreUPS = false - if audienceResponse.result ?? false { // Acquire bucketingId let bucketingId = getBucketingId(userId: userId, attributes: attributes) @@ -264,9 +262,6 @@ class DefaultDecisionService: OPTDecisionService { options: options) reasons.merge(cmabDecisionResponse.reasons) variationDecision = cmabDecisionResponse.result - - // CMAB decision shouldn't be in the UPS - ignoreUPS = cmabDecisionResponse.result?.variation != nil } else { // bucket user into a variation let decisionResponse = bucketer.bucketExperiment(config: config, @@ -282,7 +277,9 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) - if !ignoreUPS { + + // CMAB decision shouldn't be in the UPS + if !experiment.isCmab { userProfileTracker?.updateProfile(experiment: experiment, variation: variation) } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index 8217002a..c5dbd9ed 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -834,6 +834,7 @@ extension DecisionServiceTests_Experiments { self.config.project.experiments = [cmabExperiment] let mocCmabService = MockCmabService() mocCmabService.variationId = "unknown_var_id" + mocCmabService.cmabUUID = "test_UUID_1234" self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) @@ -843,8 +844,10 @@ extension DecisionServiceTests_Experiments { ) let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.cmabFetchSuccess("unknown_var_id", "test_UUID_1234", _expKey: cmabExperiment.key)) expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + let decision = self.decisionService.getVariation(config: config, experiment: cmabExperiment, user: user, @@ -885,6 +888,7 @@ fileprivate struct MockError: Error { fileprivate class MockCmabService: DefaultCmabService { var error: Error? var variationId: String? + var cmabUUID: String? init() { super.init(cmabClient: DefaultCmabClient(), cmabCache: CmabCache(size: 10, timeoutInSecs: 10)) @@ -892,7 +896,7 @@ fileprivate class MockCmabService: DefaultCmabService { override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { if let variationId = self.variationId { - let cmabUUID = UUID().uuidString + let cmabUUID = self.cmabUUID ?? UUID().uuidString return .success(CmabDecision(variationId: variationId, cmabUUID: cmabUUID)) } else { return .failure(self.error ?? MockError()) From 0670428bb496b4ee1b174d0afae28f8c7b758034 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 16 Jan 2026 19:07:53 +0600 Subject: [PATCH 4/4] Add profile tracker test case for cmab decision --- .../DecisionServiceTests_Experiments.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index c5dbd9ed..1fda868e 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -732,6 +732,38 @@ extension DecisionServiceTests_Experiments { XCTAssertNotNil(variation) XCTAssertEqual(variation?.key, kVariationKeyA) } + + func testCMABVariationDoesnotTrackByProfileTracker() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "10389729780" // kVariationKeyA + let ups = DefaultUserProfileService() + self.decisionService = DefaultDecisionService(userProfileService: ups, cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let tracker = UserProfileTracker(userId: "user_1234", userProfileService: ups, logger: self.decisionService.logger) + tracker.loadUserProfile() + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: true, + userProfileTracker: tracker) + + let variation = decision.result?.variation + XCTAssertNotNil(variation) + XCTAssertEqual(variation?.key, kVariationKeyA) + XCTAssertFalse(tracker.profileUpdated) + XCTAssertTrue(tracker.userProfile!.isEmpty) + } func testGetVariationWithCMABZeroTrafficAllocation() { // Test when traffic allocation is 0%