diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 2183ac6362e..f01609e425a 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased +- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment + or variant assignment changes, ensuring more accurate A/B test analytics and + consistent user experiences. + # 12.12.0 - [added] Introduced a new `configUpdates` property to `RemoteConfig` that provides an `AsyncSequence` for consuming real-time config updates. diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.h b/FirebaseRemoteConfig/Sources/RCNConfigContent.h index e8410074b30..bd0f2bc650b 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.h @@ -71,6 +71,7 @@ typedef NS_ENUM(NSInteger, RCNDBSource) { - (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler; /// Returns the updated parameters between fetched and active config. -- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace; +- (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace + completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler; @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index 90eba544205..d49104b180a 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; @@ -125,13 +127,15 @@ - (void)loadConfigFromMainTable { completionHandler:^( BOOL success, NSDictionary *fetchedConfig, NSDictionary *activeConfig, NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) { - self->_fetchedConfig = [fetchedConfig mutableCopy]; - self->_activeConfig = [activeConfig mutableCopy]; - self->_defaultConfig = [defaultConfig mutableCopy]; - self->_fetchedRolloutMetadata = - [rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata] copy]; - self->_activeRolloutMetadata = - [rolloutMetadata[@RCNRolloutTableKeyActiveMetadata] copy]; + @synchronized(self) { + self->_fetchedConfig = [fetchedConfig mutableCopy]; + self->_activeConfig = [activeConfig mutableCopy]; + self->_defaultConfig = [defaultConfig mutableCopy]; + self->_fetchedRolloutMetadata = + [rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata] copy]; + self->_activeRolloutMetadata = + [rolloutMetadata[@RCNRolloutTableKeyActiveMetadata] copy]; + } dispatch_group_leave(self->_dispatch_group); }]; @@ -141,8 +145,10 @@ - (void)loadConfigFromMainTable { loadPersonalizationWithCompletionHandler:^( BOOL success, NSDictionary *fetchedPersonalization, NSDictionary *activePersonalization, NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) { - self->_fetchedPersonalization = [fetchedPersonalization copy]; - self->_activePersonalization = [activePersonalization copy]; + @synchronized(self) { + self->_fetchedPersonalization = [fetchedPersonalization copy]; + self->_activePersonalization = [activePersonalization copy]; + } dispatch_group_leave(self->_dispatch_group); }]; } @@ -286,13 +292,17 @@ - (void)updateConfigContentWithResponse:(NSDictionary *)response } - (void)activatePersonalization { - _activePersonalization = _fetchedPersonalization; + @synchronized(self) { + _activePersonalization = _fetchedPersonalization; + } [_DBManager insertOrUpdatePersonalizationConfig:_activePersonalization fromSource:RCNDBSourceActive]; } - (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler { - _activeRolloutMetadata = _fetchedRolloutMetadata; + @synchronized(self) { + _activeRolloutMetadata = _fetchedRolloutMetadata; + } [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyActiveMetadata value:_activeRolloutMetadata completionHandler:^(BOOL success, NSDictionary *result) { @@ -336,22 +346,25 @@ - (void)handleUpdateStateForConfigNamespace:(NSString *)currentNamespace [_DBManager deleteRecordFromMainTableWithNamespace:currentNamespace bundleIdentifier:_bundleIdentifier fromSource:RCNDBSourceFetched]; - if ([_fetchedConfig objectForKey:currentNamespace]) { - [_fetchedConfig[currentNamespace] removeAllObjects]; - } else { - _fetchedConfig[currentNamespace] = [[NSMutableDictionary alloc] init]; - } - // Store the fetched config values. - for (NSString *key in entries) { - NSData *valueData = [entries[key] dataUsingEncoding:NSUTF8StringEncoding]; - if (!valueData) { - continue; + @synchronized(self) { + if ([_fetchedConfig objectForKey:currentNamespace]) { + [_fetchedConfig[currentNamespace] removeAllObjects]; + } else { + _fetchedConfig[currentNamespace] = [[NSMutableDictionary alloc] init]; + } + + // Store the fetched config values. + for (NSString *key in entries) { + NSData *valueData = [entries[key] dataUsingEncoding:NSUTF8StringEncoding]; + if (!valueData) { + continue; + } + _fetchedConfig[currentNamespace][key] = + [[FIRRemoteConfigValue alloc] initWithData:valueData source:FIRRemoteConfigSourceRemote]; + NSArray *values = @[ _bundleIdentifier, currentNamespace, key, valueData ]; + [self updateMainTableWithValues:values fromSource:RCNDBSourceFetched]; } - _fetchedConfig[currentNamespace][key] = - [[FIRRemoteConfigValue alloc] initWithData:valueData source:FIRRemoteConfigSourceRemote]; - NSArray *values = @[ _bundleIdentifier, currentNamespace, key, valueData ]; - [self updateMainTableWithValues:values fromSource:RCNDBSourceFetched]; } } @@ -359,7 +372,9 @@ - (void)handleUpdatePersonalization:(NSDictionary *)metadata { if (!metadata) { return; } - _fetchedPersonalization = metadata; + @synchronized(self) { + _fetchedPersonalization = metadata; + } [_DBManager insertOrUpdatePersonalizationConfig:metadata fromSource:RCNDBSourceFetched]; } @@ -367,7 +382,9 @@ - (void)handleUpdateRolloutFetchedMetadata:(NSArray *)metadata { if (!metadata) { metadata = [[NSArray alloc] init]; } - _fetchedRolloutMetadata = metadata; + @synchronized(self) { + _fetchedRolloutMetadata = metadata; + } [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata value:metadata completionHandler:nil]; @@ -434,70 +451,205 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { return true; } -// 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 *fetchedConfig = - _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init]; - NSDictionary *activeConfig = - _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init]; - NSDictionary *fetchedP13n = _fetchedPersonalization; - NSDictionary *activeP13n = _activePersonalization; - NSArray *fetchedRolloutMetadata = _fetchedRolloutMetadata; - NSArray *activeRolloutMetadata = _activeRolloutMetadata; - - // add new/updated params - for (NSString *key in [fetchedConfig allKeys]) { - if (activeConfig[key] == nil || - ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { - [updatedKeys addObject:key]; +/// Load active and fetched experiment payloads asynchronously. +- (void)loadExperimentsPayloadsWithCompletion: + (void (^)(NSDictionary *> *payloads))completion { + if (!_DBManager) { + if (completion) { + completion(@{ + @RCNExperimentTableKeyPayload : [[NSMutableArray alloc] init], + @RCNExperimentTableKeyActivePayload : [[NSMutableArray alloc] init] + }); } + return; } - // add deleted params - for (NSString *key in [activeConfig allKeys]) { - if (fetchedConfig[key] == nil) { - [updatedKeys addObject:key]; + + __weak RCNConfigContent *weakSelf = self; + [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { + RCNConfigContent *strongSelf = weakSelf; + if (!strongSelf) { + if (completion) { + completion(@{ + @RCNExperimentTableKeyPayload : [[NSMutableArray alloc] init], + @RCNExperimentTableKeyActivePayload : [[NSMutableArray alloc] init] + }); + } + return; } - } - // add params with new/updated p13n metadata - for (NSString *key in [fetchedP13n allKeys]) { - if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { - [updatedKeys addObject:key]; + NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; + NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; + + if (success && result) { + experimentPayloads = + [result[@RCNExperimentTableKeyPayload] mutableCopy] ?: experimentPayloads; + activeExperimentPayloads = + [result[@RCNExperimentTableKeyActivePayload] mutableCopy] ?: activeExperimentPayloads; } - } - // add params with deleted p13n metadata - for (NSString *key in [activeP13n allKeys]) { - if (fetchedP13n[key] == nil) { - [updatedKeys addObject:key]; + + NSDictionary *payloads = @{ + @RCNExperimentTableKeyPayload : experimentPayloads, + @RCNExperimentTableKeyActivePayload : activeExperimentPayloads + }; + + if (completion) { + completion(payloads); } - } + }]; +} - NSDictionary *fetchedRollouts = - [self getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; - NSDictionary *activeRollouts = - [self getParameterKeyToRolloutMetadata:activeRolloutMetadata]; +/// Creates a map where the key is the config key and the value is the experiment description. +- (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experiments { + NSMutableDictionary *experimentsMap = + [[NSMutableDictionary alloc] init]; - // add params with new/updated rollout metadata - for (NSString *key in [fetchedRollouts allKeys]) { - if (activeRollouts[key] == nil || - ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) { - [updatedKeys addObject:key]; + /// 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 isKindOfClass:[NSDictionary class]]) { + NSArray *configKeys = experimentJSON[kAffectedParameterKeys]; + if ([configKeys isKindOfClass:[NSArray class]]) { + 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]; + } + } } } - // add params with deleted rollout metadata - for (NSString *key in [activeRollouts allKeys]) { - if (fetchedRollouts[key] == nil) { - [updatedKeys addObject:key]; + + return experimentsMap; +} + +- (NSMutableSet *)getKeysAffectedByChangedExperiments:(NSMutableArray *)activePayloads + fetchedExperimentPayloads: + (NSMutableArray *)fetchedPayloads { + NSMutableSet *changedKeys = [[NSMutableSet alloc] init]; + + /// Create config keys to experiments map. + NSDictionary *activeMap = [self createExperimentsMap:activePayloads]; + NSDictionary *fetchedMap = [self createExperimentsMap:fetchedPayloads]; + + /// Combine all unique keys from both maps to iterate exactly once per key + NSMutableSet *allKeys = [NSMutableSet setWithArray:[activeMap allKeys]]; + [allKeys addObjectsFromArray:[fetchedMap allKeys]]; + + for (NSString *key in allKeys) { + NSDictionary *activeExp = activeMap[key]; + NSDictionary *fetchedExp = fetchedMap[key]; + + // If one exists and the other doesn't, or if they both exist but differ + if (![activeExp isEqual:fetchedExp]) { + [changedKeys addObject:key]; } } - configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; - return configUpdate; + return changedKeys; +} + +// Compare fetched config with active config and output what has changed +- (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace + completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler { + NSDictionary *fetchedConfig; + NSDictionary *activeConfig; + NSDictionary *fetchedP13n; + NSDictionary *activeP13n; + NSArray *fetchedRolloutMetadata; + NSArray *activeRolloutMetadata; + + // Fully synchronize direct reads of shared state to prevent data races. + @synchronized(self) { + fetchedConfig = [self->_fetchedConfig[FIRNamespace] copy] ?: @{}; + activeConfig = [self->_activeConfig[FIRNamespace] copy] ?: @{}; + fetchedP13n = [self->_fetchedPersonalization copy] ?: @{}; + activeP13n = [self->_activePersonalization copy] ?: @{}; + fetchedRolloutMetadata = [self->_fetchedRolloutMetadata copy] ?: @[]; + activeRolloutMetadata = [self->_activeRolloutMetadata copy] ?: @[]; + } + + __weak RCNConfigContent *weakSelf = self; + [self loadExperimentsPayloadsWithCompletion:^(NSDictionary *experiments) { + RCNConfigContent *strongSelf = weakSelf; + if (!strongSelf) { + if (completionHandler) { + completionHandler(nil); + } + return; + } + + NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; + + // add new/updated params + // Note: We use the captured immutable snapshots here safely. + for (NSString *key in [fetchedConfig allKeys]) { + if (activeConfig[key] == nil || + ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { + [updatedKeys addObject:key]; + } + } + // add deleted params + for (NSString *key in [activeConfig allKeys]) { + if (fetchedConfig[key] == nil) { + [updatedKeys addObject:key]; + } + } + + // add params with new/updated p13n metadata + for (NSString *key in [fetchedP13n allKeys]) { + if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { + [updatedKeys addObject:key]; + } + } + // add params with deleted p13n metadata + for (NSString *key in [activeP13n allKeys]) { + if (fetchedP13n[key] == nil) { + [updatedKeys addObject:key]; + } + } + + NSDictionary *fetchedRollouts = + [strongSelf getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; + NSDictionary *activeRollouts = + [strongSelf getParameterKeyToRolloutMetadata:activeRolloutMetadata]; + + // add params with new/updated rollout metadata + for (NSString *key in [fetchedRollouts allKeys]) { + if (activeRollouts[key] == nil || + ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) { + [updatedKeys addObject:key]; + } + } + // add params with deleted rollout metadata + for (NSString *key in [activeRollouts allKeys]) { + if (fetchedRollouts[key] == nil) { + [updatedKeys addObject:key]; + } + } + + // add params with updated experiment metadata + if (experiments) { + NSMutableSet *experimentKeys = [strongSelf + getKeysAffectedByChangedExperiments:[experiments + objectForKey:@RCNExperimentTableKeyActivePayload] + fetchedExperimentPayloads:[experiments + objectForKey:@RCNExperimentTableKeyPayload]]; + [updatedKeys unionSet:experimentKeys]; + } + + FIRRemoteConfigUpdate *configUpdate = + [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; + if (completionHandler) { + completionHandler(configUpdate); + } + }]; } - (NSDictionary *)getParameterKeyToRolloutMetadata: diff --git a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m index 1df80385972..813e638b02d 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m @@ -516,13 +516,16 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties if (!data) { FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000043", @"RCN Fetch: No data in fetch response"); // There may still be a difference between fetched and active config - FIRRemoteConfigUpdate *update = - [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; - return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess - withUpdate:update - withError:nil - completionHandler:completionHandler - updateCompletionHandler:updateCompletionHandler]; + [strongSelf->_content + getConfigUpdateForNamespace:strongSelf->_FIRNamespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; + }]; + return; } // Config fetch succeeded. @@ -581,7 +584,9 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]]; } - strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig]; + @synchronized(strongSelf) { + strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig]; + } } else { FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000063", @"Empty response with no fetched config."); @@ -594,17 +599,22 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties strongSelf->_settings.lastETag = latestETag; } // Compute config update after successful fetch - FIRRemoteConfigUpdate *update = - [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; - - [strongSelf->_settings - updateMetadataWithFetchSuccessStatus:YES - templateVersion:strongSelf->_templateVersionNumber]; - return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess - withUpdate:update - withError:nil - completionHandler:completionHandler - updateCompletionHandler:updateCompletionHandler]; + NSString *templateVersionNumber; + @synchronized(strongSelf) { + templateVersionNumber = strongSelf->_templateVersionNumber; + } + [strongSelf->_content + getConfigUpdateForNamespace:strongSelf->_FIRNamespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [strongSelf->_settings + updateMetadataWithFetchSuccessStatus:YES + templateVersion:templateVersionNumber]; + [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; + }]; }); }; diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m index 68320408ff3..76ad07dade4 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m @@ -27,6 +27,9 @@ @import FirebaseRemoteConfigInterop; @interface RCNConfigContent (Testing) +- (NSMutableSet *) + getKeysAffectedByChangedExperiments:(NSMutableArray *)activeExperimentPayloads + fetchedExperimentPayloads:(NSMutableArray *)experimentPayloads; - (BOOL)checkAndWaitForInitialDatabaseLoad; @end @@ -340,7 +343,7 @@ - (void)testConfigUpdate_noChange_emptyResponse { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - // active config is the same as fetched config + // active config to match fetched config FIRRemoteConfigValue *value = [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] source:FIRRemoteConfigSourceRemote]; @@ -349,9 +352,147 @@ - (void)testConfigUpdate_noChange_emptyResponse { toSource:RCNDBSourceActive forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Config update completion"]; + + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertNotNil(update, @"Update object should not be nil"); + XCTAssertEqual(update.updatedKeys.count, 0, + @"There should be no updated keys when configs match"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (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 + rolloutMetadata: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]; + + // Create expectation for async callback + XCTestExpectation *expectation = [self expectationWithDescription:@"Config update completion"]; + + [configMock getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:@"key_2"]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (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"]); +} - XCTAssertTrue([update updatedKeys].count == 0); +- (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 { @@ -374,10 +515,14 @@ - (void)testConfigUpdate_paramAdded_returnsNewKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:newParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey { @@ -402,10 +547,14 @@ - (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_paramDeleted_returnsDeletedKey { @@ -430,11 +579,16 @@ - (void)testConfigUpdate_paramDeleted_returnsDeletedKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 2); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); // deleted - XCTAssertTrue([[update updatedKeys] containsObject:newParam]); // added + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent + getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 2); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); // deleted + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); // added + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_p13nMetadataUpdated_returnsKey { @@ -466,10 +620,14 @@ - (void)testConfigUpdate_p13nMetadataUpdated_returnsKey { forKey:RCNFetchResponseKeyPersonalizationMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataUpdated_returnsKey { @@ -519,11 +677,15 @@ - (void)testConfigUpdate_rolloutMetadataUpdated_returnsKey { [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 2); - XCTAssertTrue([[update updatedKeys] containsObject:key1]); - XCTAssertTrue([[update updatedKeys] containsObject:key2]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 2); + XCTAssertTrue([[update updatedKeys] containsObject:key1]); + XCTAssertTrue([[update updatedKeys] containsObject:key2]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataDeleted_returnsKey { @@ -565,10 +727,14 @@ - (void)testConfigUpdate_rolloutMetadataDeleted_returnsKey { [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:key2]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:key2]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataDeletedAll_returnsKey { @@ -606,12 +772,17 @@ - (void)testConfigUpdate_rolloutMetadataDeletedAll_returnsKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:updateFetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - [_configContent activateRolloutMetadata:nil]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:key]); - XCTAssertTrue(_configContent.activeRolloutMetadata.count == 0); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [self->_configContent activateRolloutMetadata:nil]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:key]); + XCTAssertTrue(self->_configContent.activeRolloutMetadata.count == 0); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_valueSourceChanged_returnsKey { @@ -635,10 +806,14 @@ - (void)testConfigUpdate_valueSourceChanged_returnsKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } #pragma mark - Test Helpers @@ -660,4 +835,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:NSUTF8StringEncoding]; + + 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 fb0e71cc54f..ef3ca9f76e3 100644 --- a/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt +++ b/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt @@ -15,5 +15,6 @@ { "experimentId": "exp_1" } - ] + ], + "affectedParameterKeys": ["test_key_1"] }