Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Configuration Threading Logic #807

Merged
merged 17 commits into from
Apr 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Braintree iOS SDK Release Notes

## unreleased
* Venmo
* Reduce network connection lost error frequency on older iOS and Venmo app versions
* PPDataCollector
* Allow passing isSandbox bool for data collection in `clientMetadataID` and `collectPayPalDeviceData` functions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ - (UIView *)createPaymentButton {
- (void)tappedCustomVenmo {
self.progressBlock(@"Tapped Venmo - initiating Venmo auth");
BTVenmoRequest *venmoRequest = [[BTVenmoRequest alloc] init];
[venmoRequest setVault:YES];
[venmoRequest setPaymentMethodUsage:BTVenmoPaymentMethodUsageMultiUse];
[self.venmoDriver tokenizeVenmoAccountWithVenmoRequest:venmoRequest completion:^(BTVenmoAccountNonce * _Nullable venmoAccount, NSError * _Nullable error) {
if (venmoAccount) {
self.progressBlock(@"Got a nonce 💎!");
Expand Down
2 changes: 0 additions & 2 deletions Demo/Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
803FB9DE26D93146002BF92D /* BTCardFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803FB9DD26D93146002BF92D /* BTCardFormView.swift */; };
80581A5F2553170000006F53 /* Venmo_UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80581A5D2553152A00006F53 /* Venmo_UITests.swift */; };
9C36BD2926B3071B00F0A559 /* PPRiskMagnes.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C36BD2826B3071A00F0A559 /* PPRiskMagnes.xcframework */; };
9C36BD2A26B3071B00F0A559 /* PPRiskMagnes.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9C36BD2826B3071A00F0A559 /* PPRiskMagnes.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
9C36BD4C26B311D900F0A559 /* CardinalMobile.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C36BD4826B3102B00F0A559 /* CardinalMobile.xcframework */; };
9C36BD4D26B311D900F0A559 /* CardinalMobile.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9C36BD4826B3102B00F0A559 /* CardinalMobile.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
A0988F9124DB44B20095EEEE /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A0988E7B24DB44B10095EEEE /* Localizable.strings */; };
Expand Down Expand Up @@ -110,7 +109,6 @@
803D650A256DAF9A00ACE692 /* PayPalDataCollector.framework in Embed Frameworks */,
803D64FA256DAF9A00ACE692 /* BraintreeCard.framework in Embed Frameworks */,
803D64FE256DAF9A00ACE692 /* BraintreeDataCollector.framework in Embed Frameworks */,
9C36BD2A26B3071B00F0A559 /* PPRiskMagnes.xcframework in Embed Frameworks */,
803D64FC256DAF9A00ACE692 /* BraintreeCore.framework in Embed Frameworks */,
803D6504256DAF9A00ACE692 /* BraintreeThreeDSecure.framework in Embed Frameworks */,
803D6500256DAF9A00ACE692 /* BraintreePaymentFlow.framework in Embed Frameworks */,
Expand Down
4 changes: 2 additions & 2 deletions Demo/UI Tests/Venmo UI Tests/Venmo_UITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ class Venmo_UITests: XCTestCase {
demoApp.buttons["Venmo (custom button)"].tap()
}

func testTokenizeVenmo_whenSignInSuccessfulWithPaymentContext_returnsNonce() {
func testTokenizeVenmo_whenSignInSuccessfulWithPaymentContext_returnsToApp() {
waitForElementToBeHittable(mockVenmo.buttons["SUCCESS WITH PAYMENT CONTEXT"])
mockVenmo.buttons["SUCCESS WITH PAYMENT CONTEXT"].tap()

XCTAssertTrue(demoApp.buttons["Got a nonce. Tap to make a transaction."].waitForExistence(timeout: 15))
XCTAssertTrue(demoApp.buttons["Failed to store Venmo Account in vault"].waitForExistence(timeout: 15))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This UI test if only used to test that the SDK successfully switches to the mock Venmo app and returns. Since we updated the demo app to include vaulting, this flow now ultimately fails, because the mock Venmo app does not return a valid nonce for vaulting. We decided to just update this error message, so that we can keep vaulting in the demo app flow for future testing, and since this test still covers its intended scope.

}

func testTokenizeVenmo_whenSignInSuccessfulWithoutPaymentContext_returnsNonce() {
Expand Down
110 changes: 46 additions & 64 deletions Sources/BraintreeCore/BTAPIClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ - (nullable instancetype)initWithAuthorization:(NSString *)authorization sendAna
configurationCache = [[NSURLCache alloc] initWithMemoryCapacity:1 * 1024 * 1024 diskCapacity:0 diskPath:nil];
});
configuration.URLCache = configurationCache;
configuration.requestCachePolicy = NSURLRequestReturnCacheDataElseLoad;
// Use the caching logic defined in the protocol implementation, if any, for a particular URL load request.
configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

