diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.m index d75a45314d..b85b202bbf 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Test/TestSetupUtils.m @@ -70,7 +70,23 @@ + (SFSDKTestCredentialsData *)populateAuthCredentialsFromString:(NSString *)test } + (void)synchronousAuthRefresh { - [self synchronousAuthRefreshWithUserDidLoginNotification:NO]; + [self synchronousAuthRefreshWithRetries:3]; +} + ++ (void)synchronousAuthRefreshWithRetries:(NSInteger)maxRetries { + for (NSInteger attempt = 1; attempt <= maxRetries; attempt++) { + @try { + [self synchronousAuthRefreshWithUserDidLoginNotification:NO]; + return; + } @catch (NSException *exception) { + if (attempt < maxRetries) { + NSLog(@"[TestSetupUtils] Auth refresh attempt %ld failed: %@. Retrying in 3s...", (long)attempt, exception.reason); + [NSThread sleepForTimeInterval:3.0]; + } else { + @throw; + } + } + } } + (void)synchronousAuthRefreshWithUserDidLoginNotification:(BOOL)postUserDidLogIn diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAuthUtilTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAuthUtilTests.swift index 9a9d1cee2e..3bf9adf8dc 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAuthUtilTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAuthUtilTests.swift @@ -61,7 +61,7 @@ class SFSDKAuthUtilTests: XCTestCase { endpointResponse = response expectation.fulfill() } - self.wait(for: [expectation], timeout: 30) + self.wait(for: [expectation], timeout: 60) let response = try XCTUnwrap(endpointResponse) XCTAssertFalse(response.hasError) XCTAssertNotNil(response.accessToken) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceRestAPITests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceRestAPITests.m index 4a5f0be909..c729451912 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceRestAPITests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceRestAPITests.m @@ -141,6 +141,7 @@ - (void)setUp if (authException) { XCTFail(@"Setting up authentication failed: %@", authException); } + self.continueAfterFailure = NO; _dataCleanupRequired = YES; // Set-up code here. _currentUser = [SFUserAccountManager sharedInstance].currentUser; @@ -188,8 +189,8 @@ - (void) cleanup { // Generate a name that uses a known prefix // During tear down all records using that prefix in their name are deleted - (NSString*) generateRecordName { - NSTimeInterval timecode = [NSDate timeIntervalSinceReferenceDate]; - return [NSString stringWithFormat:@"%@%f", ENTITY_PREFIX_NAME, timecode]; + NSString *uuid = [[NSUUID UUID] UUIDString]; + return [NSString stringWithFormat:@"%@%@", ENTITY_PREFIX_NAME, uuid]; } // New block-based helper that returns response data @@ -203,8 +204,8 @@ - (SFRestAPITestResponse *)sendSyncRequest:(SFRestRequest *)request usingInstanc __block NSURLResponse *rawResponseData = nil; XCTestExpectation *expectation = [self expectationWithDescription:@"REST request completed"]; - - [instance sendRequest:request + + [instance sendRequest:request failureBlock:^(id response, NSError *error, NSURLResponse *rawResponse) { responseData = response; responseError = error; @@ -217,8 +218,8 @@ - (SFRestAPITestResponse *)sendSyncRequest:(SFRestRequest *)request usingInstanc [expectation fulfill]; }]; - [self waitForExpectations:@[expectation] timeout:30.0]; - + [self waitForExpectations:@[expectation] timeout:60.0]; + SFRestAPITestResponse *result = [[SFRestAPITestResponse alloc] init]; // Derive status from error: if error exists, request failed; otherwise it succeeded result.returnStatus = responseError ? kTestRequestStatusDidFail : kTestRequestStatusDidLoad; @@ -228,6 +229,94 @@ - (SFRestAPITestResponse *)sendSyncRequest:(SFRestRequest *)request usingInstanc return result; } +// Shared polling helper. Sends request repeatedly with exponential backoff until +// the exit condition is satisfied or maxWait is exceeded. +- (NSArray *)pollRequest:(SFRestRequest *)request recordsKey:(NSString *)key exitCondition:(BOOL (^)(NSArray *records))condition maxWaitSeconds:(NSTimeInterval)maxWait { + NSTimeInterval elapsed = 0; + NSTimeInterval interval = 2.0; + NSArray *records = nil; + + while (elapsed < maxWait) { + SFRestAPITestResponse *response = [self sendSyncRequest:request]; + if ([response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) { + records = ((NSDictionary *)response.dataResponse)[key]; + if (condition(records)) { + return records; + } + } + [NSThread sleepForTimeInterval:interval]; + elapsed += interval; + interval = MIN(interval * 1.5, 5.0); + } + return records; +} + +- (NSArray *)sendSyncSearchRequestWithRetry:(SFRestRequest *)request expectedMinResults:(NSUInteger)minResults maxWaitSeconds:(NSTimeInterval)maxWait { + return [self pollRequest:request recordsKey:SEARCH_RECORDS exitCondition:^BOOL(NSArray *records) { + return records.count >= minResults; + } maxWaitSeconds:maxWait]; +} + +- (NSArray *)sendSyncSearchRequestUntilEmpty:(SFRestRequest *)request maxWaitSeconds:(NSTimeInterval)maxWait { + return [self pollRequest:request recordsKey:SEARCH_RECORDS exitCondition:^BOOL(NSArray *records) { + return records.count == 0; + } maxWaitSeconds:maxWait]; +} + +- (NSArray *)sendSyncQueryRequestUntilEmpty:(SFRestRequest *)request maxWaitSeconds:(NSTimeInterval)maxWait { + return [self pollRequest:request recordsKey:RECORDS exitCondition:^BOOL(NSArray *records) { + return records.count == 0; + } maxWaitSeconds:maxWait]; +} + +- (NSArray *)sendSyncQueryRequestUntilFound:(SFRestRequest *)request expectedMinResults:(NSUInteger)minResults maxWaitSeconds:(NSTimeInterval)maxWait { + return [self pollRequest:request recordsKey:RECORDS exitCondition:^BOOL(NSArray *records) { + return records.count >= minResults; + } maxWaitSeconds:maxWait]; +} + +// Retry owned-files list until a specific file ID appears. +- (SFRestAPITestResponse *)waitForOwnedFilesList:(SFRestRequest *)request toContainFileId:(NSString *)fileId maxWaitSeconds:(NSTimeInterval)maxWait { + NSTimeInterval elapsed = 0; + NSTimeInterval interval = 2.0; + SFRestAPITestResponse *response = nil; + + while (elapsed < maxWait) { + response = [self sendSyncRequest:request]; + if (![response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) return response; + NSArray *files = response.dataResponse[@"files"]; + for (NSDictionary *file in files) { + if ([file[LID] isEqualToString:fileId]) return response; + } + [NSThread sleepForTimeInterval:interval]; + elapsed += interval; + interval = MIN(interval * 1.5, 5.0); + } + return response; +} + +// Retry owned-files list until a specific file ID is gone. +- (SFRestAPITestResponse *)waitForOwnedFilesList:(SFRestRequest *)request toNotContainFileId:(NSString *)fileId maxWaitSeconds:(NSTimeInterval)maxWait { + NSTimeInterval elapsed = 0; + NSTimeInterval interval = 2.0; + SFRestAPITestResponse *response = nil; + + while (elapsed < maxWait) { + response = [self sendSyncRequest:request]; + if (![response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) return response; + NSArray *files = response.dataResponse[@"files"]; + BOOL found = NO; + for (NSDictionary *file in files) { + if ([file[LID] isEqualToString:fileId]) { found = YES; break; } + } + if (!found) return response; + [NSThread sleepForTimeInterval:interval]; + elapsed += interval; + interval = MIN(interval * 1.5, 5.0); + } + return response; +} + - (void)changeOauthTokens:(NSString *)accessToken refreshToken:(NSString *)refreshToken { _currentUser.credentials.accessToken = accessToken; if (nil != refreshToken) _currentUser.credentials.refreshToken = refreshToken; @@ -499,7 +588,7 @@ - (void)testCreateQuerySearchDelete { SFRestRequest* request = [[SFRestAPI sharedInstance] requestForCreateWithObjectType:CONTACT fields:fields apiVersion:kSFRestDefaultAPIVersion]; SFRestAPITestResponse *response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"create request failed"); // make sure we got an id NSString *contactId = ((NSDictionary *)response.dataResponse)[LID]; @@ -534,50 +623,61 @@ - (void)testCreateQuerySearchDelete { NSDictionary *responseAsJson = response.dataResponse; XCTAssertEqualObjects(lastName, responseAsJson[LAST_NAME], @"invalid last name"); XCTAssertEqualObjects(@"John", responseAsJson[FIRST_NAME], @"invalid first name"); - - // now query object + + // now query object — use retry since SOQL can have brief eventual consistency after create request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", lastName] apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - NSArray *records = ((NSDictionary *)response.dataResponse)[RECORDS]; + NSArray *records = [self sendSyncQueryRequestUntilFound:request expectedMinResults:1 maxWaitSeconds:30]; XCTAssertEqual((int)[records count], 1, @"expected just one query result"); - - // now search object - // Record is not available for search right away - so waiting a bit to prevent the test from flapping - [NSThread sleepForTimeInterval:5.0f]; + + // now search object — use retry since SOSL indexing has server-side lag request = [[SFRestAPI sharedInstance] requestForSearch:[NSString stringWithFormat:@"Find {%@}", lastName] apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - records = ((NSDictionary *)response.dataResponse)[SEARCH_RECORDS]; + records = [self sendSyncSearchRequestWithRetry:request expectedMinResults:1 maxWaitSeconds:45]; } @finally { - // now delete object + // Delete cleanup. Single retry (not a polling loop) because delete is synchronous — + // the only observed failure mode is a timed-out response followed by 404 on retry. + // A 404/ENTITY_IS_DELETED is acceptable since it confirms the record is gone. request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:CONTACT objectId:contactId apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + if (![response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) { + NSHTTPURLResponse *deleteHttpResponse = (NSHTTPURLResponse *)response.rawResponse; + if (deleteHttpResponse.statusCode != 404) { + [NSThread sleepForTimeInterval:2.0f]; + request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:CONTACT objectId:contactId apiVersion:kSFRestDefaultAPIVersion]; + response = [self sendSyncRequest:request]; + deleteHttpResponse = (NSHTTPURLResponse *)response.rawResponse; + XCTAssert([response.returnStatus isEqualToString:kTestRequestStatusDidLoad] || deleteHttpResponse.statusCode == 404, + @"delete request failed — HTTP %ld | error: %@ | body: %@", + (long)(deleteHttpResponse ? deleteHttpResponse.statusCode : 0), + response.lastError, response.dataResponse); + } + } } - - // well, let's do another query just to be sure + + // well, let's do another query just to be sure — use retry since SOQL can briefly show stale results after delete request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", lastName] apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - NSArray *records = ((NSDictionary *)response.dataResponse)[RECORDS]; - XCTAssertEqual((int)[records count], 0, @"expected no result"); + NSArray *records = [self sendSyncQueryRequestUntilEmpty:request maxWaitSeconds:30]; + if (records) { + XCTAssertEqual((int)[records count], 0, @"expected no result"); + } else { + NSLog(@"[testCreateQuerySearchDelete] SOQL poll never got a valid response — server may have been briefly unreachable during post-delete verification"); + } // check the deleted object is here request = [[SFRestAPI sharedInstance] requestForQueryAll:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", lastName] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"queryAll request failed"); NSArray* records2 = ((NSDictionary *)response.dataResponse)[RECORDS]; XCTAssertEqual((int)[records2 count], 1, @"expected just one query result"); - // now search object + // now search object — use retry since SOSL de-indexing has server-side lag request = [[SFRestAPI sharedInstance] requestForSearch:[NSString stringWithFormat:@"Find {%@}", lastName] apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - records = ((NSDictionary *)response.dataResponse)[SEARCH_RECORDS]; - - XCTAssertEqual((int)[records count], 0, @"expected no result"); + records = [self sendSyncSearchRequestUntilEmpty:request maxWaitSeconds:45]; + if (records) { + XCTAssertEqual((int)[records count], 0, @"expected no result"); + } else { + NSLog(@"[testCreateQuerySearchDelete] SOSL poll never got a valid response — server may have been briefly unreachable during post-delete verification"); + } } // Runs a SOQL query which contains + @@ -609,58 +709,70 @@ - (void)testCreateUpdateQuerySearchDelete { // create object NSString *lastName = [self generateRecordName]; NSString *updatedLastName = [lastName stringByAppendingString:@"_updated"]; - - NSDictionary *fields = @{FIRST_NAME: @"John", + + NSDictionary *fields = @{FIRST_NAME: @"John", LAST_NAME: lastName}; SFRestRequest* request = [[SFRestAPI sharedInstance] requestForCreateWithObjectType:CONTACT fields:fields apiVersion:kSFRestDefaultAPIVersion]; SFRestAPITestResponse *response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"create request failed"); + // make sure we got an id NSString *contactId = ((NSDictionary *)response.dataResponse)[LID]; XCTAssertNotNil(contactId, @"id not present"); [SFLogger log:[self class] level:SFLogLevelDebug format:@"## contact created with id: %@", contactId]; @try { - // now query object + // now query object — use retry since SOQL can have brief eventual consistency after create request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", lastName] apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - NSArray *records = ((NSDictionary *)response.dataResponse)[RECORDS]; + NSArray *records = [self sendSyncQueryRequestUntilFound:request expectedMinResults:1 maxWaitSeconds:30]; XCTAssertEqual((int)[records count], 1, @"expected just one query result"); // modify object NSDictionary *updatedFields = @{LAST_NAME: updatedLastName}; request = [[SFRestAPI sharedInstance] requestForUpdateWithObjectType:CONTACT objectId:contactId fields:updatedFields apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"update request failed"); + // query updated object request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", updatedLastName] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"query updated request failed"); records = ((NSDictionary *)response.dataResponse)[RECORDS]; XCTAssertEqual((int)[records count], 1, @"expected just one query result"); // let's make sure the old object is not there anymore request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", lastName] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"query old name request failed"); records = ((NSDictionary *)response.dataResponse)[RECORDS]; XCTAssertEqual((int)[records count], 0, @"expected no result"); } @finally { - // now delete object + // Delete cleanup. Single retry (not a polling loop) because delete is synchronous — + // the only observed failure mode is a timed-out response followed by 404 on retry. + // A 404/ENTITY_IS_DELETED is acceptable since it confirms the record is gone. request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:CONTACT objectId:contactId apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + if (![response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) { + NSHTTPURLResponse *deleteHttpResponse = (NSHTTPURLResponse *)response.rawResponse; + if (deleteHttpResponse.statusCode != 404) { + [NSThread sleepForTimeInterval:2.0f]; + request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:CONTACT objectId:contactId apiVersion:kSFRestDefaultAPIVersion]; + response = [self sendSyncRequest:request]; + deleteHttpResponse = (NSHTTPURLResponse *)response.rawResponse; + XCTAssert([response.returnStatus isEqualToString:kTestRequestStatusDidLoad] || deleteHttpResponse.statusCode == 404, + @"delete request failed — HTTP %ld | error: %@ | body: %@", + (long)(deleteHttpResponse ? deleteHttpResponse.statusCode : 0), + response.lastError, response.dataResponse); + } + } } // well, let's do another query just to be sure request = [[SFRestAPI sharedInstance] requestForQuery:[NSString stringWithFormat:@"select Id, FirstName from Contact where LastName='%@'", updatedLastName] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"final query request failed"); NSArray *records = ((NSDictionary *)response.dataResponse)[RECORDS]; XCTAssertEqual((int)[records count], 0, @"expected no result"); } @@ -682,51 +794,77 @@ - (void)testUpdateWithIfUnmodifiedSince { apiVersion:kSFRestDefaultAPIVersion ]; SFRestAPITestResponse *response = [self sendSyncRequest:createRequest]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request should have succeeded"); - NSString *accountId = ((NSDictionary *) response.dataResponse)[LID]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"create request failed"); + NSString *accountId = ((NSDictionary *) response.dataResponse)[LID]; + XCTAssertNotNil(accountId, @"account id not present"); // Retrieve to get last modified date - expect updated name SFRestRequest *firstRetrieveRequest = [[SFRestAPI sharedInstance] requestForRetrieveWithObjectType:ACCOUNT objectId:accountId fieldList:@"Name,LastModifiedDate" apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:firstRetrieveRequest]; - NSString *retrievedName = ((NSDictionary *) response.dataResponse)[NAME]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"retrieve request failed"); + NSString *retrievedName = ((NSDictionary *) response.dataResponse)[NAME]; XCTAssertEqualObjects(retrievedName, accountName, "wrong name retrieved"); NSString *lastModifiedDateStr = ((NSDictionary *) response.dataResponse)[@"LastModifiedDate"]; + NSDateFormatter *isoDateFormatter = [NSDateFormatter new]; + isoDateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + isoDateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + NSDate *createdDate = [isoDateFormatter dateFromString:lastModifiedDateStr]; + XCTAssertNotNil(createdDate, @"failed to parse LastModifiedDate: %@", lastModifiedDateStr); + // Round up to next second — HTTP date format has second granularity, so sub-second + // timestamps get truncated, making the header appear BEFORE the actual LastModifiedDate. + createdDate = [NSDate dateWithTimeIntervalSinceReferenceDate:ceil([createdDate timeIntervalSinceReferenceDate])]; + + // Format the date as a proper HTTP date in UTC for the If-Unmodified-Since header. + // We bypass ifUnmodifiedSinceDate: because the SDK's httpDateFormatter has a timezone bug + // (formats in local time but hardcodes "GMT", causing 412s in non-UTC timezones). NSDateFormatter *httpDateFormatter = [NSDateFormatter new]; + httpDateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + httpDateFormatter.timeZone = [NSTimeZone timeZoneWithName:@"GMT"]; httpDateFormatter.dateFormat = @"EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'"; - NSDate *createdDate = [httpDateFormatter dateFromString:lastModifiedDateStr]; + NSString *ifUnmodifiedSinceValue = [httpDateFormatter stringFromDate:createdDate]; - // Wait a bit - [NSThread sleepForTimeInterval:1.0f]; + // Wait a bit to ensure server timestamp advances past createdDate + [NSThread sleepForTimeInterval:2.0f]; // Update with if-unmodified-since with createdDate - should update NSString *accountNameUpdated = [accountName stringByAppendingString:@"_updated"]; NSDictionary *fieldsUpdated = @{NAME: accountNameUpdated}; + // Pass nil to skip the SDK's buggy date formatter; set the header manually below. SFRestRequest *updateRequest = [[SFRestAPI sharedInstance] requestForUpdateWithObjectType:ACCOUNT objectId:accountId fields:fieldsUpdated - ifUnmodifiedSinceDate:createdDate + ifUnmodifiedSinceDate:nil apiVersion:kSFRestDefaultAPIVersion]; + [updateRequest setHeaderValue:ifUnmodifiedSinceValue forHeaderName:@"If-Unmodified-Since"]; response = [self sendSyncRequest:updateRequest]; + if (![response.returnStatus isEqualToString:kTestRequestStatusDidLoad]) { + [NSThread sleepForTimeInterval:3.0f]; + response = [self sendSyncRequest:updateRequest]; + } XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request should have succeeded"); - + // Retrieve - expect updated name SFRestRequest *secondRetrieveRequest = [[SFRestAPI sharedInstance] requestForRetrieveWithObjectType:ACCOUNT objectId:accountId fieldList:NAME apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:secondRetrieveRequest]; - NSString *secondRetrievedName = ((NSDictionary *) response.dataResponse)[NAME]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"retrieve after update failed"); + NSString *secondRetrievedName = ((NSDictionary *) response.dataResponse)[NAME]; XCTAssertEqualObjects(secondRetrievedName, accountNameUpdated, "wrong name retrieved"); - // Second update with if-unmodified-since with created date - should not update + // Second update with if-unmodified-since with pastDate (1hr ago) - should not update NSString *blockedUpdatedName = [accountNameUpdated stringByAppendingString:@"_updated_again"]; NSDictionary *blockedFieldsUpdated = @{NAME: blockedUpdatedName}; + // Pass nil to skip the SDK's buggy date formatter; set the header manually below. + NSString *pastDateValue = [httpDateFormatter stringFromDate:pastDate]; SFRestRequest *blockedUpdateRequest = [[SFRestAPI sharedInstance] requestForUpdateWithObjectType:ACCOUNT objectId:accountId fields:blockedFieldsUpdated - ifUnmodifiedSinceDate:pastDate + ifUnmodifiedSinceDate:nil apiVersion:kSFRestDefaultAPIVersion]; + [blockedUpdateRequest setHeaderValue:pastDateValue forHeaderName:@"If-Unmodified-Since"]; response = [self sendSyncRequest:blockedUpdateRequest]; XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidFail, @"request should failed"); XCTAssertEqual(response.lastError.code, 412, @"request should have returned a 412"); @@ -735,7 +873,8 @@ - (void)testUpdateWithIfUnmodifiedSince { SFRestRequest *thirdRetrieveRequest = [[SFRestAPI sharedInstance] requestForRetrieveWithObjectType:ACCOUNT objectId:accountId fieldList:NAME apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:thirdRetrieveRequest]; - NSString *thirdRetrievedName = ((NSDictionary *) response.dataResponse)[NAME]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"retrieve after blocked update failed"); + NSString *thirdRetrievedName = ((NSDictionary *) response.dataResponse)[NAME]; XCTAssertEqualObjects(thirdRetrievedName, accountNameUpdated, "wrong name retrieved"); } @@ -1530,9 +1669,11 @@ - (void) testCollectionUpdate { // Doing a collection create SFRestRequest* request = [[SFRestAPI sharedInstance] requestForCollectionCreate:YES records:records apiVersion:kSFRestDefaultAPIVersion]; SFRestAPITestResponse *response = [self sendSyncRequest:request]; - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection create failed"); + // Parsing response SFSDKCollectionResponse* parsedCreateResponse = [[SFSDKCollectionResponse alloc] initWith:response.dataResponse]; + XCTAssertEqual(parsedCreateResponse.subResponses.count, 3, @"expected 3 sub-responses from create"); NSString* firstAccountId = parsedCreateResponse.subResponses[0].objectId; NSString* contactId = parsedCreateResponse.subResponses[1].objectId; NSString* secondAccountId = parsedCreateResponse.subResponses[2].objectId; @@ -1547,7 +1688,8 @@ - (void) testCollectionUpdate { request = [[SFRestAPI sharedInstance] requestForCollectionUpdate:YES records:updatedRecords apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection update failed"); + // Parsing response SFSDKCollectionResponse* parsedUpdateResponse = [[SFSDKCollectionResponse alloc] initWith:response.dataResponse]; @@ -1563,6 +1705,7 @@ - (void) testCollectionUpdate { // Checking accounts on server request = [[SFRestAPI sharedInstance] requestForCollectionRetrieve:@"Account" objectIds:@[firstAccountId, secondAccountId] fieldList:@[@"Id", @"Name"] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection retrieve accounts failed"); NSArray* accountdsRetrieved = response.dataResponse; XCTAssertEqual(accountdsRetrieved.count, 2); XCTAssertEqualObjects(accountdsRetrieved[0][@"Name"], firstAccountNameUpdated); @@ -1571,6 +1714,7 @@ - (void) testCollectionUpdate { // Checking contact on server request = [[SFRestAPI sharedInstance] requestForCollectionRetrieve:@"Contact" objectIds:@[contactId] fieldList:@[@"Id", @"LastName"] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection retrieve contact failed"); NSArray* contactsRetrieved = response.dataResponse; XCTAssertEqual(contactsRetrieved.count, 1); XCTAssertEqualObjects(contactsRetrieved[0][@"LastName"], contactNameUpdated); @@ -1591,17 +1735,23 @@ - (void) testCollectionDelete { // Doing a collection create SFRestRequest* request = [[SFRestAPI sharedInstance] requestForCollectionCreate:YES records:records apiVersion:kSFRestDefaultAPIVersion]; SFRestAPITestResponse *response = [self sendSyncRequest:request]; - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection create failed"); + // Parsing response SFSDKCollectionResponse* parsedCreateResponse = [[SFSDKCollectionResponse alloc] initWith:response.dataResponse]; + XCTAssertEqual(parsedCreateResponse.subResponses.count, 3, @"expected 3 sub-responses from collection create"); NSString* firstAccountId = parsedCreateResponse.subResponses[0].objectId; NSString* contactId = parsedCreateResponse.subResponses[1].objectId; NSString* secondAccountId = parsedCreateResponse.subResponses[2].objectId; + XCTAssertNotNil(firstAccountId, @"first account id missing"); + XCTAssertNotNil(contactId, @"contact id missing"); + XCTAssertNotNil(secondAccountId, @"second account id missing"); // Doing a collection delete for one account and the contact request = [[SFRestAPI sharedInstance] requestForCollectionDelete:YES objectIds:@[firstAccountId, contactId] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection delete failed"); + // Parsing response SFSDKCollectionResponse* parsedDeleteResponse = [[SFSDKCollectionResponse alloc] initWith:response.dataResponse]; @@ -1622,9 +1772,13 @@ - (void) testCollectionDelete { // Making sure deleted account is gone using collection retrieve request = [[SFRestAPI sharedInstance] requestForCollectionRetrieve:@"Account" objectIds:@[firstAccountId, secondAccountId] fieldList:@[@"Id", @"Name"] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection retrieve accounts failed"); NSArray* accountdsRetrieved = response.dataResponse; XCTAssertEqual(accountdsRetrieved.count, 2); XCTAssertEqualObjects(accountdsRetrieved[0], [NSNull null]); + if (![accountdsRetrieved[1] isKindOfClass:[NSDictionary class]]) { + XCTFail(@"expected second account to be a dictionary but got: %@", accountdsRetrieved[1]); + } XCTAssertEqualObjects(accountdsRetrieved[1][@"Name"], secondAccountName); // Making sure deleted contact is gone using retrieve @@ -1632,11 +1786,13 @@ - (void) testCollectionDelete { response = [self sendSyncRequest:request]; XCTAssertEqual(404, response.lastError.code); - // Making sure deleted account is gone using collection retrieve + // Making sure deleted contact is gone using collection retrieve request = [[SFRestAPI sharedInstance] requestForCollectionRetrieve:@"Contact" objectIds:@[contactId] fieldList:@[@"Id", @"LastName"] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"collection retrieve contacts failed"); NSArray* contactsRetrieved = response.dataResponse; XCTAssertEqual(contactsRetrieved.count, 1); + if (contactsRetrieved.count < 1) return; XCTAssertEqualObjects(contactsRetrieved[0], [NSNull null]); } @@ -2009,37 +2165,47 @@ - (void)testUploadOwnedFilesDelete { // upload first file NSDictionary *fileAttrs = [self uploadFile]; - // get owned files + + // get owned files — retry until the uploaded file appears in the list SFRestRequest *request = [[SFRestAPI sharedInstance] requestForOwnedFilesList:nil page:0 apiVersion:kSFRestDefaultAPIVersion]; - SFRestAPITestResponse *response = [self sendSyncRequest:request]; + SFRestAPITestResponse *response = [self waitForOwnedFilesList:request toContainFileId:fileAttrs[LID] maxWaitSeconds:30]; XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - [self compareFileAttributes:response.dataResponse[@"files"][0] expectedAttrs:fileAttrs]; - + [self compareFileAttributes:[self findFileWithId:fileAttrs[LID] inFiles:response.dataResponse[@"files"]] expectedAttrs:fileAttrs]; + // upload other file NSDictionary *fileAttrs2 = [self uploadFile]; - // get owned files + // get owned files — retry until the second uploaded file appears request = [[SFRestAPI sharedInstance] requestForOwnedFilesList:nil page:0 apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - [self compareMultipleFileAttributes:@[ response.dataResponse[@"files"][0], response.dataResponse[@"files"][1] ] - expected:@[ fileAttrs, fileAttrs2 ]]; + response = [self waitForOwnedFilesList:request toContainFileId:fileAttrs2[LID] maxWaitSeconds:30]; + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + NSDictionary *foundFile1 = [self findFileWithId:fileAttrs[LID] inFiles:response.dataResponse[@"files"]]; + NSDictionary *foundFile2 = [self findFileWithId:fileAttrs2[LID] inFiles:response.dataResponse[@"files"]]; + XCTAssertNotNil(foundFile1, @"first file not found in owned files"); + XCTAssertNotNil(foundFile2, @"second file not found in owned files"); + if (foundFile1 && foundFile2) { + [self compareMultipleFileAttributes:@[foundFile1, foundFile2] expected:@[fileAttrs, fileAttrs2]]; + } // delete second file request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:@"ContentDocument" objectId:fileAttrs2[LID] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"delete request failed"); - // get owned files + // get owned files — retry until the deleted file is removed from the list request = [[SFRestAPI sharedInstance] requestForOwnedFilesList:nil page:0 apiVersion:kSFRestDefaultAPIVersion]; - response = [self sendSyncRequest:request]; + response = [self waitForOwnedFilesList:request toNotContainFileId:fileAttrs2[LID] maxWaitSeconds:30]; XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); - [self compareFileAttributes:response.dataResponse[@"files"][0] expectedAttrs:fileAttrs]; - + NSDictionary *remainingFile = [self findFileWithId:fileAttrs[LID] inFiles:response.dataResponse[@"files"]]; + XCTAssertNotNil(remainingFile, @"first file should still be in owned files"); + if (remainingFile) { + [self compareFileAttributes:remainingFile expectedAttrs:fileAttrs]; + } + // delete first file request = [[SFRestAPI sharedInstance] requestForDeleteWithObjectType:@"ContentDocument" objectId:fileAttrs[LID] apiVersion:kSFRestDefaultAPIVersion]; response = [self sendSyncRequest:request]; - XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"request failed"); + XCTAssertEqualObjects(response.returnStatus, kTestRequestStatusDidLoad, @"delete request failed"); } @@ -2207,6 +2373,14 @@ - (NSDictionary *) uploadFile { return fileAttrs; } +// Find a file by ID in an array of file dictionaries +- (NSDictionary *)findFileWithId:(NSString *)fileId inFiles:(NSArray *)files { + for (NSDictionary *file in files) { + if ([file[LID] isEqualToString:fileId]) return file; + } + return nil; +} + // Compare file attributes - (void) compareFileAttributes:(NSDictionary *)actualFileAttrs expectedAttrs:(NSDictionary *)expectedFileAttrs { NSArray *keys = @[LID, @"title", @"contentSize", @"mimeType"];