From 5be9ce79d4d3bab2aee422ae1ea685d5043dc920 Mon Sep 17 00:00:00 2001 From: Daniel Koster Date: Wed, 17 Dec 2025 14:06:58 -0300 Subject: [PATCH] Done: Implemented QHHTTPRequest --- Sources/Request/HTTPRequest.swift | 42 ++++- Tests/Mocks/CertificatePinning/Mocks.swift | 58 ++++++- .../URLSessionProtocol/ResponsesStubs.swift | 6 +- .../URLSessionDataTaskMock.swift | 6 +- .../URLSessionDownloadMock.swift | 2 +- .../URLSessionDownloadTaskMock.swift | 2 +- .../URLSessionMockWithDelay.swift | 2 +- .../TestCases/Request/HTTPRequestTests.swift | 151 +++++++++++++++++- 8 files changed, 251 insertions(+), 18 deletions(-) diff --git a/Sources/Request/HTTPRequest.swift b/Sources/Request/HTTPRequest.swift index 564a862..dbd90f5 100644 --- a/Sources/Request/HTTPRequest.swift +++ b/Sources/Request/HTTPRequest.swift @@ -21,25 +21,59 @@ public protocol HTTPRequest { } public protocol HTTPRequestActionable { + func response() async throws -> HTTPResponse + var responsePublisher: any Publisher { get } +} + +public protocol HTTPRequestDecodedActionable { associatedtype ResponseType: Codable - func response(queue: DispatchQueue) async -> Result - var responsePublisher: any Publisher, Never> { get } + func responseDecoded() async throws -> Response + var responseDecodedPublisher: any Publisher { get } } -public struct QHHTTPRequest: HTTPRequest, URLRequestProtocol { +public struct QHHTTPRequest: HTTPRequest, URLRequestProtocol, HTTPRequestActionable, HTTPRequestDecodedActionable { + public typealias ResponseType = T + public let headers: [String : String] public let body: Data? public let url: String public let method: HTTPMethod + private let requestFactory: NetworkRequestFactory + private let jsonDecoder: JSONDecoder public init(headers: [String : String] = [:], body: Data? = nil, url: String, - method: HTTPMethod) { + method: HTTPMethod, + requestFactory: NetworkRequestFactory = URLSessionRequestFactory(urlSession: URLSession.shared), + jsonDecoder: JSONDecoder = JSONDecoder()) { self.headers = headers self.body = body self.url = url self.method = method + self.requestFactory = requestFactory + self.jsonDecoder = jsonDecoder + } + + public func response() async throws -> any HTTPResponse { + let urlRequest = try asURLRequest() + return try await requestFactory.data(request: urlRequest) + } + + public func responseDecoded() async throws -> Response { + let urlRequest = try asURLRequest() + return try await requestFactory.response(request: urlRequest, jsonDecoder: jsonDecoder) + } + + public var responseDecodedPublisher: any Publisher { + guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) } + return requestFactory.response(urlRequest: urlRequest, + jsonDecoder: jsonDecoder) + .map { $0.data } + } + public var responsePublisher: any Publisher { + guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) } + return requestFactory.dataPublisher(request: urlRequest) } public func asURLRequest() throws -> URLRequest { diff --git a/Tests/Mocks/CertificatePinning/Mocks.swift b/Tests/Mocks/CertificatePinning/Mocks.swift index 48fa75e..8654438 100644 --- a/Tests/Mocks/CertificatePinning/Mocks.swift +++ b/Tests/Mocks/CertificatePinning/Mocks.swift @@ -6,6 +6,7 @@ // import QuickHatchHTTP import Foundation +import Combine public class PinningStrategyMock: PinningStrategy { @@ -55,7 +56,7 @@ public final class MockAuthenticationChallengeSender: NSObject, URLAuthenticatio } -public final class MockURLProtectionSpace: URLProtectionSpace { +public final class MockURLProtectionSpace: URLProtectionSpace, @unchecked Sendable { private let trust: SecTrust? public init(serverTrust: SecTrust?,host: String, port: Int, authenticationMethod: String? = nil) { self.trust = serverTrust @@ -70,3 +71,58 @@ public final class MockURLProtectionSpace: URLProtectionSpace { return trust } } + +public final class NetworkRequestFactoryMock: NetworkRequestFactory { + + public var invokedDataRequest = false + public var invokedDataRequestCount = 0 + public var invokedDataRequestParameters: (request: URLRequest, dispatchQueue: DispatchQueue)? + public var invokedDataRequestParametersList = [(request: URLRequest, dispatchQueue: DispatchQueue)]() + public var stubbedDataRequestCompletionResult: (Result, Void)? + public var stubbedDataRequestResult: DataTask! + + public func data(request: URLRequest, + dispatchQueue: DispatchQueue, + completionHandler completion: @Sendable @escaping (Result) -> Void) -> DataTask { + invokedDataRequest = true + invokedDataRequestCount += 1 + invokedDataRequestParameters = (request, dispatchQueue) + invokedDataRequestParametersList.append((request, dispatchQueue)) + if let result = stubbedDataRequestCompletionResult { + completion(result.0) + } + return stubbedDataRequestResult + } + + public var invokedDataPublisher = false + public var invokedDataPublisherCount = 0 + public var invokedDataPublisherParameters: (request: URLRequest, Void)? + public var invokedDataPublisherParametersList = [(request: URLRequest, Void)]() + public var subject = PassthroughSubject() + + public func dataPublisher(request: URLRequest) -> AnyPublisher { + invokedDataPublisher = true + invokedDataPublisherCount += 1 + invokedDataPublisherParameters = (request, ()) + invokedDataPublisherParametersList.append((request, ())) + return subject.eraseToAnyPublisher() + } + + public var invokedAsyncData = false + public var invokedAsyncDataCount = 0 + public var invokedAsyncDataParameters: (request: URLRequest, Void)? + public var invokedAsyncDataParametersList = [(request: URLRequest, Void)]() + public var asyncDataResponseResult: HTTPResponse! + public var asyncDataErrorThrown: Error? + + public func data(request: URLRequest) async throws -> HTTPResponse { + invokedAsyncData = true + invokedAsyncDataCount += 1 + invokedAsyncDataParameters = (request, ()) + invokedAsyncDataParametersList.append((request, ())) + if let asyncDataErrorThrown = asyncDataErrorThrown { + throw asyncDataErrorThrown + } + return asyncDataResponseResult + } +} diff --git a/Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift b/Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift index 49e6235..f3fb030 100644 --- a/Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift +++ b/Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift @@ -24,8 +24,8 @@ public struct URLSessionMocks { return Data() } - public static func anyURLSessionLayer(urlSession: URLSessionProtocol) -> URLSessionRequestFactory { - let urlSessionLayer = URLSessionRequestFactory(urlSession: urlSession) - return urlSessionLayer + public static func anyResponse(withData: Data? = nil, withStatusCode: Int = 200) -> HTTPResponse { + return QHHTTPResponse(body: withData, urlResponse: anyResponse(statusCode: withStatusCode)) } + } diff --git a/Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift b/Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift index 03459d1..03a9207 100644 --- a/Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift +++ b/Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift @@ -8,7 +8,7 @@ import Foundation -class URLSessionDataTaskMock: URLSessionDataTask { +class URLSessionDataTaskMock: URLSessionDataTask, @unchecked Sendable { private let closure: () -> Void @@ -21,7 +21,7 @@ class URLSessionDataTaskMock: URLSessionDataTask { } } -class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock { +class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock, @unchecked Sendable { private let delay: Double init(delay: Double, closure: @escaping () -> Void) { @@ -40,7 +40,7 @@ class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock { } } -class FakeURLSessionDataTask: URLSessionDataTask { +class FakeURLSessionDataTask: URLSessionDataTask, @unchecked Sendable { public var resumed = false override func resume() { resumed = true diff --git a/Tests/Mocks/URLSessionProtocol/URLSessionDownloadMock.swift b/Tests/Mocks/URLSessionProtocol/URLSessionDownloadMock.swift index ea63f90..928cd6c 100644 --- a/Tests/Mocks/URLSessionProtocol/URLSessionDownloadMock.swift +++ b/Tests/Mocks/URLSessionProtocol/URLSessionDownloadMock.swift @@ -8,7 +8,7 @@ import Foundation -class URLSessionDownloadMock: URLSession { +class URLSessionDownloadMock: URLSession, @unchecked Sendable { private let downloadingURL: URL? private let error: Error? private let urlResponse: URLResponse? diff --git a/Tests/Mocks/URLSessionProtocol/URLSessionDownloadTaskMock.swift b/Tests/Mocks/URLSessionProtocol/URLSessionDownloadTaskMock.swift index be1f76b..4e77ddb 100644 --- a/Tests/Mocks/URLSessionProtocol/URLSessionDownloadTaskMock.swift +++ b/Tests/Mocks/URLSessionProtocol/URLSessionDownloadTaskMock.swift @@ -8,7 +8,7 @@ import Foundation -class URLSessionDownloadTaskMock: URLSessionDownloadTask { +class URLSessionDownloadTaskMock: URLSessionDownloadTask, @unchecked Sendable { private let closure: () -> Void init(closure: @escaping () -> Void) { diff --git a/Tests/Mocks/URLSessionProtocol/URLSessionMockWithDelay.swift b/Tests/Mocks/URLSessionProtocol/URLSessionMockWithDelay.swift index dd2761f..ba20024 100644 --- a/Tests/Mocks/URLSessionProtocol/URLSessionMockWithDelay.swift +++ b/Tests/Mocks/URLSessionProtocol/URLSessionMockWithDelay.swift @@ -9,7 +9,7 @@ import Foundation import QuickHatchHTTP -class URLSessionMockWithDelay: URLSessionProtocolMock { +class URLSessionMockWithDelay: URLSessionProtocolMock, @unchecked Sendable { private var delay: Double init(data: Data? = nil, error: Error? = nil, urlResponse: URLResponse? = nil, delay: Double) { diff --git a/Tests/TestCases/Request/HTTPRequestTests.swift b/Tests/TestCases/Request/HTTPRequestTests.swift index b70c61f..2cc2d0a 100644 --- a/Tests/TestCases/Request/HTTPRequestTests.swift +++ b/Tests/TestCases/Request/HTTPRequestTests.swift @@ -5,16 +5,19 @@ // Created by Daniel Koster on 10/13/25. // import QuickHatchHTTP +@testable import QuickHatchHTTPMocks import Foundation import Testing +import Combine -struct HTTPRequestTests { +final class HTTPRequestTests { + private var cancellables = Set() @Test func asURLRequest_expectHeadersCorrectlyParsed() throws { - let sut = QHHTTPRequest(headers: ["Content-Type": "json"], - url: "quickhatch.com", - method: .get) + let sut = QHHTTPRequest(headers: ["Content-Type": "json"], + url: "quickhatch.com", + method: .get) let result = try sut.asURLRequest() @@ -26,4 +29,144 @@ struct HTTPRequestTests { #expect(headers == ["Content-Type": "json"]) #expect(method == "GET") } + + @Test + func response_whenNoErrorSpecified_expectCorrectResponse() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse() + + do { + _ = try await sut.response() + #expect(requestFactoryMock.invokedAsyncDataCount == 1) + } + catch _ { + #expect(Bool(false)) + } + } + + @Test func response_whenErrorThrown_expectCatchToWork() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse() + requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest) + + do { + _ = try await sut.response() + } + catch let error as RequestError { + #expect(error == .requestWithError(statusCode: .badRequest)) + #expect(requestFactoryMock.invokedAsyncDataCount == 1) + } + } + + @Test + func responseDecoded_whenNoErrorSpecified_expectCorrectResponse() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample) + + do { + let response = try await sut.responseDecoded() + let dataExpected = DataModel(name: "dan", nick: "sp", age: 12) + #expect(requestFactoryMock.invokedAsyncDataCount == 1) + #expect(dataExpected.name == response.data.name) + } + catch _ { + #expect(Bool(false)) + } + } + + @Test func responseDecoded_whenErrorThrown_expectCatchToWork() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse() + requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest) + + do { + _ = try await sut.responseDecoded() + } + catch let error as RequestError { + #expect(error == .requestWithError(statusCode: .badRequest)) + #expect(requestFactoryMock.invokedAsyncDataCount == 1) + } + } + + @Test func responseDecodedPublisher_whenErrorThrown_expectCatchToWork() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest))) + + await confirmation("") { confirmation in + sut.responseDecodedPublisher.sink(receiveCompletion: { completion in + switch completion { + case .failure(let error as RequestError): + #expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest)) + confirmation.confirm() + case .failure(_): break + case .finished: break + } + }, + receiveValue: { dataModel in }).store(in: &cancellables) + } + } + + @Test func responseDecodedPublisher_whenNoErrorThrown_expectCorrectResponse() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + let response = DataModel(name: "dan", nick: "sp", age: 12) + + await confirmation("") { confirmation in + sut.responseDecodedPublisher.sink(receiveCompletion: { completion in + switch completion { + case .failure(_): break + case .finished: break + } + }, + receiveValue: { dataModel in + #expect(dataModel.name == response.name) + confirmation.confirm() + }).store(in: &cancellables) + requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample)) + } + } + + @Test func responsePublisher_whenErrorThrown_expectCatchToWork() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest))) + + await confirmation("") { confirmation in + sut.responsePublisher.sink(receiveCompletion: { completion in + switch completion { + case .failure(let error as RequestError): + #expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest)) + confirmation.confirm() + case .failure(_): break + case .finished: break + } + }, + receiveValue: { _ in }).store(in: &cancellables) + } + } + + @Test func responsePublisher_whenNoErrorThrown_expectCorrectResponse() async throws { + let requestFactoryMock = NetworkRequestFactoryMock() + let sut = QHHTTPRequest(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock) + + await confirmation("") { confirmation in + sut.responsePublisher.sink(receiveCompletion: { completion in + switch completion { + case .failure(_): break + case .finished: break + } + }, + receiveValue: { response in + #expect(response.body != nil) + confirmation.confirm() + }).store(in: &cancellables) + requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample)) + } + } + }