random q: does config endpoint enforce caching with HTTP headers?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean like passing a Cache-Control header? If so the preferred method for NSURLSession is to use the requestCachePolicy on the configuration as there is some weirdness with headers not being respected (for example NSURLSession also ignores Keep-Alive headers).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right yeah I guess that's a question for the gateway actually. If config has caching behavior built in we could leverage that on Android too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah certainly something to look into for both SDKs

_configurationHTTP.session = [NSURLSession sessionWithConfiguration:configuration];

// Kickoff the background request to fetch the config
Expand Down Expand Up @@ -273,75 +274,56 @@ - (void)fetchPaymentMethodNonces:(BOOL)defaultFirst completion:(void (^)(NSArray
#pragma mark - Remote Configuration

- (void)fetchOrReturnRemoteConfiguration:(void (^)(BTConfiguration *, NSError *))completionBlock {
// Guarantee that multiple calls to this method will successfully obtain configuration exactly once.
// Fetches or returns the configuration and caches the response in the GET BTHTTP call if successful
//
// Rules:
// - If cachedConfiguration is present, return it without a request
// - If cachedConfiguration is not present, fetch it and cache the succesful response
// - If fetching fails, return error and the next queued will try to fetch again
//
// Note: Configuration queue is SERIAL. This helps ensure that each request for configuration
// is processed independently. Thus, the check for cached configuration and the fetch is an
// atomic operation with respect to other calls to this method.
//
// Note: Uses dispatch_semaphore to block the configuration queue when the configuration fetch
// request is waiting to return. In this context, it is OK to block, as the configuration
// queue is a background queue to guarantee atomic access to the remote configuration resource.
dispatch_async(self.configurationQueue, ^{
__block NSError *fetchError;

dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
jaxdesmarais marked this conversation as resolved.
Show resolved Hide resolved
__block BTConfiguration *configuration;
NSString *configPath = @"v1/configuration"; // Default for tokenizationKey
if (self.clientToken) {
configPath = [self.clientToken.configURL absoluteString];
}
[self.configurationHTTP GET:configPath parameters:@{ @"configVersion": @"3" } shouldCache:YES completion:^(BTJSON * _Nullable body, NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
fetchError = error;
} else if (response.statusCode != 200) {
NSError *configurationDomainError =
[NSError errorWithDomain:BTAPIClientErrorDomain
code:BTAPIClientErrorTypeConfigurationUnavailable
userInfo:@{
NSLocalizedFailureReasonErrorKey: @"Unable to fetch remote configuration from Braintree API at this time."
}];
fetchError = configurationDomainError;
} else {
configuration = [[BTConfiguration alloc] initWithJSON:body];
if (!self.braintreeAPI) {
NSURL *apiURL = [configuration.json[@"braintreeApi"][@"url"] asURL];
NSString *accessToken = [configuration.json[@"braintreeApi"][@"accessToken"] asString];
self.braintreeAPI = [[BTAPIHTTP alloc] initWithBaseURL:apiURL accessToken:accessToken];
}
if (!self.http) {
NSURL *baseURL = [configuration.json[@"clientApiUrl"] asURL];
if (self.clientToken) {
self.http = [[BTHTTP alloc] initWithBaseURL:baseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
} else if (self.tokenizationKey) {
self.http = [[BTHTTP alloc] initWithBaseURL:baseURL tokenizationKey:self.tokenizationKey];
}
// - If cachedConfiguration is not present, fetch it and cache the successful response
// - If fetching fails, return error
NSString *configPath = @"v1/configuration"; // Default for tokenizationKey
if (self.clientToken) {
configPath = [self.clientToken.configURL absoluteString];
}
[self.configurationHTTP GET:configPath parameters:@{ @"configVersion": @"3" } shouldCache:YES completion:^(BTJSON * _Nullable body, NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
NSError *fetchError;
BTConfiguration *configuration;

if (error) {
fetchError = error;
} else if (response.statusCode != 200) {
NSError *configurationDomainError =
[NSError errorWithDomain:BTAPIClientErrorDomain
code:BTAPIClientErrorTypeConfigurationUnavailable
userInfo:@{
NSLocalizedFailureReasonErrorKey: @"Unable to fetch remote configuration from Braintree API at this time."
}];
fetchError = configurationDomainError;
} else {
configuration = [[BTConfiguration alloc] initWithJSON:body];
if (!self.braintreeAPI) {
NSURL *apiURL = [configuration.json[@"braintreeApi"][@"url"] asURL];
NSString *accessToken = [configuration.json[@"braintreeApi"][@"accessToken"] asString];
self.braintreeAPI = [[BTAPIHTTP alloc] initWithBaseURL:apiURL accessToken:accessToken];
}
if (!self.http) {
NSURL *baseURL = [configuration.json[@"clientApiUrl"] asURL];
if (self.clientToken) {
self.http = [[BTHTTP alloc] initWithBaseURL:baseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
} else if (self.tokenizationKey) {
self.http = [[BTHTTP alloc] initWithBaseURL:baseURL tokenizationKey:self.tokenizationKey];
}
if (!self.graphQL) {
NSURL *graphQLBaseURL = [BTAPIClient graphQLURLForEnvironment:configuration.environment];
if (self.clientToken) {
self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
} else if (self.tokenizationKey) {
self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL tokenizationKey:self.tokenizationKey];
}
}
if (!self.graphQL) {
NSURL *graphQLBaseURL = [BTAPIClient graphQLURLForEnvironment:configuration.environment];
if (self.clientToken) {
self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
} else if (self.tokenizationKey) {
self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL tokenizationKey:self.tokenizationKey];
}
}

// Important: Unlock semaphore in all cases
dispatch_semaphore_signal(semaphore);
}];

dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

dispatch_async(dispatch_get_main_queue(), ^{
completionBlock(configuration, fetchError);
});
});
}
completionBlock(configuration, fetchError);
}];
}

#pragma mark - Analytics
Expand Down
22 changes: 0 additions & 22 deletions Sources/BraintreeCore/BTAnalyticsService.m
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ - (instancetype)initWithAPIClient:(BTAPIClient *)apiClient {
_sessionsQueue = dispatch_queue_create("com.braintreepayments.BTAnalyticsService", DISPATCH_QUEUE_SERIAL);
_apiClient = apiClient;
_flushThreshold = 1;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillResign:) name:UIApplicationWillResignActiveNotification object:nil];
}
return self;
}
Expand Down Expand Up @@ -241,27 +240,6 @@ - (void)flush:(void (^)(NSError *))completionBlock {
}];
}

