// Copyright 2019 Google // // 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 #import #import #import #import #import #import #import "FirebaseABTesting/Sources/ABTConditionalUserPropertyController.h" #import "FirebaseABTesting/Sources/ABTConstants.h" #import "FirebaseABTesting/Tests/Unit/ABTFakeFIRAConditionalUserPropertyController.h" #import "FirebaseABTesting/Tests/Unit/ABTTestUniversalConstants.h" extern ABTExperimentPayload *ABTDeserializeExperimentPayload(NSData *payload); extern NSArray *ABTExperimentsToSetFromPayloads( NSArray *payloads, NSArray *> *experiments, id _Nullable analytics); extern NSArray *ABTExperimentsToClearFromPayloads( NSArray *payloads, NSArray *> *experiments, id _Nullable analytics); @interface FIRExperimentController (ExposedForTest) - (void) updateExperimentConditionalUserPropertiesWithServiceOrigin:(NSString *)origin events:(FIRLifecycleEvents *)events policy: (ABTExperimentPayload_ExperimentOverflowPolicy) policy lastStartTime:(NSTimeInterval)lastStartTime payloads:(NSArray *)payloads completionHandler: (nullable void (^)(NSError *_Nullable error)) completionHandler; /// Surface internal initializer to avoid singleton usage during tests. - (instancetype)initWithAnalytics:(nullable id)analytics; @end @interface ABTConditionalUserPropertyController (ExposedForTest) - (void)maxNumberOfExperimentsOfOrigin:(NSString *)origin completionHandler:(void (^)(int32_t))completionHandler; - (int32_t)maxNumberOfExperimentsOfOrigin:(NSString *)origin; - (id)createExperimentFromOrigin:(NSString *)origin payload:(ABTExperimentPayload *)payload events:(FIRLifecycleEvents *)events; - (ABTExperimentPayload_ExperimentOverflowPolicy) overflowPolicyWithPayload:(ABTExperimentPayload *)payload originalPolicy:(ABTExperimentPayload_ExperimentOverflowPolicy)originalPolicy; @end @interface FIRExperimentControllerTest : XCTestCase { FIRExperimentController *_experimentController; ABTFakeFIRAConditionalUserPropertyController *_fakeController; id _mockCUPController; } @end @implementation FIRExperimentControllerTest - (void)setUp { [super setUp]; _fakeController = [ABTFakeFIRAConditionalUserPropertyController sharedInstance]; id fakeAnalytics = [[FakeAnalytics alloc] initWithFakeController:_fakeController]; _experimentController = [[FIRExperimentController alloc] initWithAnalytics:fakeAnalytics]; ABTConditionalUserPropertyController *controller = [ABTConditionalUserPropertyController sharedInstanceWithAnalytics:fakeAnalytics]; _mockCUPController = OCMPartialMock(controller); OCMStub([_mockCUPController maxNumberOfExperimentsOfOrigin:[OCMArg any]]).andReturn(3); } - (void)tearDown { [_fakeController resetExperiments]; [_mockCUPController stopMocking]; [super tearDown]; } - (void)testDeserializeInvalidPayload { FIRExperimentController *controller = _experimentController; XCTAssertNotNil(controller); NSString *sampleString = @"sample_invalid_payload"; NSData *invalidData = [sampleString dataUsingEncoding:NSUTF8StringEncoding]; XCTAssertNil(ABTDeserializeExperimentPayload(invalidData)); XCTAssertNotNil(ABTDeserializeExperimentPayload(nil)); } - (void)testLifecycleEvents { FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; XCTAssertEqualObjects(FIRSetExperimentEventName, events.setExperimentEventName); XCTAssertEqualObjects(FIRActivateExperimentEventName, events.activateExperimentEventName); XCTAssertEqualObjects(FIRTimeoutExperimentEventName, events.timeoutExperimentEventName); XCTAssertEqualObjects(FIRExpireExperimentEventName, events.expireExperimentEventName); XCTAssertEqualObjects(FIRClearExperimentEventName, events.clearExperimentEventName); // Should be able to override event name values. events.setExperimentEventName = @"_new_set_experiment"; XCTAssertEqualObjects(events.setExperimentEventName, @"_new_set_experiment"); events.setExperimentEventName = @"name_without_prefix"; XCTAssertEqualObjects(FIRSetExperimentEventName, events.setExperimentEventName); events.activateExperimentEventName = @"_new_activate_experiment"; XCTAssertEqualObjects(events.activateExperimentEventName, @"_new_activate_experiment"); events.activateExperimentEventName = @""; XCTAssertEqualObjects(FIRActivateExperimentEventName, events.activateExperimentEventName); events.timeoutExperimentEventName = @"__"; XCTAssertEqualObjects(events.timeoutExperimentEventName, @"__"); events.timeoutExperimentEventName = @"name_with_"; XCTAssertEqualObjects(FIRTimeoutExperimentEventName, events.timeoutExperimentEventName); events.expireExperimentEventName = @"_"; XCTAssertEqualObjects(events.expireExperimentEventName, @"_"); #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" events.expireExperimentEventName = nil; #pragma clang diagnostic pop XCTAssertEqualObjects(FIRExpireExperimentEventName, events.expireExperimentEventName); events.clearExperimentEventName = @"_new_set_experiment"; XCTAssertEqualObjects(events.clearExperimentEventName, @"_new_set_experiment"); events.clearExperimentEventName = @""; XCTAssertEqualObjects(FIRClearExperimentEventName, events.clearExperimentEventName); } - (void)testSetExperimentWithBadPayload { [[_mockCUPController reject] setExperimentWithOrigin:[OCMArg any] payload:[OCMArg any] events:[OCMArg any] policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest]; NSString *sampleString = @"sample_invalid_payload"; NSData *invalidData = [sampleString dataUsingEncoding:NSUTF8StringEncoding]; XCTAssertNil(ABTDeserializeExperimentPayload(invalidData)); } - (void)testUpdateExperiments { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v200"; payload2.experimentStartTimeMillis = (now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_1"; [payload2.ongoingExperimentsArray addObject:ongoingExperiment]; ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init]; payload3.experimentId = @"exp_3"; payload3.variantId = @"v200"; payload3.experimentStartTimeMillis = (now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_2"; [payload3.ongoingExperimentsArray addObject:ongoingExperiment]; ABTExperimentPayload *payload4 = [[ABTExperimentPayload alloc] init]; payload4.experimentId = @"exp_4"; payload4.variantId = @"v200"; payload4.experimentStartTimeMillis = (now - 900) * ABT_MSEC_PER_SEC; // start time < last start time, do not set. ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_2"; [payload4.ongoingExperimentsArray addObject:ongoingExperiment]; __block BOOL completionHandlerCalled = NO; FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; NSArray *payloads = @[ [payload2 data], [payload3 data], [payload4 data] ]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:now payloads:payloads completionHandler:^(NSError *_Nullable error) { completionHandlerCalled = YES; }]; XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2); XCTAssertTrue(completionHandlerCalled); // Second time update exp_1 no longer exist, should be cleared from experiments. payloads = @[ [payload3 data], [payload4 data] ]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:now payloads:payloads completionHandler:nil]; XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 1); } - (void)testLatestExperimentStartTimestamps { // Mock incoming payloads NSMutableArray *payloads = [[NSMutableArray alloc] init]; NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init]; payload1.experimentId = @"exp_1"; payload1.variantId = @"v3"; payload1.experimentStartTimeMillis = now * ABT_MSEC_PER_SEC; [payloads addObject:[payload1 data]]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v2"; payload2.experimentStartTimeMillis = (now + 500) * ABT_MSEC_PER_SEC; [payloads addObject:[payload2 data]]; NSString *sampleString = @"sample_invalid_payload"; NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding]; [payloads addObject:invalidPayload]; XCTAssertEqualWithAccuracy( now + 500, [_experimentController latestExperimentStartTimestampBetweenTimestamp:now + 200 andPayloads:payloads], 1); XCTAssertEqualWithAccuracy( now + 1000, [_experimentController latestExperimentStartTimestampBetweenTimestamp:now + 1000 andPayloads:payloads], 1); XCTAssertEqualWithAccuracy( now + 500, [_experimentController latestExperimentStartTimestampBetweenTimestamp:now - 10000 andPayloads:payloads], 1); } - (void)testExperimentsToSetFromPayloads { // Mock conditional user property objects in experiments. NSMutableArray *currentExperiments = [[NSMutableArray alloc] init]; NSDictionary *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"}; [currentExperiments addObject:CUP1]; NSDictionary *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"}; [currentExperiments addObject:CUP2]; // Mock incoming payloads NSMutableArray *payloads = [[NSMutableArray alloc] init]; ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init]; payload1.experimentId = @"exp_1"; payload1.variantId = @"v3"; [payloads addObject:[payload1 data]]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v2"; [payloads addObject:[payload2 data]]; NSString *sampleString = @"sample_invalid_payload"; NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding]; [payloads addObject:invalidPayload]; NSArray *experimentsToSet = ABTExperimentsToSetFromPayloads(payloads, currentExperiments, nil); XCTAssertEqual(experimentsToSet.count, 1); ABTExperimentPayload *payloadToAdd = experimentsToSet.firstObject; XCTAssertEqualObjects(payloadToAdd.experimentId, @"exp_1"); XCTAssertEqualObjects(payloadToAdd.variantId, @"v3"); } - (void)testExperimentsToClearFromPaylods { // Mock conditional user property objects in experiments. NSMutableArray *currentExperiments = [[NSMutableArray alloc] init]; NSDictionary *CUP1 = @{@"name" : @"exp_1", @"value" : @"v1"}; [currentExperiments addObject:CUP1]; NSDictionary *CUP2 = @{@"name" : @"exp_2", @"value" : @"v2"}; [currentExperiments addObject:CUP2]; // Mock incoming payloads NSMutableArray *payloads = [[NSMutableArray alloc] init]; ABTExperimentPayload *payload1 = [[ABTExperimentPayload alloc] init]; payload1.experimentId = @"exp_1"; payload1.variantId = @"v3"; [payloads addObject:[payload1 data]]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v2"; [payloads addObject:[payload2 data]]; NSString *sampleString = @"sample_invalid_payload"; NSData *invalidPayload = [sampleString dataUsingEncoding:NSUTF8StringEncoding]; [payloads addObject:invalidPayload]; NSArray *> *experimentsToClear = ABTExperimentsToClearFromPayloads(payloads, currentExperiments, nil); XCTAssertEqual(experimentsToClear.count, 1); NSDictionary *experimentToRemove = experimentsToClear.firstObject; XCTAssertEqualObjects(experimentToRemove[@"name"], @"exp_1"); XCTAssertEqualObjects(experimentToRemove[@"value"], @"v1"); } - (void)testInvalidExperiments { [[_mockCUPController reject] setExperimentWithOrigin:[OCMArg any] payload:[OCMArg any] events:[OCMArg any] policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest]; [[_mockCUPController reject] setExperimentWithOrigin:[OCMArg any] payload:[OCMArg any] events:[OCMArg any] policy:ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest]; OCMStub([_mockCUPController experimentsWithOrigin:gABTTestOrigin]).andReturn(nil); NSMutableArray *payloads = [[NSMutableArray alloc] init]; __block BOOL completionHandlerWithErrorCalled = NO; FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:-1 payloads:payloads completionHandler:^(NSError *_Nullable error) { if (error && error.code == kABTInternalErrorFailedToFetchConditionalUserProperties) { completionHandlerWithErrorCalled = YES; } }]; // Verify completion handler is still called. XCTAssertTrue(completionHandlerWithErrorCalled); } - (void)testUpdateExperimentsWithNoCompletion { id experimentControllerMock = OCMPartialMock(_experimentController); NSString *mockOrigin = @"mockOrigin"; FIRLifecycleEvents *mockLifecycleEvents = [[FIRLifecycleEvents alloc] init]; ABTExperimentPayload_ExperimentOverflowPolicy mockOverflowPolicy = ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest; NSTimeInterval mockLastStartTime = 100; NSArray *mockPayloads = @[]; [[experimentControllerMock expect] updateExperimentsWithServiceOrigin:mockOrigin events:mockLifecycleEvents policy:mockOverflowPolicy lastStartTime:mockLastStartTime payloads:mockPayloads completionHandler:nil]; // Expect that updateExperimentsWithServiceOrigin:events:policy:lastStartTime:payloads: calls the // full method with completion handler as nil. #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [experimentControllerMock updateExperimentsWithServiceOrigin:mockOrigin events:mockLifecycleEvents policy:mockOverflowPolicy lastStartTime:mockLastStartTime payloads:mockPayloads]; #pragma clang diagnostic pop [experimentControllerMock verify]; } - (void)testValidateRunningExperimentsWithEmptyArray { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v200"; payload2.experimentStartTimeMillis = (now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_1"; [payload2.ongoingExperimentsArray addObject:ongoingExperiment]; ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init]; payload3.experimentId = @"exp_3"; payload3.variantId = @"v200"; payload3.experimentStartTimeMillis = (now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_2"; [payload3.ongoingExperimentsArray addObject:ongoingExperiment]; FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; NSArray *payloads = @[ [payload2 data], [payload3 data] ]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:now payloads:payloads completionHandler:nil]; XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2); [_experimentController validateRunningExperimentsForServiceOrigin:gABTTestOrigin runningExperimentPayloads:[NSArray array]]; // Expect all experiments have been cleared. XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 0); } - (void)testValidateRunningExperimentsClearingOne { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v200"; payload2.experimentStartTimeMillis = (now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_1"; [payload2.ongoingExperimentsArray addObject:ongoingExperiment]; ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init]; payload3.experimentId = @"exp_3"; payload3.variantId = @"v200"; payload3.experimentStartTimeMillis = (now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_2"; [payload3.ongoingExperimentsArray addObject:ongoingExperiment]; FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; NSArray *payloads = @[ [payload2 data], [payload3 data] ]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:now payloads:payloads completionHandler:nil]; XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2); ABTExperimentPayload *validatingPayload2 = [[ABTExperimentPayload alloc] init]; validatingPayload2.experimentId = @"exp_2"; validatingPayload2.variantId = @"v200"; [_experimentController validateRunningExperimentsForServiceOrigin:gABTTestOrigin runningExperimentPayloads:@[ validatingPayload2 ]]; // Expect no experiments have been cleared. XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 1); } - (void)testValidateRunningExperimentsKeepingAll { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; ABTExperimentPayload *payload2 = [[ABTExperimentPayload alloc] init]; payload2.experimentId = @"exp_2"; payload2.variantId = @"v200"; payload2.experimentStartTimeMillis = (now + 1500) * ABT_MSEC_PER_SEC; // start time > last start time, do set ABTExperimentLite *ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_1"; [payload2.ongoingExperimentsArray addObject:ongoingExperiment]; ABTExperimentPayload *payload3 = [[ABTExperimentPayload alloc] init]; payload3.experimentId = @"exp_3"; payload3.variantId = @"v200"; payload3.experimentStartTimeMillis = (now + 900) * ABT_MSEC_PER_SEC; // start time > last start time, do set ongoingExperiment = [[ABTExperimentLite alloc] init]; ongoingExperiment.experimentId = @"exp_2"; [payload3.ongoingExperimentsArray addObject:ongoingExperiment]; FIRLifecycleEvents *events = [[FIRLifecycleEvents alloc] init]; NSArray *payloads = @[ [payload2 data], [payload3 data] ]; [_experimentController updateExperimentConditionalUserPropertiesWithServiceOrigin:gABTTestOrigin events:events policy: ABTExperimentPayload_ExperimentOverflowPolicy_DiscardOldest // NOLINT lastStartTime:now payloads:payloads completionHandler:nil]; XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2); ABTExperimentPayload *validatingPayload2 = [[ABTExperimentPayload alloc] init]; validatingPayload2.experimentId = @"exp_2"; validatingPayload2.variantId = @"v200"; ABTExperimentPayload *validatingPayload3 = [[ABTExperimentPayload alloc] init]; validatingPayload3.experimentId = @"exp_3"; validatingPayload3.variantId = @"v200"; [_experimentController validateRunningExperimentsForServiceOrigin:gABTTestOrigin runningExperimentPayloads:@[ validatingPayload2, validatingPayload3 ]]; // Expect no experiments have been cleared. XCTAssertEqual([_mockCUPController experimentsWithOrigin:gABTTestOrigin].count, 2); } - (void)testActivateExperiment { ABTExperimentPayload *activeExperiment = [[ABTExperimentPayload alloc] init]; activeExperiment.experimentId = @"exp_3"; activeExperiment.variantId = @"v200"; activeExperiment.triggerEvent = @"trigger"; [_experimentController activateExperiment:activeExperiment forServiceOrigin:gABTTestOrigin]; NSArray *experiments = [_mockCUPController experimentsWithOrigin:gABTTestOrigin]; FIRAConditionalUserProperty *userPropertyForExperiment = [experiments firstObject]; // Verify that the triggerEventName is cleared, making this experiment active. XCTAssertNil([userPropertyForExperiment valueForKeyPath:@"triggerEventName"]); } @end