diff --git a/Package.swift b/Package.swift index b5463bd..9e7b805 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,10 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( - name: "Jetworking" + name: "Jetworking", + linkerSettings: [ + .linkedFramework("SystemConfiguration") + ] ), .testTarget( name: "JetworkingTests", diff --git a/Sources/Jetworking/Client/Client.swift b/Sources/Jetworking/Client/Client.swift index 8dbe182..4c6e6b1 100644 --- a/Sources/Jetworking/Client/Client.swift +++ b/Sources/Jetworking/Client/Client.swift @@ -9,11 +9,21 @@ enum APIError: Error { public final class Client { public typealias RequestCompletion = (HTTPURLResponse?, Result) -> Void + + internal enum Task { + case dataTask(request: URLRequest, completion: ((Data?, URLResponse?, Error?) -> Void)) + case downloadTask(request: URLRequest) + case uploadDataTask(request: URLRequest, data: Data) + case uploadFileTask(request: URLRequest, fileURL: URL) + } + // MARK: - Properties private let configuration: Configuration private lazy var session: URLSession = .init(configuration: .default) + private let taskExecutor: ClientTaskExecutor = .default + private lazy var requestExecutor: RequestExecutor = { switch configuration.requestExecutorType { case .sync: @@ -93,7 +103,7 @@ public final class Client { public func get(endpoint: Endpoint, _ completion: @escaping RequestCompletion) -> CancellableRequest? { do { let request: URLRequest = try createRequest(forHttpMethod: .GET, and: endpoint) - return requestExecutor.send(request: request) { [weak self] data, urlResponse, error in + let task: Task = .dataTask(request: request) { [weak self] data, urlResponse, error in self?.handleResponse( data: data, urlResponse: urlResponse, @@ -102,6 +112,7 @@ public final class Client { completion: completion ) } + return try taskExecutor.perform(task, on: requestExecutor) } catch { enqueue(completion(nil, .failure(error))) } @@ -114,7 +125,7 @@ public final class Client { do { let bodyData: Data = try configuration.encoder.encode(body) let request: URLRequest = try createRequest(forHttpMethod: .POST, and: endpoint, and: bodyData) - return requestExecutor.send(request: request) { [weak self] data, urlResponse, error in + let task: Task = .dataTask(request: request) { [weak self] data, urlResponse, error in self?.handleResponse( data: data, urlResponse: urlResponse, @@ -123,6 +134,7 @@ public final class Client { completion: completion ) } + return try taskExecutor.perform(task, on: requestExecutor) } catch { enqueue(completion(nil, .failure(error))) } @@ -135,7 +147,7 @@ public final class Client { do { let bodyData: Data = try configuration.encoder.encode(body) let request: URLRequest = try createRequest(forHttpMethod: .PUT, and: endpoint, and: bodyData) - return requestExecutor.send(request: request) { [weak self] data, urlResponse, error in + let task: Task = .dataTask(request: request) { [weak self] data, urlResponse, error in self?.handleResponse( data: data, urlResponse: urlResponse, @@ -144,6 +156,7 @@ public final class Client { completion: completion ) } + return try taskExecutor.perform(task, on: requestExecutor) } catch { enqueue(completion(nil, .failure(error))) } @@ -160,7 +173,7 @@ public final class Client { do { let bodyData: Data = try configuration.encoder.encode(body) let request: URLRequest = try createRequest(forHttpMethod: .PATCH, and: endpoint, and: bodyData) - return requestExecutor.send(request: request) { [weak self] data, urlResponse, error in + let task: Task = .dataTask(request: request) { [weak self] data, urlResponse, error in self?.handleResponse( data: data, urlResponse: urlResponse, @@ -169,6 +182,7 @@ public final class Client { completion: completion ) } + return try taskExecutor.perform(task, on: requestExecutor) } catch { enqueue(completion(nil, .failure(error))) } @@ -180,7 +194,7 @@ public final class Client { public func delete(endpoint: Endpoint, parameter: [String: Any] = [:], _ completion: @escaping RequestCompletion) -> CancellableRequest? { do { let request: URLRequest = try createRequest(forHttpMethod: .DELETE, and: endpoint) - return requestExecutor.send(request: request) { [weak self] data, urlResponse, error in + let task: Task = .dataTask(request: request) { [weak self] data, urlResponse, error in self?.handleResponse( data: data, urlResponse: urlResponse, @@ -189,6 +203,7 @@ public final class Client { completion: completion ) } + return try taskExecutor.perform(task, on: requestExecutor) } catch { enqueue(completion(nil, .failure(error))) } @@ -206,7 +221,7 @@ public final class Client { guard checkForValidDownloadURL(url) else { return nil } let request: URLRequest = .init(url: url) - let task = downloadExecutor.download(request: request) + let task = try? taskExecutor.perform(.downloadTask(request: request), on: downloadExecutor) task.flatMap { executingDownloads[$0.identifier] = DownloadHandler( progressHandler: progressHandler, @@ -224,7 +239,10 @@ public final class Client { _ completion: @escaping UploadHandler.CompletionHandler ) -> CancellableRequest? { let request: URLRequest = .init(url: url, httpMethod: .POST) - let task = uploadExecutor.upload(request: request, fromFile: fileURL) + let task = try? taskExecutor.perform( + .uploadFileTask(request: request, fileURL: fileURL), + on: uploadExecutor + ) task.flatMap { executingUploads[$0.identifier] = UploadHandler( progressHandler: progressHandler, @@ -259,7 +277,10 @@ public final class Client { // TODO: Extract into constants request.setValue("\(multipartType.rawValue); boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - let task = uploadExecutor.upload(request: request, from: multipartData) + let task = try? taskExecutor.perform( + .uploadDataTask(request: request, data: multipartData), + on: uploadExecutor + ) task.flatMap { executingUploads[$0.identifier] = UploadHandler( progressHandler: progressHandler, diff --git a/Sources/Jetworking/Client/Executor/ClientTaskExecutor.swift b/Sources/Jetworking/Client/Executor/ClientTaskExecutor.swift new file mode 100644 index 0000000..05f8b3c --- /dev/null +++ b/Sources/Jetworking/Client/Executor/ClientTaskExecutor.swift @@ -0,0 +1,51 @@ +import Foundation + +enum ClientTaskError: Error { + case unexpectedTaskExecution + + static let connectionUnavailable = NSError( + domain: URLError.errorDomain, + code: URLError.Code.notConnectedToInternet.rawValue, + userInfo: nil + ) +} + +final class ClientTaskExecutor: NSObject { + internal static let `default` = ClientTaskExecutor() + + private var reachabilityManager: NetworkReachabilityMonitor? + + init(reachabilityMonitor: NetworkReachabilityMonitor? = NetworkReachabilityManager.default) { + self.reachabilityManager = reachabilityMonitor + super.init() + + do { + try self.reachabilityManager?.startListening(on: .main) { _ in } + } catch { + NSLog("[WARNING] Faild to start network monitor. Error: \(error)") + } + } + + func perform(_ task: Client.Task, on executor: T) throws -> CancellableRequest? { + guard reachabilityManager == nil || reachabilityManager?.isReachable == true else { + throw ClientTaskError.connectionUnavailable + } + + switch (executor, task) { + case (is RequestExecutor, let .dataTask(request, completionHandler)) : + return (executor as! RequestExecutor).send(request: request, completionHandler) + + case (is DownloadExecutor, let .downloadTask(request)): + return (executor as! DownloadExecutor).download(request: request) + + case (is UploadExecutor, let .uploadDataTask(request, data)): + return (executor as! UploadExecutor).upload(request: request, from: data) + + case (is UploadExecutor, let .uploadFileTask(request, fileURL)): + return (executor as! UploadExecutor).upload(request: request, fromFile: fileURL) + + default: + throw ClientTaskError.unexpectedTaskExecution + } + } +} diff --git a/Sources/Jetworking/Extensions/SCNetworkReachabilityFlags+Extension.swift b/Sources/Jetworking/Extensions/SCNetworkReachabilityFlags+Extension.swift new file mode 100644 index 0000000..e7bac70 --- /dev/null +++ b/Sources/Jetworking/Extensions/SCNetworkReachabilityFlags+Extension.swift @@ -0,0 +1,25 @@ +import Foundation +import SystemConfiguration + +extension SCNetworkReachabilityFlags { + var isReachableViaCellular: Bool { + #if os(iOS) + return contains(.isWWAN) + #else + return false + #endif + } + + var isReachableViaNetworkInterface: Bool { + contains(.reachable) && + (!contains(.connectionRequired) || canEstablishConnectionAutomatically) + } + + private var canEstablishConnection: Bool { + !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty + } + + private var canEstablishConnectionAutomatically: Bool { + canEstablishConnection && !contains(.interventionRequired) + } +} diff --git a/Sources/Jetworking/Utility/Reachability/NetworkReachability.swift b/Sources/Jetworking/Utility/Reachability/NetworkReachability.swift new file mode 100644 index 0000000..d18e28d --- /dev/null +++ b/Sources/Jetworking/Utility/Reachability/NetworkReachability.swift @@ -0,0 +1,43 @@ +import Foundation +import SystemConfiguration + +/// Typealias for the state of network reachability. +public typealias NetworkReachabilityState = NetworkReachability.State + +/// A container for all enumerations related to network state. +public enum NetworkReachability { + /// Defines the various connection types detected by reachability flags. + public enum ConnectionInterface: Equatable { + /// Wired ethernet or WiFi. + case wiredOrWirelessLAN + + /// Cellular connection. + case cellular + } + + /// Defines the various states of network reachability. + public enum State: Equatable { + /// It could not be determined whether the network is reachable. + case notDetermined + + /// The network is not reachable. + case unreachable + + /// The network is reachable over an interface `ConnectionInterface`. + case reachable(ConnectionInterface) + + init(_ flags: SCNetworkReachabilityFlags) { + guard flags.isReachableViaNetworkInterface else { + self = .unreachable + return + } + + var networkStatus: Self = .reachable(.wiredOrWirelessLAN) + if flags.isReachableViaCellular { + networkStatus = .reachable(.cellular) + } + + self = networkStatus + } + } +} diff --git a/Sources/Jetworking/Utility/Reachability/NetworkReachabilityManager.swift b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityManager.swift new file mode 100644 index 0000000..f7c8d62 --- /dev/null +++ b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityManager.swift @@ -0,0 +1,170 @@ +import Foundation +import SystemConfiguration + +/// A implementation listens for reachability changes of hosts and addresses +/// for available network interfaces. +/// +/// Network reachability cannot tell your application +/// if you can connect to a particular host, +/// only that an interface is available that might allow a connection, +/// and whether that interface is the WWAN. +open class NetworkReachabilityManager: NetworkReachabilityMonitor { + public static let `default`: NetworkReachabilityManager? = try? NetworkReachabilityManager() + + // MARK: - Properties + /// Determines whether the network is currently reachable. + open var isReachable: Bool { + state == .reachable(.cellular) || state == .reachable(.wiredOrWirelessLAN) + } + + /// Returns the current network reachability state. + open var state: NetworkReachabilityState { + flags.map(NetworkReachabilityState.init) ?? .notDetermined + } + + /// `DispatchQueue` on which reachability will update. + public let reachabilityQueue = DispatchQueue(label: "com.jamitlabs.jetworking.network-reachability") + + /// Flags of the current reachability type, if any. + private var flags: SCNetworkReachabilityFlags? { + var flags = SCNetworkReachabilityFlags() + return (SCNetworkReachabilityGetFlags(reachability, &flags)) ? flags : nil + } + + /// `SCNetworkReachability` instance providing notifications. + private let reachability: SCNetworkReachability + + /// A runtime instance for state listener + private var stateListener: NetworkReachabilityStateListener = .init() + + // MARK: - Initialization + /// Creates an instance with the specified host. + /// + /// - Note: The `host` value must *not* contain a scheme (`http`, etc.), just the hostname. + /// + /// - Parameters: + /// - host: Host used to evaluate network reachability. + public convenience init(host: String) throws { + guard let reachability = SCNetworkReachabilityCreateWithName(nil, host) else { + throw NSError( + domain: kCFErrorDomainSystemConfiguration as String, + code: Int(SCError()), + userInfo: [NSLocalizedDescriptionKey: SCErrorString(SCError())] + ) + } + + self.init(reachability: reachability) + } + + /// Creates an instance that monitors the zero address (0.0.0.0). + /// + /// The reachability treats the address as a special token that causes it + /// to actually monitor the general routing status of the device, + /// both IPv4 and IPv6. + public convenience init() throws { + var zeroAddress = sockaddr() + zeroAddress.sa_len = UInt8(MemoryLayout.size) + zeroAddress.sa_family = sa_family_t(AF_INET) + + guard let reachability = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { + throw NSError( + domain: kCFErrorDomainSystemConfiguration as String, + code: Int(SCError()), + userInfo: [NSLocalizedDescriptionKey: SCErrorString(SCError())] + ) + } + + self.init(reachability: reachability) + } + + init(reachability: SCNetworkReachability) { + self.reachability = reachability + } + + deinit { + stopListening() + } + + // MARK: - Listening + /// Starts listening for changes in network reachability state. + /// + /// - Note: Stops and removes any existing listener. + /// + /// - Parameters: + /// - queue: A queue on which to call the callback closure. Use the main queue by default. + /// - callback: A closure called when reachability changes. + open func startListening( + on queue: DispatchQueue = .main, + withCallbackOnStateChange callback: @escaping NetworkReachabilityStateCallback + ) throws { + stopListening() + + stateListener.update { state in + state.callbackQueue = queue + state.callback = callback + } + + var context = SCNetworkReachabilityContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + let callback: SCNetworkReachabilityCallBack = { _, flags, info in + guard let info = info else { return } + + let instance = Unmanaged.fromOpaque(info).takeUnretainedValue() + instance.notifyListener(flags) + } + + if !SCNetworkReachabilitySetDispatchQueue(reachability, reachabilityQueue) { + stopListening() + throw NSError( + domain: kCFErrorDomainSystemConfiguration as String, + code: Int(SCError()), + userInfo: [NSLocalizedDescriptionKey: SCErrorString(SCError())] + ) + } + + if !SCNetworkReachabilitySetCallback(reachability, callback, &context) { + stopListening() + throw NSError( + domain: kCFErrorDomainSystemConfiguration as String, + code: Int(SCError()), + userInfo: [NSLocalizedDescriptionKey: SCErrorString(SCError())] + ) + } + + // Initial state check + flags.flatMap { currentFlags in + reachabilityQueue.async { self.notifyListener(currentFlags) } + } + } + + /// Stops listening for changes in network reachability state. + open func stopListening() { + SCNetworkReachabilitySetCallback(reachability, nil, nil) + SCNetworkReachabilitySetDispatchQueue(reachability, nil) + stateListener.reset() + } + + // MARK: - Internal - Listener Notification + /// Calls the callback closure in the callback queue if the computed state has been changed. + /// + /// - Note: Should only be called from the `reachabilityQueue`. + /// + /// - Parameter flags: `SCNetworkReachabilityFlags` to use to calculate the state. + func notifyListener(_ flags: SCNetworkReachabilityFlags) { + let newStatus = NetworkReachabilityState(flags) + + stateListener.update { listener in + guard listener.previousStatus != newStatus else { return } + + listener.previousStatus = newStatus + + let callback = listener.callback + listener.callbackQueue?.async { callback?(newStatus) } + } + } +} diff --git a/Sources/Jetworking/Utility/Reachability/NetworkReachabilityMonitor.swift b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityMonitor.swift new file mode 100644 index 0000000..91bcc41 --- /dev/null +++ b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityMonitor.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A closure executed when the network reachability status changes. The closure takes a single argument: the +/// network reachability status. +public typealias NetworkReachabilityStateCallback = (NetworkReachabilityState) -> Void + +public protocol NetworkReachabilityMonitor { + /// Determines whether the network is currently reachable. + var isReachable: Bool { get } + + /// Returns the current network reachability state. + var state: NetworkReachabilityState { get } + + /// Starts listening for changes in network reachability state. + /// + /// - Note: Stops and removes any existing listener. + /// + /// - Parameters: + /// - queue: A queue on which to call the callback closure. Use the main queue by default. + /// - callback: A closure called when reachability changes. + func startListening( + on queue: DispatchQueue, + withCallbackOnStateChange callback: @escaping NetworkReachabilityStateCallback + ) throws + + /// Stops listening for changes in network reachability state. + func stopListening() +} diff --git a/Sources/Jetworking/Utility/Reachability/NetworkReachabilityStateListener.swift b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityStateListener.swift new file mode 100644 index 0000000..927ac81 --- /dev/null +++ b/Sources/Jetworking/Utility/Reachability/NetworkReachabilityStateListener.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Mutable storage for network status and callback. +struct NetworkReachabilityStateListener { + /// A closure executed when the network reachability state changes. + var callback: NetworkReachabilityStateCallback? + + /// A working queue on which listeners will be called. + var callbackQueue: DispatchQueue? + + /// Network status in previous time. + var previousStatus: NetworkReachabilityState? + + private let lock: UnfairLock = .init() + + init( + callback: NetworkReachabilityStateCallback? = nil, + callbackQueue: DispatchQueue? = nil, + previousStatus: NetworkReachabilityState? = nil + ) { + self.callback = callback + self.callbackQueue = callbackQueue + self.previousStatus = previousStatus + } + + mutating func update(callback: @escaping (inout Self) -> Void) { + lock.lock() + defer { lock.unlock() } + + callback(&self) + } + + mutating func reset() { + lock.lock() + defer { lock.unlock() } + + callback = nil + callbackQueue = nil + previousStatus = nil + } +} + +/// An implementation of unfair lock in Swift +fileprivate final class UnfairLock { + private let unfairLock: os_unfair_lock_t + + init() { + unfairLock = .allocate(capacity: 1) + unfairLock.initialize(to: os_unfair_lock()) + } + + deinit { + unfairLock.deinitialize(count: 1) + unfairLock.deallocate() + } + + func lock() { + os_unfair_lock_lock(unfairLock) + } + + func unlock() { + os_unfair_lock_unlock(unfairLock) + } +} diff --git a/Tests/JetworkingTests/ExecutorTests/ClientTaskExecutorTests.swift b/Tests/JetworkingTests/ExecutorTests/ClientTaskExecutorTests.swift new file mode 100644 index 0000000..d3ee4c1 --- /dev/null +++ b/Tests/JetworkingTests/ExecutorTests/ClientTaskExecutorTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import Jetworking + +final class ClientTaskExecutorTests: XCTestCase { + // MARK: - Network state tests + func testClientTaskExecutorWithoutNetworkReachabilityCheck() { + let taskExecutor = ClientTaskExecutor(reachabilityMonitor: nil) + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertNoThrow(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + func testClientTaskExecutorInUnreachableNetwork() { + let taskExecutor = makeSampleClientTaskExecutor(state: .unreachable) + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + func testClientTaskExecutorInNotDeterminedNetwork() { + let taskExecutor = makeSampleClientTaskExecutor(state: .notDetermined) + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + func testClientTaskExecutorInReachableNetwork() { + let taskExecutor = makeSampleClientTaskExecutor(state: .reachable(.wiredOrWirelessLAN)) + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertNoThrow(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + func testClientTaskExecutorInUnreachableNetworkThenReachableNetwork() { + let reachabilityMonitor = makeSampleReachabilityMonitor(state: .unreachable) + let taskExecutor = ClientTaskExecutor(reachabilityMonitor: reachabilityMonitor) + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + + (reachabilityMonitor as? MockNetworkReachabilityMonitor)?.reachabilityState = .reachable(.wiredOrWirelessLAN) + + XCTAssertNoThrow(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + func testClientTaskExecutorInReachableNetworkThenUnreachableNetwork() { + let reachabilityMonitor = makeSampleReachabilityMonitor(state: .reachable(.wiredOrWirelessLAN)) + let taskExecutor = ClientTaskExecutor(reachabilityMonitor: reachabilityMonitor) + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertNoThrow(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + + (reachabilityMonitor as? MockNetworkReachabilityMonitor)?.reachabilityState = .unreachable + + XCTAssertThrowsError(try taskExecutor.perform(makeSampleClientDataTask(), on: requestExecutor)) + } + + // MARK: - Task operation tests + func testClientTaskExecutorWithDataTask() { + let taskExecutor: ClientTaskExecutor = makeSampleClientTaskExecutor() + let task: Client.Task = makeSampleClientDataTask() + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertNoThrow(try taskExecutor.perform(task, on: requestExecutor)) + + let downloadExecutor = DefaultDownloadExecutor( + sessionConfiguration: URLSession.shared.configuration, + downloadExecutorDelegate: MockDownloadExecutorDelegation() + ) + XCTAssertThrowsError(try taskExecutor.perform(task, on: downloadExecutor)) + + let uploadExecutor = DefaultUploadExecutor.init( + sessionConfiguration: URLSession.shared.configuration, + uploadExecutorDelegate: MockUploadExecutorDelegation() + ) + XCTAssertThrowsError(try taskExecutor.perform(task, on: uploadExecutor)) + } + + func testClientTaskExecutorWithDownloadTask() { + let taskExecutor: ClientTaskExecutor = makeSampleClientTaskExecutor() + let task: Client.Task = .downloadTask(request: makeSampleRequest()) + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(task, on: requestExecutor)) + + let downloadExecutor = DefaultDownloadExecutor( + sessionConfiguration: URLSession.shared.configuration, + downloadExecutorDelegate: MockDownloadExecutorDelegation() + ) + XCTAssertNoThrow(try taskExecutor.perform(task, on: downloadExecutor)) + + let uploadExecutor = DefaultUploadExecutor.init( + sessionConfiguration: URLSession.shared.configuration, + uploadExecutorDelegate: MockUploadExecutorDelegation() + ) + XCTAssertThrowsError(try taskExecutor.perform(task, on: uploadExecutor)) + } + + func testClientTaskExecutorWithUploadDataTask() { + let task: Client.Task = .uploadDataTask(request: makeSampleRequest(), data: Data()) + let taskExecutor: ClientTaskExecutor = makeSampleClientTaskExecutor() + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(task, on: requestExecutor)) + + let downloadExecutor = DefaultDownloadExecutor( + sessionConfiguration: URLSession.shared.configuration, + downloadExecutorDelegate: MockDownloadExecutorDelegation() + ) + XCTAssertThrowsError(try taskExecutor.perform(task, on: downloadExecutor)) + + let uploadExecutor = DefaultUploadExecutor.init( + sessionConfiguration: URLSession.shared.configuration, + uploadExecutorDelegate: MockUploadExecutorDelegation() + ) + XCTAssertNoThrow(try taskExecutor.perform(task, on: uploadExecutor)) + } + + func testClientTaskExecutorWithUploadFileTask() { + let filePath = Bundle.module.path(forResource: "avatar", ofType: ".png")! + var components: URLComponents = .init() + components.scheme = "file" + components.path = filePath + + let task: Client.Task = .uploadFileTask(request: makeSampleRequest(), fileURL: components.url!) + let taskExecutor: ClientTaskExecutor = makeSampleClientTaskExecutor() + + let requestExecutor = AsyncRequestExecutor(session: URLSession(configuration: .default)) + XCTAssertThrowsError(try taskExecutor.perform(task, on: requestExecutor)) + + let downloadExecutor = DefaultDownloadExecutor( + sessionConfiguration: URLSession.shared.configuration, + downloadExecutorDelegate: MockDownloadExecutorDelegation() + ) + XCTAssertThrowsError(try taskExecutor.perform(task, on: downloadExecutor)) + + let uploadExecutor = DefaultUploadExecutor.init( + sessionConfiguration: URLSession.shared.configuration, + uploadExecutorDelegate: MockUploadExecutorDelegation() + ) + XCTAssertNoThrow(try taskExecutor.perform(task, on: uploadExecutor)) + } +} + +extension ClientTaskExecutorTests { + func makeSampleRequest() -> URLRequest { + .init(url: URL(string: "http://localhost")!) + } + + func makeSampleReachabilityMonitor(state: NetworkReachabilityState) -> NetworkReachabilityMonitor { + MockNetworkReachabilityMonitor(state: state) + } + + func makeSampleClientTaskExecutor(state: NetworkReachabilityState = .reachable(.wiredOrWirelessLAN)) -> ClientTaskExecutor { + let reachabilityMonitor = makeSampleReachabilityMonitor(state: state) + return ClientTaskExecutor(reachabilityMonitor: reachabilityMonitor) + } + + func makeSampleClientDataTask() -> Client.Task { + .dataTask(request: makeSampleRequest(), completion: { _,_,_ in }) + } +} diff --git a/Tests/JetworkingTests/Mocks/Executors/MockAsyncDelegation.swift b/Tests/JetworkingTests/Mocks/Executors/MockAsyncDelegation.swift new file mode 100644 index 0000000..906a829 --- /dev/null +++ b/Tests/JetworkingTests/Mocks/Executors/MockAsyncDelegation.swift @@ -0,0 +1,5 @@ +import Foundation + +class MockAsyncDelegation { + var callback: (Bool) -> Void = { _ in } +} diff --git a/Tests/JetworkingTests/Mocks/Executors/MockDownloadExecutorDelegation.swift b/Tests/JetworkingTests/Mocks/Executors/MockDownloadExecutorDelegation.swift new file mode 100644 index 0000000..ec9b52e --- /dev/null +++ b/Tests/JetworkingTests/Mocks/Executors/MockDownloadExecutorDelegation.swift @@ -0,0 +1,16 @@ +import Foundation +import Jetworking + +final class MockDownloadExecutorDelegation: MockAsyncDelegation, DownloadExecutorDelegate { + func downloadExecutor(_ downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + // Implement this if needed + } + + func downloadExecutor(_ downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + callback(true) + } + + func downloadExecutor(_ downloadTask: URLSessionDownloadTask, didCompleteWithError error: Error?) { + callback(error == nil) + } +} diff --git a/Tests/JetworkingTests/Mocks/Executors/MockUploadExecutorDelegation.swift b/Tests/JetworkingTests/Mocks/Executors/MockUploadExecutorDelegation.swift new file mode 100644 index 0000000..f5c0bad --- /dev/null +++ b/Tests/JetworkingTests/Mocks/Executors/MockUploadExecutorDelegation.swift @@ -0,0 +1,16 @@ +import Foundation +import Jetworking + +final class MockUploadExecutorDelegation: MockAsyncDelegation, UploadExecutorDelegate { + func uploadExecutor(_ uploadTask: URLSessionUploadTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + // Implement this if needed + } + + func uploadExecutor(didFinishWith uploadTask: URLSessionUploadTask) { + callback(true) + } + + func uploadExecutor(_ uploadTask: URLSessionUploadTask, didCompleteWithError error: Error?) { + callback(error == nil) + } +} diff --git a/Tests/JetworkingTests/Mocks/NetworkReachability/MockNetworkReachability.swift b/Tests/JetworkingTests/Mocks/NetworkReachability/MockNetworkReachability.swift new file mode 100644 index 0000000..c87a1da --- /dev/null +++ b/Tests/JetworkingTests/Mocks/NetworkReachability/MockNetworkReachability.swift @@ -0,0 +1,25 @@ +import Foundation +import Jetworking + +final class MockNetworkReachabilityMonitor: NetworkReachabilityMonitor { + var isReachable: Bool { if case .reachable = state { return true } else { return false } } + + var state: NetworkReachabilityState { reachabilityState } + + var reachabilityState: NetworkReachabilityState + + init(state: NetworkReachabilityState = .notDetermined) { + reachabilityState = state + } + + func startListening( + on queue: DispatchQueue, + withCallbackOnStateChange callback: @escaping NetworkReachabilityStateCallback + ) throws { + // Implement this if needed + } + + func stopListening() { + // Implement this if needed + } +} diff --git a/Tests/JetworkingTests/UtilityTests/NetworkReachabilityManagerTests.swift b/Tests/JetworkingTests/UtilityTests/NetworkReachabilityManagerTests.swift new file mode 100644 index 0000000..8649144 --- /dev/null +++ b/Tests/JetworkingTests/UtilityTests/NetworkReachabilityManagerTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import Jetworking + +final class NetworkReachabilityManagerTests: XCTestCase { + var timeout: TimeInterval = 10 + + func testManagerInitializedWithHost() { + let manager: NetworkReachabilityManager? = try? .init(host: "localhost") + + XCTAssertNotNil(manager) + } + + func testManagerInitializedWithAddress() { + let manager: NetworkReachabilityManager? = try? .init() + + XCTAssertNotNil(manager) + } + + func testHostManagerStartWithReachableStatus() { + let manager: NetworkReachabilityManager? = try? .init(host: "localhost") + + XCTAssertEqual(manager?.isReachable, true) + XCTAssertEqual(manager?.state, .reachable(.wiredOrWirelessLAN)) + } + + func testAddressManagerStartWithReachableStatus() { + let manager: NetworkReachabilityManager? = try? .init() + + XCTAssertEqual(manager?.isReachable, true) + XCTAssertEqual(manager?.state, .reachable(.wiredOrWirelessLAN)) + } + + func testHostManagerRestart() { + let manager: NetworkReachabilityManager? = try? .init(host: "localhost") + let firstCallbackExpectation = expectation(description: "First callback should be called") + let secondCallbackExpectation = expectation(description: "Second callback should be called") + + try? manager?.startListening { _ in + firstCallbackExpectation.fulfill() + } + wait(for: [firstCallbackExpectation], timeout: timeout) + + manager?.stopListening() + + try? manager?.startListening { _ in + secondCallbackExpectation.fulfill() + } + wait(for: [secondCallbackExpectation], timeout: timeout) + + XCTAssertEqual(manager?.state, .reachable(.wiredOrWirelessLAN)) + } + + func testAddressManagerRestart() { + let manager: NetworkReachabilityManager? = try? .init() + let firstCallbackExpectation = expectation(description: "First callback should be called") + let secondCallbackExpectation = expectation(description: "Second callback should be called") + + try? manager?.startListening { _ in + firstCallbackExpectation.fulfill() + } + wait(for: [firstCallbackExpectation], timeout: timeout) + + manager?.stopListening() + + try? manager?.startListening { _ in + secondCallbackExpectation.fulfill() + } + wait(for: [secondCallbackExpectation], timeout: timeout) + + XCTAssertEqual(manager?.state, .reachable(.wiredOrWirelessLAN)) + } + + func testHostManagerDeinitialized() { + let expect = expectation(description: "Reachability queue should get cleared") + var manager: NetworkReachabilityManager? = try? .init(host: "localhost") + weak var weakManager = manager + + try? manager?.startListening(withCallbackOnStateChange: { _ in }) + manager?.stopListening() + manager?.reachabilityQueue.async { expect.fulfill() } + manager = nil + + waitForExpectations(timeout: timeout) + + XCTAssertNil(manager) + XCTAssertNil(weakManager) + } + + func testAddressManagerDeinitialized() { + let expect = expectation(description: "Reachability queue should get clear") + var manager: NetworkReachabilityManager? = try? .init() + weak var weakManager = manager + + try? manager?.startListening(withCallbackOnStateChange: { _ in }) + manager?.stopListening() + manager?.reachabilityQueue.async { expect.fulfill() } + manager = nil + + waitForExpectations(timeout: timeout) + + XCTAssertNil(manager) + XCTAssertNil(weakManager) + } +} diff --git a/Tests/JetworkingTests/UtilityTests/NetworkReachabilityStatusTests.swift b/Tests/JetworkingTests/UtilityTests/NetworkReachabilityStatusTests.swift new file mode 100644 index 0000000..a5c4f0c --- /dev/null +++ b/Tests/JetworkingTests/UtilityTests/NetworkReachabilityStatusTests.swift @@ -0,0 +1,47 @@ +import SystemConfiguration +import XCTest +@testable import Jetworking + +final class NetworkReachabilityStateTests: XCTestCase { + func testNetworkStatusForNonReachableConnectionThatMustBeEstablishOnDemand() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.connectionOnDemand] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .unreachable) + } + + func testNetworkStatusForReachableConnectionThatRequiresToBeEstablish() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .connectionRequired] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .unreachable) + } + + func testNetworkStatusForReachableConnectionThatRequiresToBeEstablishedWithUserIntervention() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .connectionRequired, .interventionRequired] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .unreachable) + } + + func testNetworkStatusForReachableConnection() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .reachable(.wiredOrWirelessLAN)) + } + + func testNetworkStatusForReachableConnectionThatRequiresToBeEstablishedOnDemand() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .connectionRequired, .connectionOnDemand] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .reachable(.wiredOrWirelessLAN)) + } + + func testNetworkStatusForReachableConnectionThatRequiresToBeEstablishedOnTraffic() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .connectionRequired, .connectionOnTraffic] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .reachable(.wiredOrWirelessLAN)) + } + + #if os(iOS) + func testNetworkStatusForReachableConnectionViaCellular() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .isWWAN] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .reachable(.cellular)) + } + + func testNetworkStatusForReachableConnectionViaCellularThatRequiresToBeEstablished() { + let reachabilityFlags: SCNetworkReachabilityFlags = [.reachable, .isWWAN, .connectionRequired] + XCTAssertEqual(NetworkReachabilityState(reachabilityFlags), .unreachable) + } + #endif +}