#pragma mark - Private methods

- (void)appWillResign:(NSNotification *)notification {
UIApplication *application = notification.object;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qq: Are analytics all processed in the foreground now?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't change the thread where analytics are being processed which is done in the sendAnalyticsEvent function. Here we were unnecessarily flushing analytics on the background thread and updating this results in all of the analytics events being sent as expected, so was unnecessarily adding strain.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah background tasks do still run when the app is closed by the user, it may be good to do this in the future. On Android we're using WorkManager to do timed uploads every 30 seconds, I'm wondering if iOS has something similar.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, previously we were getting a ton of flush analytics errors which this resolves. I think we should certainly look into something similar in the future. When we re-write the core module in Swift I think there is certainly a lot of optimization to be done as we move to newer APIs with those changes in the future.


__block UIBackgroundTaskIdentifier bgTask;
bgTask = [application beginBackgroundTaskWithName:@"BTAnalyticsService" expirationHandler:^{
[[BTLogger sharedLogger] warning:@"Analytics service background task expired"];
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];

// Start the long-running task and return immediately.
dispatch_async(self.sessionsQueue, ^{
[self flush:^(__unused NSError * _Nullable error) {
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];
});
}

#pragma mark - Helpers

- (void)enqueueEvent:(NSString *)eventKind {
Expand Down
22 changes: 13 additions & 9 deletions Sources/BraintreeCore/BTHTTP.m
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,19 @@ - (void)httpRequestWithCaching:(NSString *)method path:(NSString *)aPath paramet
[[NSURLCache sharedURLCache] removeAllCachedResponses];
cachedResponse = nil;
}

if (cachedResponse != nil) {
[self handleRequestCompletion:cachedResponse.data request:nil shouldCache:NO response:cachedResponse.response error:nil completionBlock:completionBlock];
} else {
NSURLSessionTask *task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
[self handleRequestCompletion:data request:request shouldCache:YES response:response error:error completionBlock:completionBlock];
}];
[task resume];
}

