FBSDKCoreKit/FBSDKCoreKitTests/GraphRequestConnectionTests.swift (1,453 lines of code) (raw):
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*/
@testable import FBSDKCoreKit
import UIKit
import XCTest
final class GraphRequestConnectionTests: XCTestCase, GraphRequestConnectionDelegate {
let appID = "appid"
var didInvokeDelegateRequestConnectionDidSendBodyData = false
// swiftlint:disable implicitly_unwrapped_optional force_unwrapping line_length
let sampleUrl = URL(string: "https://example.com")!
let missingTokenData = "{\"error\": {\"message\": \"Token is broken\",\"code\": 190,\"error_subcode\": 463}}".data(using: .utf8)!
let clientToken = "client_token"
var session: TestURLSessionProxy!
var secondSession: TestURLSessionProxy!
var sessionFactory: TestURLSessionProxyFactory!
var errorConfiguration: TestErrorConfiguration!
var errorConfigurationProvider: TestErrorConfigurationProvider!
var errorRecoveryConfiguration: ErrorRecoveryConfiguration!
var settings: TestSettings!
var graphRequestConnectionFactory: TestGraphRequestConnectionFactory!
var eventLogger: TestEventLogger!
var processInfo: TestProcessInfo!
var macCatalystDeterminator: TestMacCatalystDeterminator!
var logger: TestLogger!
var connection: GraphRequestConnection!
var errorFactory: TestErrorFactory!
var metadata: GraphRequestMetadata!
var piggybackManager: TestGraphRequestPiggybackManager!
// swiftlint:enable implicitly_unwrapped_optional force_unwrapping line_length
func createSampleMetadata() -> GraphRequestMetadata {
GraphRequestMetadata(
request: makeSampleRequest(),
completionHandler: nil,
batchParameters: nil
)
}
var requestConnectionStartingCallback: ((GraphRequestConnecting) -> Void)?
var requestConnectionCallback: ((GraphRequestConnecting, Error?) -> Void)?
override func setUp() {
super.setUp()
metadata = createSampleMetadata()
TestAccessTokenWallet.reset()
TestAuthenticationTokenWallet.reset()
GraphRequestConnection.setCanMakeRequests()
session = TestURLSessionProxy()
secondSession = TestURLSessionProxy()
sessionFactory = TestURLSessionProxyFactory.create(withSessions: [session, secondSession])
errorRecoveryConfiguration = makeNonTransientErrorRecoveryConfiguration()
errorConfiguration = TestErrorConfiguration()
errorConfiguration.stubbedRecoveryConfiguration = errorRecoveryConfiguration
errorConfigurationProvider = TestErrorConfigurationProvider(configuration: errorConfiguration)
settings = TestSettings()
settings.appID = appID
settings.clientToken = clientToken
graphRequestConnectionFactory = TestGraphRequestConnectionFactory()
eventLogger = TestEventLogger()
processInfo = TestProcessInfo()
macCatalystDeterminator = TestMacCatalystDeterminator()
logger = TestLogger(loggingBehavior: .developerErrors)
errorFactory = TestErrorFactory()
piggybackManager = TestGraphRequestPiggybackManager()
GraphRequestConnection.configure(
withURLSessionProxyFactory: sessionFactory,
errorConfigurationProvider: errorConfigurationProvider,
piggybackManager: piggybackManager,
settings: settings,
graphRequestConnectionFactory: graphRequestConnectionFactory,
eventLogger: eventLogger,
operatingSystemVersionComparer: processInfo,
macCatalystDeterminator: macCatalystDeterminator,
accessTokenProvider: TestAccessTokenWallet.self,
accessTokenSetter: TestAccessTokenWallet.self,
errorFactory: errorFactory,
authenticationTokenProvider: TestAuthenticationTokenWallet.self
)
connection = GraphRequestConnection()
graphRequestConnectionFactory.stubbedConnection = connection
}
override func tearDown() {
session = nil
secondSession = nil
sessionFactory = nil
errorConfiguration = nil
errorConfigurationProvider = nil
errorRecoveryConfiguration = nil
settings = nil
graphRequestConnectionFactory = nil
eventLogger = nil
processInfo = nil
macCatalystDeterminator = nil
logger = nil
connection = nil
errorFactory = nil
metadata = nil
piggybackManager = nil
GraphRequestConnection.resetClassDependencies()
GraphRequestConnection.resetDefaultConnectionTimeout()
GraphRequestConnection.resetCanMakeRequests()
TestLogger.reset()
TestAccessTokenWallet.reset()
TestAuthenticationTokenWallet.reset()
super.tearDown()
}
// MARK: - GraphRequestConnectionDelegate
func requestConnection(_ connection: GraphRequestConnecting, didFailWithError error: Error) {
if let completion = requestConnectionCallback {
completion(connection, error)
requestConnectionCallback = nil
}
}
func requestConnectionDidFinishLoading(_ connection: GraphRequestConnecting) {
if let completion = requestConnectionCallback {
completion(connection, nil)
requestConnectionCallback = nil
}
}
func requestConnectionWillBeginLoading(_ connection: GraphRequestConnecting) {
if let completion = requestConnectionStartingCallback {
completion(connection)
requestConnectionStartingCallback = nil
}
}
func requestConnection(
_ connection: GraphRequestConnecting,
didSendBodyData bytesWritten: Int,
totalBytesWritten: Int,
totalBytesExpectedToWrite: Int
) {
didInvokeDelegateRequestConnectionDidSendBodyData = true
}
// MARK: - Dependencies
func testDefaultDependencies() {
GraphRequestConnection.resetClassDependencies()
XCTAssertNil(
GraphRequestConnection.sessionProxyFactory,
"A graph request connection should not have a session provider by default"
)
XCTAssertNil(
GraphRequestConnection.errorConfigurationProvider,
"A graph request connection should not have a error configuration provider by default"
)
XCTAssertNil(
GraphRequestConnection.piggybackManager,
"A graph request connection should not have a piggyback manager by default"
)
XCTAssertNil(
GraphRequestConnection.settings,
"A graph request connection should not have a settings type by default"
)
XCTAssertNil(
GraphRequestConnection.graphRequestConnectionFactory,
"A graph request connection should not have a connection factory by default"
)
XCTAssertNil(
GraphRequestConnection.eventLogger,
"A graph request connection should not have an events logger by default"
)
XCTAssertNil(
GraphRequestConnection.operatingSystemVersionComparer,
"A graph request connection should not have an operating system version comparer by default"
)
XCTAssertNil(
GraphRequestConnection.macCatalystDeterminator,
"A graph request connection should not have a Mac Catalyst determinator by default"
)
XCTAssertNil(
GraphRequestConnection.accessTokenProvider,
"A graph request connection should not an access token provider by default"
)
XCTAssertNil(
GraphRequestConnection.accessTokenSetter,
"A graph request connection should not have an access token setter by default"
)
XCTAssertNil(
GraphRequestConnection.errorFactory,
"A graph request connection should not have an error factory by default"
)
XCTAssertNil(
GraphRequestConnection.authenticationTokenProvider,
"A graph request connection should not have an authentication token provider by default"
)
}
func testCreatingWithCustomDependencies() {
XCTAssertTrue(
GraphRequestConnection.sessionProxyFactory === sessionFactory,
"A graph request connection should persist the session provider it was created with"
)
XCTAssertTrue(
connection.session === session,
"A graph request connection should derive sessions from the session provider"
)
XCTAssertTrue(
GraphRequestConnection.errorConfigurationProvider === errorConfigurationProvider,
"A graph request connection should persist the error configuration provider it was created with"
)
XCTAssertTrue(
GraphRequestConnection.piggybackManager === piggybackManager,
"A graph request connection should persist the piggyback manager it was created with"
)
XCTAssertTrue(
GraphRequestConnection.settings === settings,
"A graph request connection should persist the settings it was created with"
)
XCTAssertTrue(
GraphRequestConnection.graphRequestConnectionFactory === graphRequestConnectionFactory,
"A graph request connection should persist the connection factory it was created with"
)
XCTAssertTrue(
GraphRequestConnection.eventLogger === eventLogger,
"A graph request connection should persist the events logger it was created with"
)
XCTAssertTrue(
GraphRequestConnection.operatingSystemVersionComparer === processInfo,
"A graph request connection should persist the operating system comparer it was created with"
)
XCTAssertTrue(
GraphRequestConnection.macCatalystDeterminator === macCatalystDeterminator,
"A graph request connection should persist the Mac Catalyst determinator it was created with"
)
XCTAssertTrue(
GraphRequestConnection.accessTokenProvider === TestAccessTokenWallet.self,
"A graph request connection should persist the access token provider it was created with"
)
XCTAssertTrue(
GraphRequestConnection.accessTokenSetter === TestAccessTokenWallet.self,
"A graph request connection should persist the access token setter it was created with"
)
XCTAssertTrue(
GraphRequestConnection.errorFactory === errorFactory,
"A graph request connection should persist the error factory it was created with"
)
XCTAssertTrue(
GraphRequestConnection.authenticationTokenProvider === TestAuthenticationTokenWallet.self,
"A graph request connection should persist the authentication token provider it was created with"
)
}
// MARK: - Properties
func testDefaultConnectionTimeout() {
XCTAssertEqual(
GraphRequestConnection.defaultConnectionTimeout,
60,
"Should have a default connection timeout of 60 seconds"
)
}
func testOverridingDefaultConnectionTimeoutWithInvalidTimeout() {
GraphRequestConnection.defaultConnectionTimeout = -1
XCTAssertEqual(
GraphRequestConnection.defaultConnectionTimeout,
60,
"Should not be able to override the default connection timeout with an invalid timeout"
)
}
func testOverridingDefaultConnectionTimeoutWithValidTimeout() {
GraphRequestConnection.defaultConnectionTimeout = 100
XCTAssertEqual(
GraphRequestConnection.defaultConnectionTimeout,
100,
"Should be able to override the default connection timeout"
)
}
func testDefaultOverriddenVersionPart() {
XCTAssertNil(
connection.overriddenVersionPart,
"There should not be an overridden version part by default"
)
}
func testOverridingVersionPartWithInvalidVersions() {
["", "abc", "-5", "1.1.1.1.1", "v1.1.1.1"]
.forEach { string in
connection.overrideGraphAPIVersion(string)
XCTAssertEqual(
connection.overriddenVersionPart,
string,
"Should not be able to override the graph api version with \(string) but you can"
)
}
}
func testOverridingVersionPartWithValidVersions() {
["1", "1.1", "1.1.1", "v1", "v1.1", "v1.1.1"]
.forEach { string in
connection.overrideGraphAPIVersion(string)
XCTAssertEqual(
connection.overriddenVersionPart,
string,
"Should be able to override the graph api version with a valid version string"
)
}
}
func testOverridingVersionCopies() {
var version = "v1.0"
connection.overrideGraphAPIVersion(version)
version = "foo"
XCTAssertNotEqual(
version,
connection.overriddenVersionPart,
"Should copy the version so that changes to the original string do not affect the stored value"
)
}
func testDefaultCanMakeRequests() {
GraphRequestConnection.resetCanMakeRequests()
XCTAssertFalse(
GraphRequestConnection.canMakeRequests(),
"Should not be able to make requests by default"
)
}
func testDelegateQueue() {
XCTAssertNil(connection.delegateQueue, "Should not have a delegate queue by default")
}
func testSettingDelegateQueue() {
let queue = OperationQueue()
connection.delegateQueue = queue
XCTAssertEqual(
connection.delegateQueue,
queue,
"Should be able to set the delegate queue"
)
XCTAssertEqual(
session.delegateQueue,
queue,
"Should set the session's delegate queue when setting the connnection's delegate queue"
)
}
// MARK: - Adding Requests
func testAddingRequestWithoutBatchEntryName() throws {
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in }
let metadata = try XCTUnwrap(connection.requests.firstObject as? GraphRequestMetadata)
XCTAssertTrue(
metadata.batchParameters.isEmpty,
"Adding a request without a batch entry name should not store batch parameters"
)
}
func testAddingRequestWithEmptyBatchEntryName() throws {
connection.add(
makeRequestForMeWithEmptyFields(),
name: ""
) { _, _, _ in }
let metadata = try XCTUnwrap(connection.requests.firstObject as? GraphRequestMetadata)
XCTAssertTrue(
metadata.batchParameters.isEmpty,
"Should not store batch parameters for a request with an empty batch entry name"
)
}
func testAddingRequestWithValidBatchEntryName() throws {
connection.add(
makeRequestForMeWithEmptyFields(),
name: "foo"
) { _, _, _ in }
let expectedParameters = ["name": "foo"]
let metadata = try XCTUnwrap(connection.requests.firstObject as? GraphRequestMetadata)
XCTAssertEqual(
metadata.batchParameters as? [String: String],
expectedParameters,
"Should create and store batch parameters for a request with a non-empty batch entry name"
)
}
func testAddingRequestWithBatchParameters() {
[
GraphRequestConnectionState.started,
.cancelled,
.completed,
.serialized,
]
.forEach { state in
connection.state = state
assertRaisesException(
message: "Should raise an exception on request addition when state has raw value: \(state.rawValue)"
) {
self.connection.add(
self.makeRequestForMeWithEmptyFields(),
parameters: [:]
) { _, _, _ in }
}
}
connection.state = .created
assertDoesNotRaiseException(
message: "Should not throw an error on request addition when state is 'created'"
) {
self.connection.add(
self.makeRequestForMeWithEmptyFields(),
parameters: [:]
) { _, _, _ in }
}
}
func testAddingRequestToBatchWithBatchParameters() throws {
let batchParameters = [
name: "Foo",
"Bar": "Baz",
]
let metadata = GraphRequestMetadata(
request: makeSampleRequest(),
completionHandler: nil,
batchParameters: batchParameters
)
let batch = NSMutableArray()
connection.addRequest(
metadata,
toBatch: batch,
attachments: NSMutableDictionary(),
batchToken: nil
)
let first = batch.firstObject as? [String: String]
XCTAssertEqual(
first?[name],
"Foo",
"Should add the batch parameters to the from the request to the batch"
)
XCTAssertEqual(
first?["Bar"],
"Baz",
"Should add the batch parameters to the from the request to the batch"
)
}
func testAddingRequestToBatchSetsMethod() {
let postRequest = TestGraphRequest(
graphPath: "me",
HTTPMethod: .post
)
let metadata = GraphRequestMetadata(
request: postRequest,
completionHandler: nil,
batchParameters: [:]
)
let batch = NSMutableArray()
connection.addRequest(
metadata,
toBatch: batch,
attachments: NSMutableDictionary(),
batchToken: nil
)
let parameters = batch.firstObject as? [String: HTTPMethod]
XCTAssertEqual(
parameters?["method"],
.post,
"Should include the http method from the graph request in the batch"
)
}
func testAddingRequestToBatchWithToken() throws {
let token = name
let expectedItem = URLQueryItem(name: "access_token", value: token)
let metadata = GraphRequestMetadata(
request: makeSampleRequest(),
completionHandler: nil,
batchParameters: [:]
)
let batch = NSMutableArray()
connection.addRequest(
metadata,
toBatch: batch,
attachments: NSMutableDictionary(),
batchToken: token
)
let parameters = try XCTUnwrap(batch.firstObject as? [String: String])
let urlString = try XCTUnwrap(parameters["relative_url"])
let queryItems = try XCTUnwrap(
URLComponents(string: urlString)?.queryItems
)
XCTAssertTrue(
queryItems.contains(expectedItem),
"Should include the batch token in the url for the batch request"
)
}
func testAddingRequestToBatchWithAttachments() throws {
let data = try XCTUnwrap("foo".data(using: .utf8))
let data2 = try XCTUnwrap("bar".data(using: .utf8))
let request = makeSampleRequest(parameters: [name: data])
let request2 = makeSampleRequest(parameters: [name: data2])
let metadata1 = makeMetadata(from: request)
let metadata2 = makeMetadata(from: request2)
let batch = NSMutableArray()
let attachments = NSMutableDictionary()
connection.addRequest(
metadata1,
toBatch: batch,
attachments: attachments,
batchToken: nil
)
connection.addRequest(
metadata2,
toBatch: batch,
attachments: attachments,
batchToken: nil
)
batch.enumerateObjects { object, index, _ in
let expectedFileName = "file\(index)"
let parameters = object as? [String: String]
XCTAssertEqual(
parameters?["attached_files"],
expectedFileName,
"Should store retrieval keys for the attachments taken from the graph requests"
)
}
let expectedAttachments = [
"file0": data,
"file1": data2,
]
XCTAssertEqual(
expectedAttachments,
attachments as? [String: Data],
"Should add attachments from the graph requests"
)
}
// MARK: - Attachments
func testAppendingNonFormStringAttachment() {
let body = TestGraphRequestBody()
connection.appendAttachments(
[name: "foo"],
to: body,
addFormData: false,
logger: logger
)
XCTAssertNil(
body.capturedKey,
"Should not append strings if the attachment type is not form data"
)
XCTAssertNil(
body.capturedFormValue,
"Should not append strings if the attachment type is not form data"
)
}
func testAppendingFormStringAttachment() {
let body = TestGraphRequestBody()
connection.appendAttachments(
[name: "foo"],
to: body,
addFormData: true,
logger: logger
)
XCTAssertEqual(
body.capturedKey,
name,
"Should append strings when the attachment type is form data"
)
XCTAssertEqual(body.capturedFormValue, "foo", "Should pass through whether or not to use form data")
}
func testAppendingImageData() {
let image = UIImage()
let body = TestGraphRequestBody()
connection.appendAttachments(
[name: image],
to: body,
addFormData: false,
logger: logger
)
XCTAssertEqual(
body.capturedImage,
image,
"Should always append images"
)
body.capturedImage = nil
connection.appendAttachments(
[name: image],
to: body,
addFormData: true,
logger: logger
)
XCTAssertIdentical(
body.capturedImage,
image,
"Should always append images"
)
}
func testAppendingData() {
let data = name.data(using: .utf8)! // swiftlint:disable:this force_unwrapping
let body = TestGraphRequestBody()
connection.appendAttachments(
[name: data],
to: body,
addFormData: false,
logger: logger
)
XCTAssertEqual(
body.capturedData,
data,
"Should always append data"
)
body.capturedData = nil
connection.appendAttachments(
[name: data],
to: body,
addFormData: true,
logger: logger
)
XCTAssertEqual(
body.capturedData,
data,
"Should always append data"
)
}
func testAppendingDataAttachments() {
let data = name.data(using: .utf8)! // swiftlint:disable:this force_unwrapping
let attachment = GraphRequestDataAttachment(
data: data,
filename: "fooFile",
contentType: "application/json"
)
let body = TestGraphRequestBody()
connection.appendAttachments(
[name: attachment],
to: body,
addFormData: false,
logger: logger
)
XCTAssertEqual(
body.capturedAttachment,
attachment,
"Should always append data attachments"
)
body.capturedAttachment = nil
connection.appendAttachments(
[name: attachment],
to: body,
addFormData: true,
logger: logger
)
XCTAssertEqual(
body.capturedAttachment,
attachment,
"Should always append data attachments"
)
}
func testAppendingUnknownAttachmentTypeWithLogger() {
let body = TestGraphRequestBody()
let logger = makeLogger()
connection.appendAttachments(
[name: UIColor.gray],
to: body,
addFormData: false,
logger: logger
)
XCTAssertEqual(
TestLogger.capturedLoggingBehavior,
.developerErrors,
"Should log an error when an unsupported type is attached"
)
XCTAssertEqual(
TestLogger.capturedLogEntry,
"Unsupported FBSDKGraphRequest attachment:UIExtendedGrayColorSpace 0.5 1, skipping.",
"Should log an error when an unsupported type is attached"
)
}
// MARK: - Cancelling
func testCancellingConnection() {
var expectedInvalidationCallCount = 0
[
GraphRequestConnectionState.created,
.started,
.cancelled,
.completed,
.serialized,
]
.forEach { state in
connection.state = state
expectedInvalidationCallCount += 1
connection.cancel()
XCTAssertEqual(
connection.state,
.cancelled,
"Cancelling a connection should set the state to the expected value"
)
XCTAssertEqual(
session.invalidateAndCancelCallCount,
expectedInvalidationCallCount,
"Cancelling a connetion should invalidate and cancel the session"
)
}
}
// MARK: - Starting
func testStartingConnectionWithUninitializedSDK() {
GraphRequestConnection.resetCanMakeRequests()
connection.logger = makeLogger()
let expectedMessage = "FBSDKGraphRequestConnection cannot be started before Facebook SDK initialized."
var capturedError: Error?
connection.add(makeSampleRequest()) { _, _, error in
capturedError = error
}
connection.start()
let testError = capturedError as? TestSDKError
XCTAssertEqual(
testError?.type,
.unknown,
"Starting a graph request before the SDK is initialized should return an unknown-type error"
)
XCTAssertEqual(
testError?.message,
expectedMessage,
"Starting a graph request before the SDK is initialized should return an error with the appropriate mesage"
)
XCTAssertEqual(
connection.state,
.cancelled,
"Starting a graph request before the SDK is initialized should update the connection state"
)
XCTAssertEqual(
TestLogger.capturedLogEntry,
expectedMessage,
"Starting a graph request before the SDK is initialized should log a warning"
)
XCTAssertEqual(
TestLogger.capturedLoggingBehavior,
.developerErrors,
"Starting a graph request before the SDK is initialized should log a warning"
)
}
func testStartingWithInvalidStates() {
connection.logger = makeLogger()
[
GraphRequestConnectionState.started,
.cancelled,
.completed,
]
.forEach { state in
connection.state = .created
connection.add(makeSampleRequest()) { _, _, _ in
XCTFail("Should not be called")
}
connection.state = state
connection.start()
XCTAssertEqual(
connection.state,
state,
"Should not change the connection state when starting in an invalid state"
)
XCTAssertEqual(
TestLogger.capturedLogEntry,
"FBSDKGraphRequestConnection cannot be started again.",
"Starting a connection in an invalid state"
)
XCTAssertEqual(
TestLogger.capturedLoggingBehavior,
.developerErrors,
"Starting a connection in an invalid state"
)
XCTAssertNil(session.capturedRequest, "Should not start a request for a connection in an invalid state")
}
}
func testStartingWithValidStates() {
[
GraphRequestConnectionState.created,
.serialized,
]
.forEach { state in
connection.state = .created
connection.add(makeSampleRequest()) { _, _, _ in
XCTFail("Should not be called")
}
connection.state = state
connection.start()
XCTAssertEqual(
connection.state,
.started,
"Should change the connection state to 'started' when starting in an valid state"
)
XCTAssertNotNil(session.capturedRequest, "Should start a request for a connection in an valid state")
}
}
func testStartingWithDelegateQueue() {
connection.delegate = self
let queue = TestOperationQueue()
connection.delegateQueue = queue
connection.add(makeSampleRequest()) { _, _, _ in
XCTFail("Should not be called")
}
connection.start()
XCTAssertTrue(
queue.addOperationWithBlockWasCalled,
"Starting a connection should add the request to the delegate queue when one exists"
)
}
func testStartingInvokesPiggybackManager() {
connection.add(makeSampleRequest()) { _, _, _ in }
connection.start()
XCTAssertTrue(
connection === piggybackManager.capturedConnection,
"Starting a request should invoke the piggyback manager"
)
}
// MARK: - Errors From Results
func testErrorFromResultWithNonDictionaryInput() {
let inputs: [Any] = ["foo", 123, true, NSNull(), Data(), [Any]()]
for input in inputs {
XCTAssertNil(
connection.error(fromResult: input, request: makeSampleRequest()),
"Should not create an error from \(input)"
)
}
}
func testErrorFromResultWithMissingBodyInInput() {
XCTAssertNil(
connection.error(fromResult: [], request: makeSampleRequest()),
"Should not create an error from an empty dictionary"
)
}
func testErrorFromResultWithMissingErrorInInputBody() {
let result: [String: Any] = [
"body": [],
]
XCTAssertNil(
connection.error(fromResult: result, request: makeSampleRequest()),
"Should not create an error from a dictionary with a missing error key"
)
}
func testErrorFromResultWithFuzzyInput() {
(1 ... 100).forEach { _ in
connection.error(
fromResult: Fuzzer.randomize(json: makeSampleErrorDictionary()),
request: makeSampleRequest()
)
}
}
func testErrorFromResultDependsOnErrorConfiguration() {
connection.error(
fromResult: NSDictionary(dictionary: makeSampleErrorDictionary()),
request: makeSampleRequest()
)
let capturedRequest = errorConfiguration.capturedGraphRequest
XCTAssertNotNil(capturedRequest?.graphPath, "Should capture the graph request from the result")
XCTAssertEqual(
errorConfiguration.capturedRecoveryConfigurationCode,
"1",
"Should capture the error code from the result"
)
XCTAssertEqual(
errorConfiguration.capturedRecoveryConfigurationSubcode,
"2",
"Should capture the error subcode from the result"
)
}
func testErrorFromResult() {
let error = connection.error(
fromResult: makeSampleErrorDictionary(),
request: makeSampleRequest()
) as NSError?
XCTAssertEqual(
error?.userInfo[NSLocalizedRecoverySuggestionErrorKey] as? String,
errorRecoveryConfiguration.localizedRecoveryDescription,
"Should derive the recovery description from the recovery configuration"
)
XCTAssertEqual(
error?.userInfo[NSLocalizedRecoveryOptionsErrorKey] as? [String],
errorRecoveryConfiguration.localizedRecoveryOptionDescriptions,
"Should derive the recovery options from the recovery configuration"
)
XCTAssertNil(
error?.userInfo[NSRecoveryAttempterErrorKey],
"A non transient error should not provide a recovery attempter"
)
}
func testErrorFromResultMessagePriority() {
var response: [String: Any] = [
"body": [
"error": ["error_msg": "error_msg"],
],
]
var error = connection.error(
fromResult: response,
request: makeSampleRequest()
) as? TestSDKError
XCTAssertEqual(
error?.message,
"error_msg",
"Should use the 'error_msg' if it's the only message available"
)
response = [
"body": [
"error": [
"error_msg": "error_msg",
"error_reason": "error_reason",
],
],
]
error = connection.error(
fromResult: response,
request: makeSampleRequest()
) as? TestSDKError
XCTAssertEqual(
error?.message,
"error_reason",
"Should prefer the 'error_reason' to the 'error_msg'"
)
response = [
"body": [
"error": [
"error_msg": "error_msg",
"error_reason": "error_reason",
"message": "message",
],
],
]
error = connection.error(
fromResult: response,
request: makeSampleRequest()
) as? TestSDKError
XCTAssertEqual(
error?.message,
"message",
"Should prefer the 'message' key to other error message keys"
)
}
// MARK: - Client Token
func testClientToken() throws {
let expectation = XCTestExpectation(description: name)
errorConfigurationProvider.configuration = nil
var capturedError: Error?
connection.add(makeRequestForMeWithEmptyFields()) { _, _, error in
capturedError = error
expectation.fulfill()
}
connection.start()
let data = "{\"error\": {\"message\": \"Token is broken\",\"code\": 190,\"error_subcode\": 463, \"type\":\"OAuthException\"}}".data(using: .utf8)! // swiftlint:disable:this force_unwrapping line_length
let response = HTTPURLResponse(
url: sampleUrl,
statusCode: 400,
httpVersion: nil,
headerFields: nil
)
session.capturedCompletion?(data, response, nil)
wait(for: [expectation], timeout: 1)
let error = try XCTUnwrap(capturedError as NSError?)
// make sure there is no recovery info for client token failures.
XCTAssertNil(error.localizedRecoverySuggestion)
}
func testClientTokenSkipped() throws {
let expectation = expectation(description: name)
var capturedError: Error?
errorConfigurationProvider.configuration = nil
connection.add(makeRequestForMeWithEmptyFields()) { _, _, error in
capturedError = error
expectation.fulfill()
}
connection.start()
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(missingTokenData, response, nil)
wait(for: [expectation], timeout: 1)
let error = try XCTUnwrap(capturedError as NSError?)
// make sure there is no recovery info for client token failures.
XCTAssertNil(error.localizedRecoverySuggestion)
}
func testConnectionDelegate() {
let expectation = expectation(description: name)
var actualCallbacksCount = 0
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in
XCTAssertEqual(1, actualCallbacksCount, "this should have been the second callback")
actualCallbacksCount += 1
}
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in
XCTAssertEqual(2, actualCallbacksCount, "this should have been the third callback")
actualCallbacksCount += 1
}
requestConnectionStartingCallback = { _ in
XCTAssertEqual(actualCallbacksCount, 0, "this should have been the first callback")
actualCallbacksCount += 1
}
requestConnectionCallback = { _, error in
XCTAssertNil(error, "unexpected error: \(String(describing: error))")
XCTAssertEqual(actualCallbacksCount, 3, "this should have been the fourth callback")
actualCallbacksCount += 1
expectation.fulfill()
}
connection.delegate = self
connection.start()
let meResponse = "{ \"Any\":\"userid\"}".replacingOccurrences(of: "\"", with: "\\\"")
let responseString = "[{\"code\":200,\"body\": \"\(meResponse)\" }, {\"code\":200,\"body\": \"\(meResponse)\" } ]"
let data = responseString.data(using: .utf8)
let response = HTTPURLResponse(url: sampleUrl, statusCode: 200, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(data, response, nil)
wait(for: [expectation], timeout: 1)
}
func testNonErrorEmptyDictionaryOrNullResponse() {
let expectation = expectation(description: name)
var actualCallbacksCount = 0
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in
XCTAssertEqual(actualCallbacksCount, 1, "this should have been the second callback")
actualCallbacksCount += 1
}
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in
XCTAssertEqual(actualCallbacksCount, 2, "this should have been the third callback")
actualCallbacksCount += 1
}
requestConnectionStartingCallback = { _ in
XCTAssertEqual(actualCallbacksCount, 0, "this should have been the first callback")
actualCallbacksCount += 1
}
requestConnectionCallback = { _, error in
XCTAssertNil(error, "unexpected error: \(String(describing: error))")
XCTAssertEqual(actualCallbacksCount, 3, "this should have been the fourth callback")
actualCallbacksCount += 1
expectation.fulfill()
}
connection.delegate = self
connection.start()
let responseString = "[{\"code\":200,\"body\": null }, {\"code\":200,\"body\": {} } ]"
let data = responseString.data(using: .utf8)
let response = HTTPURLResponse(url: sampleUrl, statusCode: 200, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(data, response, nil)
wait(for: [expectation], timeout: 1)
}
func testConnectionDelegateWithNetworkError() {
let expectation = expectation(description: name)
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in }
requestConnectionCallback = { _, error in
XCTAssertNotNil(error, "didFinishLoading shouldn't have been called")
expectation.fulfill()
}
connection.delegate = self
connection.start()
session.capturedCompletion?(nil, nil, NSError(domain: ".domain", code: -1009, userInfo: nil))
wait(for: [expectation], timeout: 1)
}
func testUnsettingAccessToken() {
let expectation = expectation(description: name)
let accessToken = AccessToken(
tokenString: "token",
permissions: ["public_profile"],
declinedPermissions: [],
expiredPermissions: [],
appID: "appid",
userID: "userid",
expirationDate: nil,
refreshDate: nil,
dataAccessExpirationDate: nil
)
TestAccessTokenWallet.currentAccessToken = accessToken
connection.add(makeRequest(tokenString: accessToken.tokenString)) { _, result, error in
XCTAssertNil(result)
let testError = error as? TestSDKError
XCTAssertEqual("Token is broken", testError?.message)
XCTAssertNil(
TestAccessTokenWallet.currentAccessToken,
"Should clear the current stored access token"
)
expectation.fulfill()
}
connection.start()
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(missingTokenData, response, nil)
wait(for: [expectation], timeout: 1)
}
func testUnsettingAccessTokenSkipped() {
let expectation = expectation(description: name)
TestAccessTokenWallet.currentAccessToken = AccessToken(
tokenString: "token",
permissions: ["public_profile"],
declinedPermissions: [],
expiredPermissions: [],
appID: "appid",
userID: "userid",
expirationDate: nil,
refreshDate: nil,
dataAccessExpirationDate: nil
)
let request = TestGraphRequest(
graphPath: "me",
parameters: ["fields": ""],
tokenString: "notCurrentToken"
)
connection.add(request) { _, result, error in
XCTAssertNil(result)
let testError = error as? TestSDKError
XCTAssertEqual("Token is broken", testError?.message)
XCTAssertNotNil(TestAccessTokenWallet.currentAccessToken)
expectation.fulfill()
}
connection.start()
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(missingTokenData, response, nil)
wait(for: [expectation], timeout: 1)
}
func testUnsettingAccessTokenFlag() {
let expectation = expectation(description: name)
TestAccessTokenWallet.currentAccessToken = AccessToken(
tokenString: "token",
permissions: ["public_profile"],
declinedPermissions: [],
expiredPermissions: [],
appID: "appid",
userID: "userid",
expirationDate: nil,
refreshDate: nil,
dataAccessExpirationDate: nil
)
let request = TestGraphRequest(
graphPath: "me",
parameters: ["fields": ""],
flags: [.doNotInvalidateTokenOnError]
)
connection.add(request) { _, result, error in
XCTAssertNil(result)
let testError = error as? TestSDKError
XCTAssertEqual("Token is broken", testError?.message)
XCTAssertNotNil(TestAccessTokenWallet.currentAccessToken)
expectation.fulfill()
}
connection.start()
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(missingTokenData, response, nil)
wait(for: [expectation], timeout: 1)
}
func testRequestWithUserAgentSuffix() throws {
settings.userAgentSuffix = "UnitTest.1.0.0"
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in }
connection.start()
let userAgent = try XCTUnwrap(session.capturedRequest?.value(forHTTPHeaderField: "User-Agent"))
XCTAssertTrue(userAgent.hasSuffix("/UnitTest.1.0.0"), "unexpected user agent \(userAgent)")
}
func testRequestWithoutUserAgentSuffix() throws {
settings.userAgentSuffix = nil
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in }
connection.start()
let userAgent = try XCTUnwrap(session.capturedRequest?.value(forHTTPHeaderField: "User-Agent"))
XCTAssertEqual(
userAgent,
"FBiOSSDK.\(FBSDK_VERSION_STRING)",
"unexpected user agent \(userAgent)"
)
}
func testRequestWithMacCatalystUserAgent() throws {
macCatalystDeterminator.stubbedIsMacCatalystApp = true
settings.userAgentSuffix = nil
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in }
connection.start()
let userAgent = try XCTUnwrap(session.capturedRequest?.value(forHTTPHeaderField: "User-Agent"))
XCTAssertTrue(userAgent.hasSuffix("/macOS"), "unexpected user agent \(userAgent)")
}
func testNonDictionaryInError() {
let expectation = expectation(description: name)
connection.add(makeRequestForMeWithEmptyFields()) { _, _, _ in
// should not crash when receiving something other than a dictionary within the response.
expectation.fulfill()
}
connection.start()
let data = "{\"error\": \"a-non-dictionary\"}".data(using: .utf8)
let response = HTTPURLResponse(url: sampleUrl, statusCode: 200, httpVersion: nil, headerFields: nil)
session.capturedCompletion?(data, response, nil)
wait(for: [expectation], timeout: 1)
}
func testRequestWithBatchConstructionWithSingleGetRequest() throws {
let singleRequest = TestGraphRequest(graphPath: "me", parameters: ["fields": "with_suffix"])
connection.add(singleRequest) { _, _, _ in }
let requests = try XCTUnwrap(connection.requests as? [GraphRequestMetadata])
let request = connection.request(withBatch: requests, timeout: 0)
let url = try XCTUnwrap(request.url)
let urlComponents = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: true))
let requestBody = try XCTUnwrap(request.httpBody)
XCTAssertEqual(urlComponents.host, "graph.facebook.com")
XCTAssertTrue(urlComponents.path.contains("me"))
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(requestBody.count, 0)
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
}
func testRequestWithBatchConstructionWithSinglePostRequest() throws {
// T108100329: Abstract internal utility from GraphRequestConnection
AuthenticationToken.current = nil
let parameters: [String: Any] = [
"first_key": "first_value",
]
let singleRequest = TestGraphRequest(graphPath: "activities", parameters: parameters, HTTPMethod: .post)
connection.add(singleRequest) { _, _, _ in }
let requests = try XCTUnwrap(connection.requests as? [GraphRequestMetadata])
let request = connection.request(withBatch: requests, timeout: 0)
let url = try XCTUnwrap(request.url)
let urlComponents = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: true))
let requestBody = try XCTUnwrap(request.httpBody)
XCTAssertEqual(urlComponents.host, "graph.facebook.com")
XCTAssertTrue(urlComponents.path.contains("activities"))
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertGreaterThan(requestBody.count, 0)
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Encoding"), "gzip")
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
}
// MARK: - accessTokenWithRequest
func testAccessTokenWithRequest() {
// T108100329: Abstract internal utility from GraphRequestConnection
AuthenticationToken.current = nil
let expectedToken = "fake_token"
let request = TestGraphRequest(
graphPath: "me",
parameters: ["fields": ""],
tokenString: expectedToken,
HTTPMethod: .get,
flags: []
)
let token = connection.accessToken(with: request)
XCTAssertEqual(token, expectedToken)
}
func testAccessTokenWithRequestWithoutFacebookClientToken() throws {
settings.clientToken = nil
connection.logger = makeLogger()
assertRaisesException(message: "An exception should be raised if a client token is unavailable") {
self.connection.accessToken(with: self.makeRequestForMeWithEmptyFieldsNoTokenString())
}
XCTAssertEqual(
TestLogger.capturedLoggingBehavior,
.developerErrors,
"Should log a developer error when a request is started with no client token set"
)
let message = try XCTUnwrap(TestLogger.capturedLogEntry)
XCTAssertTrue(
message.starts(with: "Starting with v13 of the SDK, a client token must be embedded in your client code"),
"Should log the expected error message when a request is started with no client token set"
)
TestLogger.reset()
assertRaisesException(message: "An exception should be raised if a client token is unavailable") {
self.connection.accessToken(with: self.makeRequestForMeWithEmptyFieldsNoTokenString())
}
XCTAssertEqual(
TestLogger.capturedLoggingBehavior,
.developerErrors,
"Should log consistently for requests started with no client token set"
)
}
func testAccessTokenWithRequestWithFacebookClientToken() {
connection.logger = makeLogger()
let token = connection.accessToken(with: makeRequestForMeWithEmptyFieldsNoTokenString())
let expectedToken = "\(appID)|\(clientToken)"
XCTAssertEqual(token, expectedToken)
XCTAssertNil(
TestLogger.capturedLoggingBehavior,
"Should not log a developer error when a request is started with a client token set"
)
}
func testAccessTokenWithRequestWithGamingClientToken() {
settings.clientToken = clientToken
let authToken = AuthenticationToken(
tokenString: "token_string",
nonce: "nonce",
graphDomain: "gaming"
)
TestAuthenticationTokenWallet.currentAuthenticationToken = authToken
let token = connection.accessToken(with: makeRequestForMeWithEmptyFieldsNoTokenString())
let expectedToken = "GG|\(appID)|\(clientToken)"
XCTAssertEqual(token, expectedToken)
}
// MARK: - Error recovery.
func testRetryWithTransientError() throws {
let expectation = expectation(description: name)
settings.isGraphErrorRecoveryEnabled = true
errorRecoveryConfiguration = makeTransientErrorRecoveryConfiguration()
errorConfiguration.stubbedRecoveryConfiguration = errorRecoveryConfiguration
errorConfigurationProvider.configuration = errorConfiguration
let retryConnection = GraphRequestConnection()
graphRequestConnectionFactory.stubbedConnection = retryConnection
var completionCallCount = 0
var capturedError: Error?
connection.add(makeRequestForMeWithEmptyFields()) { _, _, error in
completionCallCount += 1
XCTAssertEqual(completionCallCount, 1, "The completion should only be called once")
capturedError = error
expectation.fulfill()
}
connection.start()
let data = "{\"error\": {\"message\": \"Server is busy\",\"code\": 1,\"error_subcode\": 463}}".data(using: .utf8)
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
// The first captured completion will be invoked and cause the retry
session.capturedCompletion?(data, response, nil)
// It's necessary to dispatch async to avoid the completion from being invoked before it is captured
DispatchQueue.main.async {
let secondData = "{\"error\": {\"message\": \"Server is busy\",\"code\": 2,\"error_subcode\": 463}}".data(using: .utf8) // swiftlint:disable:this line_length
self.secondSession.capturedCompletion?(secondData, response, nil)
}
wait(for: [expectation], timeout: 1)
let error = try XCTUnwrap(capturedError as NSError?)
XCTAssertEqual(
2,
error.userInfo[GraphRequestErrorGraphErrorCodeKey] as? Int,
"The completion should be called with the expected error code"
)
}
func testRetryDisabled() throws {
settings.isGraphErrorRecoveryEnabled = false
let expectation = expectation(description: name)
var completionCallCount = 0
var capturedError: Error?
connection.add(makeRequestForMeWithEmptyFields()) { _, _, error in
capturedError = error
completionCallCount += 1
XCTAssertEqual(completionCallCount, 1, "The completion should only be called once")
expectation.fulfill()
}
connection.start()
let data = "{\"error\": {\"message\": \"Server is busy\",\"code\": 1,\"error_subcode\": 463}}".data(using: .utf8)
let response = HTTPURLResponse(url: sampleUrl, statusCode: 400, httpVersion: nil, headerFields: nil)
// The first captured completion will be invoked and cause the retry
session.capturedCompletion?(data, response, nil)
wait(for: [expectation], timeout: 1)
let error = try XCTUnwrap(capturedError as NSError?)
XCTAssertEqual(
1,
error.userInfo[GraphRequestErrorGraphErrorCodeKey] as? Int,
"The completion should be called with the expected error code"
)
}
// MARK: - Response Parsing
func testParsingJsonResponseWithInvalidData() {
var value = 0xb70f
let data = Data(bytes: &value, count: 2)
var error: NSError?
connection.parseJSONResponse(data, error: &error, statusCode: 0)
XCTAssertEqual(
eventLogger.capturedEventName?.rawValue,
"fb_response_invalid_utf8",
"Should log the correct event name"
)
XCTAssertTrue(
eventLogger.capturedIsImplicitlyLogged,
"Should implicitly log an event indicating a json parsing failure"
)
}
func testProcessingResultBodyWithDebugDictionary() {
connection.logger = makeLogger()
let entries = [
"message1 Link: link1",
"message2 Link: link2",
]
connection.processResultBody(debugResponse, error: nil, metadata: metadata, canNotifyDelegate: false)
XCTAssertEqual(
TestLogger.capturedLogEntries,
entries,
"Should log entries from the debug dictionary"
)
}
func testProcessingResultBodyWithRandomizedDebugDictionary() {
(1 ..< 100).forEach { _ in
if let body = Fuzzer.randomize(json: debugResponse) as? [String: Any] {
connection.processResultBody(body, error: nil, metadata: metadata, canNotifyDelegate: false)
}
}
}
func testLogRequestWithInactiveLogger() {
let request = NSMutableURLRequest(url: sampleUrl)
let logger = makeLogger()
let bodyLogger = makeLogger()
let attachmentLogger = makeLogger()
connection.logger = logger
connection.logRequest(request, bodyLength: 1024, bodyLogger: bodyLogger, attachmentLogger: attachmentLogger)
XCTAssertEqual(logger.capturedAppendedKeys, [])
XCTAssertEqual(logger.capturedAppendedValues, [])
}
func testLogRequestWithActiveLogger() {
let request = NSMutableURLRequest(url: sampleUrl)
request.addValue("user agent", forHTTPHeaderField: "User-Agent")
request.addValue("content type", forHTTPHeaderField: "Content-Type")
let logger = makeLogger()
let bodyLogger = makeLogger()
let attachmentLogger = makeLogger()
// Start with some previously 'logged' contents
bodyLogger.capturedContents = "bodyContents"
attachmentLogger.capturedContents = "attachmentLoggerContents"
logger.stubbedIsActive = true
connection.logger = logger
connection.logRequest(request, bodyLength: 1024, bodyLogger: bodyLogger, attachmentLogger: attachmentLogger)
let expectedKeys = [
"URL",
"Method",
"UserAgent",
"MIME",
"Body Size",
"Body (w/o attachments)",
"Attachments",
]
let expectedValues = [
"https://example.com",
"GET",
"user agent",
"content type",
"1 kB",
"bodyContents",
"attachmentLoggerContents",
]
XCTAssertEqual(
logger.capturedAppendedKeys,
expectedKeys,
"Should append the expected key value pairs to log"
)
XCTAssertEqual(
logger.capturedAppendedValues,
expectedValues,
"Should append the expected key value pairs to log"
)
}
func testInvokesDelegate() {
connection.delegate = self
connection.urlSession(
URLSession.shared,
task: URLSessionDataTask(),
didSendBodyData: 0,
totalBytesSent: 0,
totalBytesExpectedToSend: 0
)
XCTAssertTrue(
didInvokeDelegateRequestConnectionDidSendBodyData,
"The url session data delegate should pass through to the graph request connection delegate"
)
}
// MARK: - Helpers
func makeLogger() -> TestLogger {
TestLogger(loggingBehavior: .developerErrors)
}
func makeSampleRequest() -> TestGraphRequest {
makeRequestForMeWithEmptyFields()
}
func makeSampleRequest(parameters: [String: Any]) -> TestGraphRequest {
TestGraphRequest(graphPath: "me", parameters: parameters)
}
func makeRequestForMeWithEmptyFields() -> TestGraphRequest {
TestGraphRequest(graphPath: "me", parameters: ["fields": ""])
}
func makeRequestForMeWithEmptyFieldsNoTokenString() -> TestGraphRequest {
TestGraphRequest(
graphPath: "me",
parameters: ["fields": ""],
flags: []
)
}
func makeRequest(tokenString: String) -> TestGraphRequest {
TestGraphRequest(
graphPath: "me",
parameters: ["fields": ""],
tokenString: tokenString
)
}
func makeMetadata(from request: GraphRequestProtocol) -> GraphRequestMetadata {
GraphRequestMetadata(
request: request,
completionHandler: nil,
batchParameters: [:]
)
}
func makeSampleErrorDictionary() -> [String: Any] {
[
"code": 200,
"body": [
"error": [
"is_transient": 1,
"code": 1,
"error_subcode": 2,
"error_msg": "error_msg",
"error_reason": "error_reason",
"message": "message",
"error_user_title": "error_user_title",
"error_user_msg": "error_user_msg",
],
],
]
}
func makeTransientErrorRecoveryConfiguration() -> ErrorRecoveryConfiguration {
ErrorRecoveryConfiguration(
recoveryDescription: "Recovery Description",
optionDescriptions: ["Option1", "Option2"],
category: .transient,
recoveryActionName: "Recovery Action"
)
}
func makeNonTransientErrorRecoveryConfiguration() -> ErrorRecoveryConfiguration {
ErrorRecoveryConfiguration(
recoveryDescription: "Recovery Description",
optionDescriptions: ["Option1", "Option2"],
category: .other,
recoveryActionName: "Recovery Action"
)
}
var debugResponse: [String: Any] {
[
"__debug__": [
"messages": [
[
"message": "message1",
"type": "type1",
"link": "link1",
],
[
"message": "message2",
"type": "warning",
"link": "link2",
],
],
],
]
}
}