diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/ApiBaseTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/ApiBaseTask.swift index 9bec5473ef..36fc4b0611 100644 --- a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/ApiBaseTask.swift +++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/ApiBaseTask.swift @@ -2,6 +2,12 @@ import Foundation import PocketCastsDataModel import PocketCastsUtils +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case delete = "DELETE" +} + class ApiBaseTask: Operation { private let syncTimeout = 60 as TimeInterval private let isoDateFormatter = ISO8601DateFormatter() @@ -41,29 +47,9 @@ class ApiBaseTask: Operation { private func performPostToServer(url: String, token: String, data: Data, retryOnUnauthorized: Bool = true) -> (Data?, Int) { let requestUrl = ServerHelper.asUrl(url) - let method = "POST" - var request = createRequest(url: requestUrl, method: method, token: token) - do { - request.httpBody = data - - let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request) - guard let httpResponse = response as? HTTPURLResponse else { return (nil, ServerConstants.HttpConstants.serverError) } - if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized { - if retryOnUnauthorized, let newToken = tokenHelper.acquireToken() { - return performPostToServer(url: url, token: newToken, data: data, retryOnUnauthorized: false) - } - // our token may have expired, remove it so next time a sync happens we'll acquire a new one - KeychainHelper.removeKey(ServerConstants.Values.syncingV2TokenKey) - return (nil, httpResponse.statusCode) - } - - return (responseData, httpResponse.statusCode) - } catch { - logFailure(method: method, url: url, error: error) - } - - return (nil, ServerConstants.HttpConstants.serverError) + let (data, response) = requestToServer(url: requestUrl, method: .post, token: token, retryOnUnauthorized: retryOnUnauthorized, data: data) + return (data, response?.statusCode ?? ServerConstants.HttpConstants.serverError) } func getToServer(url: String, token: String, customHeaders: [String: String]? = nil) -> (Data?, HTTPURLResponse?) { @@ -72,8 +58,20 @@ class ApiBaseTask: Operation { func performGetToServer(url: String, token: String, retryOnUnauthorized: Bool = true, customHeaders: [String: String]? = nil) -> (Data?, HTTPURLResponse?) { let requestUrl = ServerHelper.asUrl(url) - let method = "GET" - var request = createRequest(url: requestUrl, method: method, token: token) + + return requestToServer(url: requestUrl, method: .get, token: token, retryOnUnauthorized: retryOnUnauthorized, customHeaders: customHeaders) + } + + func deleteToServer(url: String, token: String?, data: Data) -> (Data?, Int) { + let url = ServerHelper.asUrl(url) + + let (data, response) = requestToServer(url: url, method: .delete, token: token, data: data) + return (data, response?.statusCode ?? ServerConstants.HttpConstants.serverError) + } + + func requestToServer(url: URL, method: HTTPMethod, token: String?, retryOnUnauthorized: Bool = true, customHeaders: [String: String]? = nil, data: Data? = nil) -> (Data?, HTTPURLResponse?) { + var request = createRequest(url: url, method: method.rawValue, token: token) + if let customHeaders = customHeaders { for header in customHeaders { request.setValue(header.value, forHTTPHeaderField: header.key) @@ -81,11 +79,17 @@ class ApiBaseTask: Operation { } do { + request.httpBody = data + let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request) - guard let httpResponse = response as? HTTPURLResponse else { return (nil, nil) } + guard let httpResponse = response as? HTTPURLResponse else { + return (nil, HTTPURLResponse(url: url, statusCode: ServerConstants.HttpConstants.serverError, httpVersion: nil, headerFields: nil)) + } + if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized { + if retryOnUnauthorized, let newToken = tokenHelper.acquireToken() { - return performGetToServer(url: url, token: newToken, retryOnUnauthorized: false, customHeaders: customHeaders) + return requestToServer(url: url, method: method, token: newToken, retryOnUnauthorized: retryOnUnauthorized, customHeaders: customHeaders, data: data) } // our token may have expired, remove it so next time a sync happens we'll acquire a new one @@ -95,33 +99,10 @@ class ApiBaseTask: Operation { return (responseData, httpResponse) } catch { - logFailure(method: method, url: url, error: error) - } - - return (nil, nil) - } - - func deleteToServer(url: String, token: String?, data: Data) -> (Data?, Int) { - let url = ServerHelper.asUrl(url) - let method = "DELETE" - var request = createRequest(url: url, method: method, token: token) - do { - request.httpBody = data - - let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request) - guard let httpResponse = response as? HTTPURLResponse else { return (nil, ServerConstants.HttpConstants.serverError) } - if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized { - // our token may have expired, remove it so next time a sync happens we'll acquire a new one - KeychainHelper.removeKey(ServerConstants.Values.syncingV2TokenKey) - return (nil, httpResponse.statusCode) - } - - return (responseData, httpResponse.statusCode) - } catch { - logFailure(method: method, url: url.absoluteString, error: error) + logFailure(method: method.rawValue, url: url.absoluteString, error: error) } - return (nil, ServerConstants.HttpConstants.serverError) + return (nil, HTTPURLResponse(url: url, statusCode: ServerConstants.HttpConstants.serverError, httpVersion: nil, headerFields: nil)) } func formatDate(_ date: Date?) -> String { diff --git a/Modules/Server/Tests/PocketCastsServerTests/APIBaseTaskTests.swift b/Modules/Server/Tests/PocketCastsServerTests/APIBaseTaskTests.swift new file mode 100644 index 0000000000..adfc9f4977 --- /dev/null +++ b/Modules/Server/Tests/PocketCastsServerTests/APIBaseTaskTests.swift @@ -0,0 +1,173 @@ +@testable import PocketCastsServer +import XCTest + +final class APIBaseTaskTests: XCTestCase { + + func testGetRequest() { + + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.httpMethod, "GET") + expectation.fulfill() + return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + }) + + let (_, response) = task.getToServer(url: "http://pocketcasts.com", token: "") + + XCTAssertEqual(response?.statusCode, 200) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + func testGetHeadersRequest() { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.value(forHTTPHeaderField: "Test"), "TestValue") + XCTAssertEqual(request.httpMethod, "GET") + expectation.fulfill() + return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + }) + + let (_, response) = task.getToServer(url: "http://pocketcasts.com", token: "", customHeaders: ["Test": "TestValue"]) + + XCTAssertEqual(response?.statusCode, 200) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + func testPostRequest() { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.httpMethod, "POST") + expectation.fulfill() + return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + }) + + let (_, statusCode) = task.postToServer(url: "http://pocketcasts.com", token: "", data: Data()) + + XCTAssertEqual(statusCode, 200) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + func testEmptyResponsePostRequest() { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.httpMethod, "POST") + expectation.fulfill() + return (nil, nil) + }) + + let (data, statusCode) = task.postToServer(url: "http://pocketcasts.com", token: "", data: Data()) + + XCTAssertNil(data) + XCTAssertEqual(statusCode, 500) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + func testDeleteRequest() { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.httpMethod, "DELETE") + expectation.fulfill() + return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + }) + + let (_, statusCode) = task.deleteToServer(url: "http://pocketcasts.com", token: "", data: Data()) + + XCTAssertEqual(statusCode, 200) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + func testEmptyResponseDeleteRequest() { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in + XCTAssertEqual(request.httpMethod, "DELETE") + expectation.fulfill() + return (nil, nil) + }) + + let (data, statusCode) = task.deleteToServer(url: "http://pocketcasts.com", token: nil, data: Data()) + + XCTAssertNil(data) + XCTAssertEqual(statusCode, 500) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + } + + // MARK: Generic Request tests + + /// Mocks requests for testing with standard checks for HTTPMethod and expectation to wait on response from callbacks. + /// - Parameters: + /// - httpMethod: The httpMethod to send + /// - handler: An optional handler to produce a Data and HTTPURLResponse for a given URLRequest + /// - Returns: Data and HTTPURLResponse + private func genericRequest(httpMethod: HTTPMethod, handler: ((URLRequest) -> (Data?, HTTPURLResponse?))? = nil) -> (Data?, HTTPURLResponse?) { + let expectation = self.expectation(description: "APIBaseTask should complete") + let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, HTTPURLResponse?) in + XCTAssertEqual(request.httpMethod, httpMethod.rawValue) + expectation.fulfill() + if let response = handler?(request) { + return response + } else { + return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + } + }) + + let url = URL(string: "http://pocketcasts.com")! + let (data, response) = task.requestToServer(url: url, method: httpMethod, token: nil, retryOnUnauthorized: false, customHeaders: nil, data: nil) + + OperationQueue.main.addOperation(task) + self.waitForExpectations(timeout: 5) + + return (data, response) + } + + func testGenericGetRequest() { + let (data, response) = genericRequest(httpMethod: .get) + + XCTAssertNil(data) + XCTAssertEqual(response?.statusCode, 200) + } + + func testGenericPostRequest() { + let (data, response) = genericRequest(httpMethod: .post) + + XCTAssertNil(data) + XCTAssertEqual(response?.statusCode, 200) + } + + func testGenericEmptyResponsePostRequest() { + let (data, response) = genericRequest(httpMethod: .post) { _ in + return (nil, nil) + } + + XCTAssertNil(data) + XCTAssertEqual(response?.statusCode, 500) + } + + func testGenericDeleteRequest() { + let (data, response) = genericRequest(httpMethod: .delete) + + XCTAssertNil(data) + XCTAssertEqual(response?.statusCode, 200) + } + + func testGenericEmptyResponseDeleteRequest() { + let (data, response) = genericRequest(httpMethod: .delete) { _ in + return (nil, nil) + } + + XCTAssertNil(data) + XCTAssertEqual(response?.statusCode, 500) + } + +} diff --git a/Modules/Server/Tests/PocketCastsServerTests/MockRequestHandler.swift b/Modules/Server/Tests/PocketCastsServerTests/MockRequestHandler.swift new file mode 100644 index 0000000000..70e29e2513 --- /dev/null +++ b/Modules/Server/Tests/PocketCastsServerTests/MockRequestHandler.swift @@ -0,0 +1,31 @@ +@testable import PocketCastsServer +import XCTest + +struct MockRequestHandler { + typealias Handler = ((URLRequest) throws -> (Data?, URLResponse?)) + + let handler: Handler + + init(handler: @escaping ((URLRequest) throws -> (Data?, URLResponse?))) { + self.handler = handler + } +} + +extension MockRequestHandler: RequestHandler { + func send(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + do { + let (data, response) = try handler(request) + completion(data, response, nil) + } catch let error { + completion(nil, nil, error) + } + } +} + +extension URLConnection { + /// A convenient initializer to pass a block which returns data, response, and error for a given URLRequest. + /// - Parameter mockHandler: The handler block (URLRequest) throws -> (Data, URLResponse?) + convenience init(mockHandler: @escaping MockRequestHandler.Handler) { + self.init(handler: MockRequestHandler(handler: mockHandler)) + } +}