// The increase in speed of API calls with cached configuration caused an increase in "network connection lost" errors.
// Adding this delay allows us to throttle the network requests slightly to reduce load on the servers and decrease connection lost errors.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to put the 0.1 as a constant somewhere? What are the odds that we will want to change this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. we probably shouldn't change this value without the same amount of testing we did initially (we also tested smaller increments and this seemed to be the shortest amount of time that improved the issue). It's also not used elsewhere so I think leaving it here is ok, but happy to change it if others disagree

if (cachedResponse != nil) {
[self handleRequestCompletion:cachedResponse.data request:nil shouldCache:NO response:cachedResponse.response error:nil completionBlock:completionBlock];
} else {
NSURLSessionTask *task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
[self handleRequestCompletion:data request:request shouldCache:YES response:response error:error completionBlock:completionBlock];
}];
[task resume];
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the httpRequestWithCaching: only used for BTConfiguration requests?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! We only want to cache configuration requests (not anything else)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the overall solution to prevent multiple BT API requests within a 1 second timeframe?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, essentially we are throttling this request by 0.1 seconds

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh aight Analytics HTTP requests happen as soon as they're triggered on iOS that is rough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't mean to nit I genuinely have questions lol. Did we choose 0.1 because it worked best in testing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we tried 0.05 and 0.075 but we still saw an increased number of Network connection lost errors. 0.1 seems to be the least amount of time that still improved the errors.

}];
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/BraintreeVenmo/BTVenmoDriver.m
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ @interface BTVenmoDriver ()

NSString * const BTVenmoDriverErrorDomain = @"com.braintreepayments.BTVenmoDriverErrorDomain";
NSString * const BTVenmoAppStoreUrl = @"https://itunes.apple.com/us/app/venmo-send-receive-money/id351727428";
NSInteger const NetworkConnectionLostCode = -1005;

@implementation BTVenmoDriver

Expand Down Expand Up @@ -159,6 +160,9 @@ - (void)tokenizeVenmoAccountWithVenmoRequest:(BTVenmoRequest *)venmoRequest comp

