diff --git a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift index 54f069c804a..f3bb15379aa 100644 --- a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift +++ b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift @@ -52,6 +52,9 @@ public class AlamofireNetwork: Network { /// Keeps track of failure counts for each site when switching to direct requests private var appPasswordFailures: [Int64: Int] = [:] + /// Keeps track of retried requests when direct requests fail + private var retriedJetpackRequests: [RetriedJetpackRequest] = [] + /// Public Initializer /// /// - Parameters: @@ -116,11 +119,21 @@ public class AlamofireNetwork: Network { /// - Yes. We do the above because the Jetpack Tunnel endpoint doesn't properly relay the correct statusCode. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - let request = requestConverter.convert(request) - alamofireSession.request(request) - .validateIfRestRequest(for: request) - .responseData { response in - completion(response.value, response.networkingError) + let convertedRequest = convertRequestIfNeeded(request) + alamofireSession.request(convertedRequest) + .validateIfRestRequest(for: convertedRequest) + .responseData { [weak self] response in + self?.handleFailureForDirectRequestIfNeeded( + originalRequest: request, + convertedRequest: convertedRequest, + failure: response.networkingError, + onRetry: { + self?.responseData(for: request, completion: completion) + }, + onCompletion: { + completion(response.value, response.networkingError) + } + ) } } @@ -134,23 +147,45 @@ public class AlamofireNetwork: Network { /// - completion: Closure to be executed upon completion. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - let request = requestConverter.convert(request) - alamofireSession.request(request) - .validateIfRestRequest(for: request) - .responseData { response in - if let error = response.networkingError { - completion(.failure(error)) - } else { - completion(response.result.mapError { $0 }) - } + let convertedRequest = convertRequestIfNeeded(request) + alamofireSession.request(convertedRequest) + .validateIfRestRequest(for: convertedRequest) + .responseData { [weak self] response in + self?.handleFailureForDirectRequestIfNeeded( + originalRequest: request, + convertedRequest: convertedRequest, + failure: response.networkingError, + onRetry: { + self?.responseData(for: request, completion: completion) + }, + onCompletion: { + if let error = response.networkingError { + completion(.failure(error)) + } else { + completion(response.result.mapError { $0 }) + } + } + ) } } public func responseDataAndHeaders(for request: URLRequestConvertible) async throws -> (Data, ResponseHeaders?) { - let request = requestConverter.convert(request) - let sessionRequest = alamofireSession.request(request) - .validateIfRestRequest(for: request) + let convertedRequest = convertRequestIfNeeded(request) + let sessionRequest = alamofireSession.request(convertedRequest) + .validateIfRestRequest(for: convertedRequest) let response = await sessionRequest.serializingData().response + let failure = response.networkingError + + if shouldRetryJetpackRequest( + originalRequest: request, + convertedRequest: convertedRequest, + failure: failure + ) { + return try await responseDataAndHeaders(for: request) + } + + flagSiteAsUnsupportedForAppPasswordIfNeeded(originalRequest: request, failure: failure) + if let error = response.networkingError { throw error } @@ -172,16 +207,28 @@ public class AlamofireNetwork: Network { /// - Returns: A publisher that emits the result of the given request. public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { return Future() { promise in - let request = self.requestConverter.convert(request) + let convertedRequest = self.convertRequestIfNeeded(request) self.alamofireSession - .request(request) - .validateIfRestRequest(for: request) - .responseData { response in - if let error = response.networkingError { - promise(.success(.failure(error))) - } else { - promise(.success(response.result.mapError { $0 })) - } + .request(convertedRequest) + .validateIfRestRequest(for: convertedRequest) + .responseData { [weak self] response in + self?.handleFailureForDirectRequestIfNeeded( + originalRequest: request, + convertedRequest: convertedRequest, + failure: response.networkingError, + onRetry: { + self?.responseData(for: request) { result in + promise(.success(result)) + } + }, + onCompletion: { + if let error = response.networkingError { + promise(.success(.failure(error))) + } else { + promise(.success(response.result.mapError { $0 })) + } + } + ) } }.eraseToAnyPublisher() } @@ -189,11 +236,21 @@ public class AlamofireNetwork: Network { public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, to request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - let request = requestConverter.convert(request) + let convertedRequest = self.convertRequestIfNeeded(request) alamofireSession - .upload(multipartFormData: multipartFormData, with: request) - .responseData { response in - completion(response.value, response.error) + .upload(multipartFormData: multipartFormData, with: convertedRequest) + .responseData { [weak self] response in + self?.handleFailureForDirectRequestIfNeeded( + originalRequest: request, + convertedRequest: convertedRequest, + failure: response.networkingError, + onRetry: { + self?.uploadMultipartFormData(multipartFormData: multipartFormData, to: request, completion: completion) + }, + onCompletion: { + completion(response.value, response.error) + } + ) } } } @@ -232,23 +289,118 @@ private extension AlamofireNetwork { } } +// MARK: Helper methods for error handling +// +private extension AlamofireNetwork { + func convertRequestIfNeeded(_ request: URLRequestConvertible) -> URLRequestConvertible { + let isRetried = retriedJetpackRequests.contains { retriedRequest in + let urlRequest = try? request.asURLRequest() + let currentItem = try? retriedRequest.request.asURLRequest() + return currentItem == urlRequest + } + if isRetried { + return request // do not convert + } + return requestConverter.convert(request) + } + + /// Checks if the specified request and error are eligible for retrying as Jetpack request. + /// If yes, enqueue the original request to the retried list before returning. + /// + func shouldRetryJetpackRequest(originalRequest: URLRequestConvertible, + convertedRequest: URLRequestConvertible, + failure: Error?) -> Bool { + if let request = originalRequest as? JetpackRequest, + convertedRequest is RESTRequest, + case .some(.wpcom) = credentials, + let failure = failure as? NetworkError { + let retriedRequest = RetriedJetpackRequest(request: request, error: failure) + retriedJetpackRequests.append(retriedRequest) + return true + } + return false + } + + /// Determines if the site has issue with application password based on the original request. + /// + func flagSiteAsUnsupportedForAppPasswordIfNeeded(originalRequest: URLRequestConvertible, + failure: Error?) { + let retriedRequestIndex = retriedJetpackRequests.firstIndex { retriedRequest in + let urlRequest = try? originalRequest.asURLRequest() + let retriedRequest = try? retriedRequest.request.asURLRequest() + return urlRequest == retriedRequest + } + guard let index = retriedRequestIndex else { return } + + if failure == nil { + let siteID = retriedJetpackRequests[index].request.siteID + let originalFailure = retriedJetpackRequests[index].error + switch originalFailure { + case .unacceptableStatusCode(statusCode: 401, _), + .unacceptableStatusCode(statusCode: 403, _), + .unacceptableStatusCode(statusCode: 429, _): + flagSiteAsUnsupported(for: siteID) + default: + if let code = originalFailure.errorCode, AppPasswordConstants.disabledCodes.contains(code) { + flagSiteAsUnsupported(for: siteID) + } else { + incrementFailureCount(for: siteID) + } + } + } + + // remove retried request from list + retriedJetpackRequests.remove(at: index) + } + + func handleFailureForDirectRequestIfNeeded(originalRequest: URLRequestConvertible, + convertedRequest: URLRequestConvertible, + failure: Error?, + onRetry: @escaping () -> Void, + onCompletion: @escaping () -> Void) { + if shouldRetryJetpackRequest(originalRequest: originalRequest, + convertedRequest: convertedRequest, + failure: failure) { + onRetry() + } else { + flagSiteAsUnsupportedForAppPasswordIfNeeded(originalRequest: originalRequest, failure: failure) + onCompletion() + } + } + + /// Helper type to keep track of retried requests with accompanied error + /// + struct RetriedJetpackRequest { + let request: JetpackRequest + let error: NetworkError + } +} + // MARK: `RequestProcessorDelegate` conformance // extension AlamofireNetwork: RequestProcessorDelegate { func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, reason: AppPasswordFailureReason) { switch reason { case .notSupported: + flagSiteAsUnsupported(for: siteID) + case .unknown: + incrementFailureCount(for: siteID) + } + } + + func flagSiteAsUnsupported(for siteID: Int64) { + let currentList = userDefaults.applicationPasswordUnsupportedList + userDefaults.applicationPasswordUnsupportedList = currentList + [siteID] + } + + func incrementFailureCount(for siteID: Int64) { + let currentFailureCount = appPasswordFailures[siteID] ?? 0 + let updatedCount = currentFailureCount + 1 + if updatedCount == AppPasswordConstants.requestFailureThreshold { let currentList = userDefaults.applicationPasswordUnsupportedList userDefaults.applicationPasswordUnsupportedList = currentList + [siteID] - case .unknown: - let currentFailureCount = appPasswordFailures[siteID] ?? 0 - let updatedCount = currentFailureCount + 1 - if updatedCount == AppPasswordConstants.requestFailureThreshold { - let currentList = userDefaults.applicationPasswordUnsupportedList - userDefaults.applicationPasswordUnsupportedList = currentList + [siteID] - } - appPasswordFailures[siteID] = updatedCount } + appPasswordFailures[siteID] = updatedCount } } @@ -258,7 +410,8 @@ enum AppPasswordConstants { static let requestFailureThreshold = 10 static let disabledCodes = [ "application_passwords_disabled", - "application_passwords_disabled_for_user" + "application_passwords_disabled_for_user", + "incorrect_password" ] } diff --git a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift index 45b14957c68..6e6a0e89ed4 100644 --- a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift +++ b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift @@ -292,8 +292,428 @@ final class AlamofireNetworkTests: XCTestCase { // Then XCTAssertTrue(true) } catch { - XCTFail("Requests should fail with sessionDeinitialized error") + XCTFail("Requests should fail with sessionDeinitialized error, got \(error) instead") + } + } + + // MARK: - Retry Logic Tests + + func test_responseData_with_completion_retries_direct_request_when_converted_request_fails() throws { + // Given + let siteID: Int64 = 123 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "products") + let restRequest = createRESTRequest(path: "products") + let network = createNetworkWithSelectedSite(siteID: siteID) + + try setupMockForDirectRequestFailure(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 401, + failureResponse: ["error": "unauthorized"]) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + let responseDict = try JSONSerialization.jsonObject(with: result.0!, options: []) as? [String: String] + XCTAssertEqual(responseDict?["success"], "data") + } + + func test_responseData_with_result_retries_direct_request_when_converted_request_fails() throws { + // Given + let siteID: Int64 = 456 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "orders") + let restRequest = createRESTRequest(path: "orders") + let network = createNetworkWithSelectedSite(siteID: siteID) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 403, + failureResponse: ["error": "forbidden"], + successResponse: ["success": "orders"]) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isSuccess) + let data = try XCTUnwrap(result.get()) + let responseDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] + XCTAssertEqual(responseDict?["success"], "orders") + } + + func test_responseData_flags_site_as_unsupported_when_jetpack_retry_succeeds_after_401_failure() throws { + // Given + let siteID: Int64 = 789 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "customers") + let restRequest = createRESTRequest(path: "customers") + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 401, + failureResponse: ["error": "unauthorized"], + successResponse: ["success": "customers"]) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + func test_responseDataAndHeaders_retries_direct_request_when_converted_request_fails() async throws { + // Given + let siteID: Int64 = 101 + let testParameters = ["name": "Test Product"] + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "products", method: .post, parameters: testParameters) + let restRequest = createRESTRequest(path: "products", method: .post, parameters: testParameters) + let network = createNetworkWithSelectedSite(siteID: siteID) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 429, + failureResponse: ["error": "rate_limited"], + successResponse: ["product": "created"]) + + // When + let result = try await network.responseDataAndHeaders(for: jetpackRequest) + + // Then + let responseDict = try JSONSerialization.jsonObject(with: result.0, options: []) as? [String: String] + XCTAssertEqual(responseDict?["product"], "created") + } + + func test_responseDataAndHeaders_flags_site_as_unsupported_when_jetpack_retry_succeeds_after_403_failure() async throws { + // Given + let siteID: Int64 = 202 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "reports") + let restRequest = createRESTRequest(path: "reports") + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 403, + failureResponse: ["error": "forbidden"], + successResponse: ["reports": "data"]) + + // When + let result = try await network.responseDataAndHeaders(for: jetpackRequest) + + // Then + let responseDict = try JSONSerialization.jsonObject(with: result.0, options: []) as? [String: String] + XCTAssertEqual(responseDict?["reports"], "data") + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + func test_responseDataPublisher_retries_direct_request_when_converted_request_fails() { + // Given + let siteID: Int64 = 303 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "settings") + let restRequest = createRESTRequest(path: "settings") + let network = createNetworkWithSelectedSite(siteID: siteID) + + try! setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 500, + failureResponse: ["error": "server_error"], + successResponse: ["settings": "values"]) + + // When + let result = waitFor { promise in + self.responseDataSubscription = network.responseDataPublisher(for: jetpackRequest) + .sink { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isSuccess) + let data = try! result.get() + let responseDict = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: String] + XCTAssertEqual(responseDict["settings"], "values") + } + + func test_responseDataPublisher_flags_site_as_unsupported_when_jetpack_retry_succeeds_after_429_failure() { + // Given + let siteID: Int64 = 404 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "stats") + let restRequest = createRESTRequest(path: "stats") + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try! setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 429, + failureResponse: ["error": "rate_limited"], + successResponse: ["stats": "data"]) + + // When + let result = waitFor { promise in + self.responseDataSubscription = network.responseDataPublisher(for: jetpackRequest) + .sink { result in + promise(result) + } } + + // Then + XCTAssertTrue(result.isSuccess) + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + func test_uploadMultipartFormData_retries_direct_request_when_converted_request_fails() { + // Given + let siteID: Int64 = 505 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "media", method: .post) + let restRequest = createRESTRequest(path: "media", method: .post) + let network = createNetworkWithSelectedSite(siteID: siteID) + + try! setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 400, + failureResponse: ["error": "upload_failed"], + successResponse: ["media": "uploaded"]) + + // When + let result = waitFor { promise in + network.uploadMultipartFormData(multipartFormData: { formData in + let testData = "test data".data(using: .utf8)! + formData.append(testData, withName: "file") + }, to: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + let responseDict = try! JSONSerialization.jsonObject(with: result.0!, options: []) as! [String: String] + XCTAssertEqual(responseDict["media"], "uploaded") + } + + func test_uploadMultipartFormData_flags_site_as_unsupported_when_jetpack_retry_succeeds_after_failure() { + // Given + let siteID: Int64 = 606 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "media", method: .post) + let restRequest = createRESTRequest(path: "media", method: .post) + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try! setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 401, + failureResponse: ["error": "unauthorized"], + successResponse: ["upload": "success"]) + + // When + let result = waitFor { promise in + network.uploadMultipartFormData(multipartFormData: { formData in + let imageData = "fake image data".data(using: .utf8)! + formData.append(imageData, withName: "image") + }, to: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + // MARK: - Application Password Error Code Tests + + func test_responseData_flags_site_as_unsupported_when_jetpack_retry_succeeds_after_application_passwords_disabled_error() throws { + // Given + let siteID: Int64 = 707 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "plugins") + let restRequest = createRESTRequest(path: "plugins") + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 400, + failureResponse: [ + "code": "application_passwords_disabled", + "message": "Application passwords are disabled" + ], + successResponse: ["plugins": "list"]) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + func test_responseData_increments_failure_count_when_jetpack_retry_succeeds_after_unknown_error() throws { + // Given + let siteID: Int64 = 808 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "system_status") + let restRequest = createRESTRequest(path: "system_status") + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 400, + failureResponse: [ + "code": "unknown_error", + "message": "Some unknown error" + ], + successResponse: ["status": "ok"]) + + // When - Call once, should not flag site yet + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + } + + func test_responseData_flags_site_as_unsupported_when_unknown_error_threshold_reached() throws { + // Given + let siteID: Int64 = 909 + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) + + // When - Call 10 times to reach threshold + for i in 1...10 { + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "test_\(i)") + let restRequest = createRESTRequest(path: "test_\(i)") + + try setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: jetpackRequest, + restRequest: restRequest, + failureStatusCode: 400, + failureResponse: [ + "code": "random_error", + "message": "Random error \(i)" + ], + successResponse: ["result": "success_\(i)"]) + + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + XCTAssertNil(result.1) + XCTAssertNotNil(result.0) + } + + // Then + XCTAssertEqual(userDefaults.applicationPasswordUnsupportedList, [siteID]) + } + + func test_responseData_does_not_retry_when_jetpack_request_not_available_as_rest() throws { + // Given + let siteID: Int64 = 111 + let jetpackRequest = JetpackRequest(wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: "test", + availableAsRESTRequest: false) + let network = createNetworkWithSelectedSite(siteID: siteID, userDefaults: userDefaults) + + // Mock Jetpack request to fail + let jetpackUrlRequest = try XCTUnwrap(try? jetpackRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(["error": "failed"], statusCode: 400, for: jetpackUrlRequest) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then - Should return error without retrying + XCTAssertNotNil(result.1) + let networkError = result.1 as? NetworkError + XCTAssertEqual(networkError?.errorCode, "failed") + } + + func test_responseData_does_not_retry_when_credentials_not_wpcom() throws { + // Given + let siteID: Int64 = 333 + let restRequest = createRESTRequest(path: "test") + let wporgCredentials = Credentials.wporg(username: "user", password: "pass", siteAddress: "https://example.com") + let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wporgCredentials, userDefaults: userDefaults) + + // Mock Jetpack request to fail + let urlRequest = try XCTUnwrap(try? restRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(["error": "failed"], statusCode: 400, for: urlRequest) + + // When + let result = waitFor { promise in + network.responseData(for: restRequest) { data, error in + promise((data, error)) + } + } + + // Then - Should return error without retrying + XCTAssertNotNil(result.1) + let networkError = result.1 as? NetworkError + XCTAssertEqual(networkError?.errorCode, "failed") + } + + func test_responseData_does_not_retry_when_no_selected_site_injected() throws { + // Given + let siteID: Int64 = 333 + let jetpackRequest = createJetpackRequest(siteID: siteID, path: "test") + let wpcomCredentials = createWPComCredentials() + let network = AlamofireNetwork(credentials: wpcomCredentials, sessionManager: createSessionWithMockURLProtocol()) + + // Mock Jetpack request to fail + let jetpackUrlRequest = try XCTUnwrap(try? jetpackRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(["error": "failed"], statusCode: 400, for: jetpackUrlRequest) + + // When + let result = waitFor { promise in + network.responseData(for: jetpackRequest) { data, error in + promise((data, error)) + } + } + + // Then - Should return error without retrying since no selected site means no request conversion + XCTAssertNotNil(result.1) + let networkError = result.1 as? NetworkError + XCTAssertEqual(networkError?.errorCode, "failed") } } @@ -303,4 +723,70 @@ private extension AlamofireNetworkTests { configuration.protocolClasses = [MockURLProtocol.self] return Session(configuration: configuration) } + + func createJetpackRequest(siteID: Int64, path: String, method: HTTPMethod = .get, parameters: [String: Any]? = nil) -> JetpackRequest { + return JetpackRequest(wooApiVersion: .mark3, + method: method, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) + } + + func createRESTRequest(path: String, method: HTTPMethod = .get, parameters: [String: Any]? = nil) -> RESTRequest { + return RESTRequest(siteURL: "https://example.com", + wooApiVersion: .mark3, + method: method, + path: path, + parameters: parameters) + } + + func createWPComCredentials() -> Credentials { + return Credentials.wpcom(username: "user", authToken: "token", siteAddress: "https://example.com") + } + + func createSelectedSitePublisher(siteID: Int64) -> AnyPublisher { + let site = JetpackSite(siteID: siteID, siteAddress: "https://example.com", applicationPasswordAvailable: true) + return Just(site).eraseToAnyPublisher() + } + + func createNetworkWithSelectedSite(siteID: Int64, credentials: Credentials? = nil, userDefaults: UserDefaults? = nil) -> AlamofireNetwork { + let networkCredentials = credentials ?? createWPComCredentials() + let selectedSite = createSelectedSitePublisher(siteID: siteID) + let network = AlamofireNetwork( + credentials: networkCredentials, + selectedSite: selectedSite, + userDefaults: userDefaults ?? .standard, + sessionManager: createSessionWithMockURLProtocol() + ) + network.updateAppPasswordSwitching(enabled: true) + return network + } + + func setupMockForDirectRequestFailure(jetpackRequest: JetpackRequest, + restRequest: RESTRequest, + failureStatusCode: Int, + failureResponse: AnyCodable = ["error": "failed"]) throws { + // Mock REST request to fail + let restUrlRequest = try XCTUnwrap(try? restRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(failureResponse, statusCode: failureStatusCode, for: restUrlRequest) + + // Mock Jetpack request to succeed + let jetpackUrlRequest = try XCTUnwrap(try? jetpackRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(["success": "data"], statusCode: 200, for: jetpackUrlRequest) + } + + func setupMockForDirectRequestFailureWithRetrySuccess(jetpackRequest: JetpackRequest, + restRequest: RESTRequest, + failureStatusCode: Int, + failureResponse: AnyCodable = ["error": "failed"], + successResponse: AnyCodable) throws { + // Mock REST request to fail + let restUrlRequest = try XCTUnwrap(try? restRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(failureResponse, statusCode: failureStatusCode, for: restUrlRequest) + + // Mock Jetpack request to succeed with custom response + let jetpackUrlRequest = try XCTUnwrap(try? jetpackRequest.asURLRequest()) + MockURLProtocol.Mocks.mockResponse(successResponse, statusCode: 200, for: jetpackUrlRequest) + } }