diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index 4f55a2e92749..8be21052356b 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -25,6 +25,8 @@ #import "FirebaseCore/Extension/FirebaseCoreInternal.h" +static NSString *const kAffectedParameterKeys = @"affectedParameterKeys"; + @implementation RCNConfigContent { /// Active config data that is currently used. NSMutableDictionary *_activeConfig; @@ -398,13 +400,108 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { return true; } +/// Load active and fetched experiment payloads and return them in a map. +- (NSDictionary *> *)loadExperimentsPayloads { + __block NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; + __block NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; + + /// Load experiments from DB. + RCNDBCompletion completionHandler = ^(BOOL success, NSDictionary *result) { + if (result[@RCNExperimentTableKeyPayload]) { + experimentPayloads = [result[@RCNExperimentTableKeyPayload] mutableCopy]; + } + if (result[@RCNExperimentTableKeyActivePayload]) { + activeExperimentPayloads = [result[@RCNExperimentTableKeyActivePayload] mutableCopy]; + } + }; + [_DBManager loadExperimentWithCompletionHandler:completionHandler]; + + return @{ + @RCNExperimentTableKeyPayload : experimentPayloads, + @RCNExperimentTableKeyActivePayload : activeExperimentPayloads + }; +} + +/// Creates a map where the key is the config key and the value if the experiment description. +- (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experiments { + NSMutableDictionary *experimentsMap = + [[NSMutableDictionary alloc] init]; + + /// Iterate through all the experiments and check if they contain `affectedParameterKeys`. + for (NSData *experiment in experiments) { + NSError *error; + NSDictionary *experimentJSON = + [NSJSONSerialization JSONObjectWithData:experiment + options:NSJSONReadingMutableContainers + error:&error]; + if (!error && experimentJSON) { + if ([experimentJSON objectForKey:kAffectedParameterKeys]) { + NSMutableArray *configKeys = + (NSMutableArray *)[experimentJSON objectForKey:kAffectedParameterKeys]; + NSMutableDictionary *experimentCopy = [experimentJSON mutableCopy]; + /// Remove `affectedParameterKeys` because the values come out of order and could affect the + /// diffing. + [experimentCopy removeObjectForKey:kAffectedParameterKeys]; + + /// Map experiments to config keys. + for (NSString *key in configKeys) { + [experimentsMap setObject:experimentCopy forKey:key]; + } + } + } + } + + return experimentsMap; +} + +/// Returns keys that were affected by experiment changes. +- (NSMutableSet *) + getKeysAffectedByChangedExperiments:(NSMutableArray *)activeExperimentPayloads + fetchedExperimentPayloads:(NSMutableArray *)experimentPayloads { + NSMutableSet *changedKeys = [[NSMutableSet alloc] init]; + + /// Create config keys to experiments map. + NSMutableDictionary *activeExperimentsMap = [self createExperimentsMap:activeExperimentPayloads]; + NSMutableDictionary *fetchedExperimentsMap = [self createExperimentsMap:experimentPayloads]; + + /// Iterate through active experiment's keys and compare them to fetched experiment's keys. + for (NSString *key in [activeExperimentsMap allKeys]) { + if (![fetchedExperimentsMap objectForKey:key]) { + [changedKeys addObject:key]; + } else { + if (![[activeExperimentsMap objectForKey:key] + isEqualToDictionary:[fetchedExperimentsMap objectForKey:key]]) { + [changedKeys addObject:key]; + } + } + } + + /// Iterate through fetched experiment's keys and compare them to active experiment's keys. + for (NSString *key in [fetchedExperimentsMap allKeys]) { + if (![activeExperimentsMap objectForKey:key]) { + [changedKeys addObject:key]; + } else { + if (![[fetchedExperimentsMap objectForKey:key] + isEqualToDictionary:[activeExperimentsMap objectForKey:key]]) { + [changedKeys addObject:key]; + } + } + } + + return changedKeys; +} + // Compare fetched config with active config and output what has changed - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace { - // TODO: handle diff in experiment metadata - FIRRemoteConfigUpdate *configUpdate; NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; + NSDictionary *experiments = [self loadExperimentsPayloads]; + NSMutableSet *changedExperimentKeys = [self + getKeysAffectedByChangedExperiments:[experiments + objectForKey:@RCNExperimentTableKeyActivePayload] + fetchedExperimentPayloads:[experiments objectForKey:@RCNExperimentTableKeyPayload]]; + NSDictionary *fetchedConfig = _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init]; NSDictionary *activeConfig = @@ -439,6 +536,11 @@ - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace } } + // Add params affected by changed experiments. + for (NSString *key in changedExperimentKeys) { + [updatedKeys addObject:key]; + } + configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; return configUpdate; } diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m index d4f33bf0f714..962611259a64 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m @@ -26,6 +26,9 @@ #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h" @interface RCNConfigContent (Testing) +- (NSMutableSet *) + getKeysAffectedByChangedExperiments:(NSMutableArray *)activeExperimentPayloads + fetchedExperimentPayloads:(NSMutableArray *)experimentPayloads; - (BOOL)checkAndWaitForInitialDatabaseLoad; @end @@ -349,6 +352,129 @@ - (void)testConfigUpdate_noChange_emptyResponse { XCTAssertTrue([update updatedKeys].count == 0); } +- (void)testConfigUpdate_noParamChange_butExperimentChange { + NSString *namespace = @"test_namespace"; + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *experimentKeys = [[NSMutableSet alloc] init]; + [experimentKeys addObject:@"key_2"]; + id configMock = OCMPartialMock(configContent); + OCMStub([configMock getKeysAffectedByChangedExperiments:OCMOCK_ANY + fetchedExperimentPayloads:OCMOCK_ANY]) + .andReturn(experimentKeys); + + // populate fetched config + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"} p13nMetadata:nil]; + [configMock updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + // active config is the same as fetched config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}}; + [configMock copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [configMock getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:@"key_2"]); +} + +- (void)testExperimentDiff_addedExperiment { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@"exp_2" forKey:@"experimentId"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_changedExperimentMetadata { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@"var_2" forKey:@"variantId"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_changedExperimentKeys { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@[ @"test_key_1", @"test_key_2" ] forKey:@"affectedParameterKeys"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_2"]); +} + +- (void)testExperimentDiff_deletedExperiment { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + NSMutableArray *experimentPayloads = [@[] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_noChange { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + NSMutableArray *experimentPayloads = [@[ payloadData1 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys count] == 0); +} + - (void)testConfigUpdate_paramAdded_returnsNewKey { NSString *namespace = @"test_namespace"; NSString *newParam = @"key2"; @@ -501,4 +627,29 @@ - (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)co return fetchResponse; } ++ (NSData *)payloadDataFromTestFile { +#if SWIFT_PACKAGE + NSBundle *bundle = SWIFTPM_MODULE_BUNDLE; +#else + NSBundle *bundle = [NSBundle bundleForClass:[self class]]; +#endif + NSString *testJsonDataFilePath = [bundle pathForResource:@"TestABTPayload" ofType:@"txt"]; + NSError *readTextError = nil; + NSString *fileText = [[NSString alloc] initWithContentsOfFile:testJsonDataFilePath + encoding:NSUTF8StringEncoding + error:&readTextError]; + + NSData *fileData = [fileText dataUsingEncoding:kCFStringEncodingUTF8]; + + NSError *jsonDictionaryError = nil; + NSMutableDictionary *jsonDictionary = + [[NSJSONSerialization JSONObjectWithData:fileData + options:kNilOptions + error:&jsonDictionaryError] mutableCopy]; + NSError *jsonDataError = nil; + return [NSJSONSerialization dataWithJSONObject:jsonDictionary + options:kNilOptions + error:&jsonDataError]; +} + @end diff --git a/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt b/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt index fb0e71cc54f1..ef3ca9f76e33 100644 --- a/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt +++ b/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt @@ -15,5 +15,6 @@ { "experimentId": "exp_1" } - ] + ], + "affectedParameterKeys": ["test_key_1"] }