[self.apiClient POST:@"" parameters:params httpType:BTAPIClientHTTPTypeGraphQLAPI completion:^(BTJSON *body, __unused NSHTTPURLResponse *response, NSError *err) {
if (err) {
if (err.code == NetworkConnectionLostCode) {
[self.apiClient sendAnalyticsEvent:@"ios.pay-with-venmo.network-connection.failure"];
}
NSError *error = [NSError errorWithDomain:BTVenmoDriverErrorDomain
code:BTVenmoDriverErrorTypeInvalidRequestURL
userInfo:@{NSLocalizedDescriptionKey: @"Failed to fetch a Venmo paymentContextID while constructing the requestURL."}];
Expand Down Expand Up @@ -210,6 +214,9 @@ - (void)vaultVenmoAccountNonce:(NSString *)nonce {
parameters:params
completion:^(BTJSON *body, __unused NSHTTPURLResponse *response, NSError *error) {
if (error) {
if (error.code == NetworkConnectionLostCode) {
[self.apiClient sendAnalyticsEvent:@"ios.pay-with-venmo.network-connection.failure"];
}
[self.apiClient sendAnalyticsEvent:@"ios.pay-with-venmo.vault.failure"];
self.appSwitchCompletionBlock(nil, error);
} else {
Expand Down Expand Up @@ -280,6 +287,9 @@ - (void)handleOpenURL:(NSURL *)url {

[self.apiClient POST:@"" parameters:params httpType:BTAPIClientHTTPTypeGraphQLAPI completion:^(BTJSON *body, __unused NSHTTPURLResponse *response, NSError *error) {
if (error) {
if (error.code == NetworkConnectionLostCode) {
[self.apiClient sendAnalyticsEvent:@"ios.pay-with-venmo.network-connection.failure"];
}
[self.apiClient sendAnalyticsEvent:@"ios.pay-with-venmo.appswitch.handle.client-failure"];
self.appSwitchCompletionBlock(nil, error);
self.appSwitchCompletionBlock = nil;
Expand Down
2 changes: 1 addition & 1 deletion UnitTests/BraintreeCoreTests/BTAnalyticsMetadataSpec.m
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
});
describe(@"deviceModel", ^{
it(@"returns the device model", ^{
expect([BTAnalyticsMetadata metadata][@"deviceModel"]).to.match(@"iPhone\\d,\\d|i386|x86_64");
expect([BTAnalyticsMetadata metadata][@"deviceModel"]).to.match(@"iPhone\\d,\\d|i386|x86_64|arm64");
});
});

Expand Down
27 changes: 0 additions & 27 deletions UnitTests/BraintreeCoreTests/BTAnalyticsService_Tests.m
Original file line number Diff line number Diff line change
Expand Up @@ -224,33 +224,6 @@ - (void)testAnalyticsService_afterConfigurationError_maintainsQueuedEventsUntilC
[self waitForExpectationsWithTimeout:2 handler:nil];
}

- (void)testAnalyticsService_whenAppIsBackgrounded_sendsQueuedAnalyticsEvents {
MockAPIClient *stubAPIClient = [self stubbedAPIClientWithAnalyticsURL:@"test://do-not-send.url"];
FakeHTTP *mockAnalyticsHTTP = [FakeHTTP fakeHTTP];
BTAnalyticsService *analyticsService = [[BTAnalyticsService alloc] initWithAPIClient:stubAPIClient];
analyticsService.flushThreshold = 5;
analyticsService.http = mockAnalyticsHTTP;

[analyticsService sendAnalyticsEvent:@"an.analytics.event"];
[analyticsService sendAnalyticsEvent:@"another.analytics.event"];
// Pause briefly to allow analytics service to dispatch async blocks
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
[[NSNotificationCenter defaultCenter] postNotificationName:UIApplicationWillResignActiveNotification object:nil];

[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];

XCTAssertTrue(mockAnalyticsHTTP.POSTRequestCount == 1);
XCTAssertEqualObjects(mockAnalyticsHTTP.lastRequestEndpoint, @"/");
XCTAssertEqualObjects(mockAnalyticsHTTP.lastRequestParameters[@"analytics"][0][@"kind"], @"an.analytics.event");
XCTAssertGreaterThanOrEqual([mockAnalyticsHTTP.lastRequestParameters[@"analytics"][0][@"timestamp"] unsignedIntegerValue], self.currentTime);
XCTAssertLessThanOrEqual([mockAnalyticsHTTP.lastRequestParameters[@"analytics"][0][@"timestamp"] unsignedIntegerValue], self.oneSecondLater);

XCTAssertEqualObjects(mockAnalyticsHTTP.lastRequestParameters[@"analytics"][1][@"kind"], @"another.analytics.event");
XCTAssertGreaterThanOrEqual([mockAnalyticsHTTP.lastRequestParameters[@"analytics"][1][@"timestamp"] unsignedIntegerValue], self.currentTime);
XCTAssertLessThanOrEqual([mockAnalyticsHTTP.lastRequestParameters[@"analytics"][1][@"timestamp"] unsignedIntegerValue], self.oneSecondLater);
[self validateMetaParameters:mockAnalyticsHTTP.lastRequestParameters[@"_meta"]];
}

#pragma mark - Helpers

- (MockAPIClient *)stubbedAPIClientWithAnalyticsURL:(NSString *)analyticsURL {
Expand Down
Loading