From 51927b17ca8b96a0034640520d685aa04a1fe383 Mon Sep 17 00:00:00 2001 From: Ben Chatelain Date: Sun, 6 Jan 2019 12:26:08 -0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20NetworkSession=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MasKit/Commands/Search.swift | 8 +- MasKit/MasStoreSearch.swift | 25 ++-- MasKit/Network/NetworkManager.swift | 11 +- MasKit/Network/NetworkSession.swift | 50 ++++++- ...swift => NetworkSessionMockFromFile.swift} | 36 +++--- .../Network/URLSession+NetworkSession.swift | 2 +- MasKit/StoreSearch.swift | 15 ++- MasKit/String+PercentEncoding.swift | 16 +++ MasKitTests/Bundle+JSON.swift | 30 +++++ MasKitTests/Commands/SearchSpec.swift | 4 +- MasKitTests/MasStoreSearchSpec.swift | 5 +- MasKitTests/Mocks/MockURLSession.swift | 122 ------------------ MasKitTests/Network/MockNetworkSession.swift | 44 +++++++ .../Network/MockNetworkSessionFromFile.swift | 77 +++++++++++ .../MockURLSessionDataTask.swift | 2 +- MasKitTests/Network/NetworkManagerTests.swift | 8 +- MasKitTests/String+FileExtension.swift | 21 +++ mas-cli.xcodeproj/project.pbxproj | 30 +++-- 18 files changed, 321 insertions(+), 185 deletions(-) rename MasKit/Network/{URLSession+Synchronous.swift => NetworkSessionMockFromFile.swift} (62%) create mode 100644 MasKit/String+PercentEncoding.swift create mode 100644 MasKitTests/Bundle+JSON.swift delete mode 100644 MasKitTests/Mocks/MockURLSession.swift create mode 100644 MasKitTests/Network/MockNetworkSession.swift create mode 100644 MasKitTests/Network/MockNetworkSessionFromFile.swift rename MasKitTests/{Mocks => Network}/MockURLSessionDataTask.swift (90%) create mode 100644 MasKitTests/String+FileExtension.swift diff --git a/MasKit/Commands/Search.swift b/MasKit/Commands/Search.swift index aa14665..228f657 100644 --- a/MasKit/Commands/Search.swift +++ b/MasKit/Commands/Search.swift @@ -25,15 +25,15 @@ public struct SearchCommand: CommandProtocol { public let verb = "search" public let function = "Search for apps from the Mac App Store" - private let urlSession: URLSession + private let networkSession: NetworkSession - public init(urlSession: URLSession = URLSession.shared) { - self.urlSession = urlSession + public init(networkSession: NetworkSession = URLSession.shared) { + self.networkSession = networkSession } public func run(_ options: Options) -> Result<(), MASError> { guard let searchURLString = searchURLString(options.appName), - let searchJson = urlSession.requestSynchronousJSONWithURLString(searchURLString) as? [String: Any] else { + let searchJson = networkSession.requestSynchronousJSONWithURLString(searchURLString) as? [String: Any] else { return .failure(.searchFailed) } diff --git a/MasKit/MasStoreSearch.swift b/MasKit/MasStoreSearch.swift index b43efb4..6b9bbb5 100644 --- a/MasKit/MasStoreSearch.swift +++ b/MasKit/MasStoreSearch.swift @@ -8,11 +8,11 @@ /// Manages searching the MAS catalog through the iTunes Search and Lookup APIs. public class MasStoreSearch: StoreSearch { - private let urlSession: URLSession + private let networkManager: NetworkManager /// Designated initializer. - public init(urlSession: URLSession = URLSession.shared) { - self.urlSession = urlSession + public init(networkManager: NetworkManager = NetworkManager()) { + self.networkManager = networkManager } /// Looks up app details. @@ -21,22 +21,29 @@ public class MasStoreSearch: StoreSearch { /// - Returns: Search result record of app or nil if no apps match the ID. /// - Throws: Error if there is a problem with the network request. public func lookup(app appId: String) throws -> SearchResult? { - guard let lookupURLString = lookupURLString(forApp: appId), - let jsonData = urlSession.requestSynchronousDataWithURLString(lookupURLString) + guard let url = lookupURL(forApp: appId) + else { throw MASError.searchFailed } + + let result = networkManager.loadDataSync(from: url) + + // Unwrap network result + guard case let .success(data) = result else { - // network error + if case let .failure(error) = result { + throw error + } throw MASError.searchFailed } - guard let results = try? JSONDecoder().decode(SearchResultList.self, from: jsonData) + guard let results = try? JSONDecoder().decode(SearchResultList.self, from: data) else { // parse error throw MASError.searchFailed } - guard let result = results.results.first + guard let searchResult = results.results.first else { return nil } - return result + return searchResult } } diff --git a/MasKit/Network/NetworkManager.swift b/MasKit/Network/NetworkManager.swift index 4adbddb..03e3f0c 100644 --- a/MasKit/Network/NetworkManager.swift +++ b/MasKit/Network/NetworkManager.swift @@ -9,7 +9,7 @@ import Foundation /// Network abstraction -class NetworkManager { +public class NetworkManager { enum NetworkError: Error { case timeout } @@ -18,8 +18,8 @@ class NetworkManager { /// Designated initializer /// - /// - Parameter session: <#session description#> - init(session: NetworkSession = URLSession.shared) { + /// - Parameter session: A networking session. + public init(session: NetworkSession = URLSession.shared) { self.session = session } @@ -30,8 +30,9 @@ class NetworkManager { /// - completionHandler: Closure where result is delivered. func loadData(from url: URL, completionHandler: @escaping (NetworkResult) -> Void) { session.loadData(from: url) { (data: Data?, error: Error?) in - let result: NetworkResult = data != nil ? .success(data!) : .failure(error!) -// let result = data.map(NetworkResult.success) ?? .failure(error) + let result: NetworkResult = data != nil + ? .success(data!) + : .failure(error!) completionHandler(result) } } diff --git a/MasKit/Network/NetworkSession.swift b/MasKit/Network/NetworkSession.swift index 3395466..10c2146 100644 --- a/MasKit/Network/NetworkSession.swift +++ b/MasKit/Network/NetworkSession.swift @@ -6,6 +6,52 @@ // Copyright © 2019 mas-cli. All rights reserved. // -protocol NetworkSession { - func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) +@objc public protocol NetworkSession { + @objc func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) +} + +// MARK: - URLSession+Synchronous +extension NetworkSession { + /// Return data from synchronous URL request + public func requestSynchronousData(_ request: URLRequest) -> Data? { + var data: Data? = nil + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { + (taskData, _, error) -> Void in + data = taskData + if data == nil, let error = error { + print(error) + } + semaphore.signal() + } + task.resume() + + _ = semaphore.wait(timeout: .distantFuture) + return data + } + + /// Return data synchronous from specified endpoint + public func requestSynchronousDataWithURLString(_ requestString: String) -> Data? { + guard let url = URL(string:requestString) else { return nil } + let request = URLRequest(url: url) + return requestSynchronousData(request) + } + + /// Return JSON synchronous from URL request + public func requestSynchronousJSON(_ request: URLRequest) -> Any? { + guard let data = requestSynchronousData(request) else { return nil } + return try! JSONSerialization.jsonObject(with: data, options: []) + } + + /// Return JSON synchronous from specified endpoint + public func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? { + guard let url = URL(string: requestString) else { return nil } + + var request = URLRequest(url:url) + request.httpMethod = "GET" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + return requestSynchronousJSON(request) + } } diff --git a/MasKit/Network/URLSession+Synchronous.swift b/MasKit/Network/NetworkSessionMockFromFile.swift similarity index 62% rename from MasKit/Network/URLSession+Synchronous.swift rename to MasKit/Network/NetworkSessionMockFromFile.swift index 42bd431..10c2146 100644 --- a/MasKit/Network/URLSession+Synchronous.swift +++ b/MasKit/Network/NetworkSessionMockFromFile.swift @@ -1,24 +1,24 @@ // -// URLSession+Synchronous.swift -// mas-cli +// NetworkSession.swift +// MasKit // -// Created by Michael Schneider on 4/14/16. -// Copyright © 2016 Andrew Naylor. All rights reserved. +// Created by Ben Chatelain on 1/5/19. +// Copyright © 2019 mas-cli. All rights reserved. // -// Synchronous NSURLSession code found at: http://ericasadun.com/2015/11/12/more-bad-things-synchronous-nsurlsessions/ +@objc public protocol NetworkSession { + @objc func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) +} -import Foundation - -/// NSURLSession synchronous behavior -/// Particularly for playground sessions that need to run sequentially -public extension URLSession { +// MARK: - URLSession+Synchronous +extension NetworkSession { /// Return data from synchronous URL request public func requestSynchronousData(_ request: URLRequest) -> Data? { var data: Data? = nil let semaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: request) { - (taskData, _, error) -> Void in + (taskData, _, error) -> Void in data = taskData if data == nil, let error = error { print(error) @@ -26,12 +26,13 @@ public extension URLSession { semaphore.signal() } task.resume() + _ = semaphore.wait(timeout: .distantFuture) return data } /// Return data synchronous from specified endpoint - @objc public func requestSynchronousDataWithURLString(_ requestString: String) -> Data? { + public func requestSynchronousDataWithURLString(_ requestString: String) -> Data? { guard let url = URL(string:requestString) else { return nil } let request = URLRequest(url: url) return requestSynchronousData(request) @@ -44,18 +45,13 @@ public extension URLSession { } /// Return JSON synchronous from specified endpoint - @objc public func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? { + public func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? { guard let url = URL(string: requestString) else { return nil } + var request = URLRequest(url:url) request.httpMethod = "GET" request.addValue("application/json", forHTTPHeaderField: "Content-Type") + return requestSynchronousJSON(request) } } - -public extension String { - /// Return an URL encoded string - var URLEncodedString: String? { - return addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - } -} diff --git a/MasKit/Network/URLSession+NetworkSession.swift b/MasKit/Network/URLSession+NetworkSession.swift index 232ecd8..92af759 100644 --- a/MasKit/Network/URLSession+NetworkSession.swift +++ b/MasKit/Network/URLSession+NetworkSession.swift @@ -9,7 +9,7 @@ import Foundation extension URLSession: NetworkSession { - func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) { + @objc open func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) { let task = dataTask(with: url) { (data, _, error) in completionHandler(data, error) } diff --git a/MasKit/StoreSearch.swift b/MasKit/StoreSearch.swift index 35f2d0d..b6e4278 100644 --- a/MasKit/StoreSearch.swift +++ b/MasKit/StoreSearch.swift @@ -18,9 +18,16 @@ extension StoreSearch { /// - Parameter appId: MAS app identifier. /// - Returns: A string URL for the lookup service or nil if the appId can't be encoded. public func lookupURLString(forApp appId: String) -> String? { - if let urlEncodedAppId = appId.URLEncodedString { - return "https://itunes.apple.com/lookup?id=\(urlEncodedAppId)" - } - return nil + guard let urlEncodedAppId = appId.URLEncodedString else { return nil } + return "https://itunes.apple.com/lookup?id=\(urlEncodedAppId)" + } + + /// Builds the lookup URL for an app. + /// + /// - Parameter appId: MAS app identifier. + /// - Returns: A string URL for the lookup service or nil if the appId can't be encoded. + public func lookupURL(forApp appId: String) -> URL? { + guard let urlString = lookupURLString(forApp: appId) else { return nil } + return URL(string: urlString) } } diff --git a/MasKit/String+PercentEncoding.swift b/MasKit/String+PercentEncoding.swift new file mode 100644 index 0000000..51d936c --- /dev/null +++ b/MasKit/String+PercentEncoding.swift @@ -0,0 +1,16 @@ +// +// String+PercentEncoding.swift +// MasKit +// +// Created by Ben Chatelain on 1/5/19. +// Copyright © 2019 mas-cli. All rights reserved. +// + +import Foundation + +public extension String { + /// Return an URL encoded string + var URLEncodedString: String? { + return addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + } +} diff --git a/MasKitTests/Bundle+JSON.swift b/MasKitTests/Bundle+JSON.swift new file mode 100644 index 0000000..4059e90 --- /dev/null +++ b/MasKitTests/Bundle+JSON.swift @@ -0,0 +1,30 @@ +// +// Bundle+JSON.swift +// MasKitTests +// +// Created by Ben Chatelain on 1/5/19. +// Copyright © 2019 mas-cli. All rights reserved. +// + +import Foundation + +extension Bundle { + /// Locates a JSON response file from the test bundle. + /// + /// - Parameter fileName: Name of file to locate. + /// - Returns: URL to file. + static func jsonResponse(fileName: String) -> URL? { + return Bundle(for: MockNetworkSession.self).fileURL(fileName: fileName) + } + + /// Builds a URL for a file in the JSON directory of the current bundle. + /// + /// - Parameter fileName: Name of file to locate. + /// - Returns: URL to file. + func fileURL(fileName: String) -> URL? { + guard let path = self.path(forResource: fileName.fileNameWithoutExtension, ofType: fileName.fileExtension, inDirectory: "JSON") + else { fatalError("Unable to load file \(fileName)") } + + return URL(fileURLWithPath: path) + } +} diff --git a/MasKitTests/Commands/SearchSpec.swift b/MasKitTests/Commands/SearchSpec.swift index 87bad6e..ab983d1 100644 --- a/MasKitTests/Commands/SearchSpec.swift +++ b/MasKitTests/Commands/SearchSpec.swift @@ -16,7 +16,7 @@ class SearchSpec: QuickSpec { describe("search") { context("for slack") { it("succeeds") { - let search = SearchCommand(urlSession: MockURLSession(responseFile: "search/slack.json")) + let search = SearchCommand(networkSession: MockNetworkSessionFromFile(responseFile: "search/slack.json")) let searchOptions = SearchOptions(appName: "slack", price: false) let result = search.run(searchOptions) expect(result).to(beSuccess()) @@ -24,7 +24,7 @@ class SearchSpec: QuickSpec { } context("for nonexistent") { it("fails") { - let search = SearchCommand(urlSession: MockURLSession(responseFile: "search/nonexistent.json")) + let search = SearchCommand(networkSession: MockNetworkSessionFromFile(responseFile: "search/nonexistent.json")) let searchOptions = SearchOptions(appName: "nonexistent", price: false) let result = search.run(searchOptions) expect(result).to(beFailure { error in diff --git a/MasKitTests/MasStoreSearchSpec.swift b/MasKitTests/MasStoreSearchSpec.swift index 7cc902f..f605d44 100644 --- a/MasKitTests/MasStoreSearchSpec.swift +++ b/MasKitTests/MasStoreSearchSpec.swift @@ -14,8 +14,9 @@ import Nimble class MasStoreSearchSpec: QuickSpec { override func spec() { let appId = 803453959 - let urlSession = MockURLSession(responseFile: "lookup/slack.json") - let storeSearch = MasStoreSearch(urlSession: urlSession) + let urlSession = MockNetworkSessionFromFile(responseFile: "lookup/slack.json") + let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: urlSession)) + describe("store search") { it("can find slack") { let result = try! storeSearch.lookup(app: appId.description) diff --git a/MasKitTests/Mocks/MockURLSession.swift b/MasKitTests/Mocks/MockURLSession.swift deleted file mode 100644 index 5337200..0000000 --- a/MasKitTests/Mocks/MockURLSession.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// MockURLSession.swift -// MasKitTests -// -// Created by Ben Chatelain on 11/13/18. -// Copyright © 2018 mas-cli. All rights reserved. -// - -@testable import MasKit - -/// Mock URLSession for testing. -// FIXME: allow mock url session to operate offline -//2019-01-04 17:20:41.741632-0800 xctest[76410:1817605] TIC TCP Conn Failed [3:0x100a67420]: 1:50 Err(50) -//2019-01-04 17:20:41.741849-0800 xctest[76410:1817605] Task <0C05E774-1CDE-48FB-9408-AFFCD12F3F60>.<3> HTTP load failed (error code: -1009 [1:50]) -//2019-01-04 17:20:41.741903-0800 xctest[76410:1817605] Task <0C05E774-1CDE-48FB-9408-AFFCD12F3F60>.<3> finished with error - code: -1009 -//Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline." UserInfo={NSUnderlyingError=0x100a692f0 {Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)" UserInfo={_kCFStreamErrorCodeKey=50, _kCFStreamErrorDomainKey=1}}, NSErrorFailingURLStringKey=https://itunes.apple.com/lookup?id=803453959, NSErrorFailingURLKey=https://itunes.apple.com/lookup?id=803453959, _kCFStreamErrorDomainKey=1, _kCFStreamErrorCodeKey=50, NSLocalizedDescription=The Internet connection appears to be offline.} -// Fatal error: 'try!' expression unexpectedly raised an error: Search failed: file /BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang_Fall2018/swiftlang_Fall2018-1000.11.42/src/swift/stdlib/public/core/ErrorType.swift, line 184 -// 2019-01-04 17:20:41.818432-0800 xctest[76410:1817499] Fatal error: 'try!' expression unexpectedly raised an error: Search failed: file /BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang_Fall2018/swiftlang_Fall2018-1000.11.42/src/swift/stdlib/public/core/ErrorType.swift, line 184 -class MockURLSession: URLSession { - // The singleton URL session, configured to use our custom config and delegate. - static let session = URLSession( - configuration: URLSessionConfiguration.testSessionConfiguration(), - // Delegate is retained by the session. - delegate: TestURLSessionDelegate(), - delegateQueue: OperationQueue.main) - - private let responseFile: String - - /// Initializes a mock URL session with a file for the response. - /// - /// - Parameter responseFile: Name of file containing JSON response body. - init(responseFile: String) { - self.responseFile = responseFile - } - - typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void - - // Properties that enable us to set exactly what data or error - // we want our mocked URLSession to return for any request. - var data: Data? - var error: Error? - - override func dataTask(with url: URL, completionHandler: @escaping CompletionHandler) -> URLSessionDataTask { - let data = self.data - let error = self.error - - return URLSessionDataTaskMock { - completionHandler(data, nil, error) - } - } - - /// Override which returns Data from a file. - /// - /// - Parameter requestString: Ignored URL string - /// - Returns: Contents of responseFile - @objc override func requestSynchronousDataWithURLString(_ requestString: String) -> Data? { - guard let fileURL = Bundle.jsonResponse(fileName: responseFile) - else { fatalError("Unable to load file \(responseFile)") } - - do { - let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) - return data - } catch { - print("Error opening file: \(error)") - } - - return nil - } - - /// Override which returns JSON contents from a file. - /// - /// - Parameter requestString: Ignored URL string - /// - Returns: Parsed contents of responseFile - @objc override func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? { - guard let data = requestSynchronousDataWithURLString(requestString) - else { return nil } - - do { - let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) - if let jsonResult = jsonResult as? Dictionary { - return jsonResult - } - } catch { - print("Error parsing JSON: \(error)") - } - - return nil - } -} - -extension Bundle { - /// Locates a JSON response file from the test bundle. - /// - /// - Parameter fileName: Name of file to locate. - /// - Returns: URL to file. - static func jsonResponse(fileName: String) -> URL? { - return Bundle(for: MockURLSession.self).fileURL(fileName: fileName) - } - - /// Builds a URL for a file in the JSON directory of the current bundle. - /// - /// - Parameter fileName: Name of file to locate. - /// - Returns: URL to file. - func fileURL(fileName: String) -> URL? { - guard let path = self.path(forResource: fileName.fileNameWithoutExtension, ofType: fileName.fileExtension, inDirectory: "JSON") - else { fatalError("Unable to load file \(fileName)") } - - return URL(fileURLWithPath: path) - } -} - -extension String { - /// Returns the file name before the extension. - var fileNameWithoutExtension: String { - return (self as NSString).deletingPathExtension - } - - /// Returns the file extension. - var fileExtension: String { - return (self as NSString).pathExtension - } -} diff --git a/MasKitTests/Network/MockNetworkSession.swift b/MasKitTests/Network/MockNetworkSession.swift new file mode 100644 index 0000000..216f3ba --- /dev/null +++ b/MasKitTests/Network/MockNetworkSession.swift @@ -0,0 +1,44 @@ +// +// MockURLSession.swift +// MasKitTests +// +// Created by Ben Chatelain on 11/13/18. +// Copyright © 2018 mas-cli. All rights reserved. +// + +import MasKit + +/// Mock URLSession for testing. +class MockNetworkSession: NetworkSession { + typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void + + // Properties that enable us to set exactly what data or error + // we want our mocked URLSession to return for any request. + var data: Data? + var error: Error? + + /// Creates a mock data task + /// + /// - Parameters: + /// - url: unused + /// - completionHandler: Closure which is delivered both data and error properties (only one should be non-nil) + /// - Returns: Mock data task + func dataTask(with url: URL, completionHandler: @escaping CompletionHandler) -> URLSessionDataTask { + let data = self.data + let error = self.error + + return MockURLSessionDataTask { + completionHandler(data, nil, error) + } + } + + /// Immediately passes data and error to completion handler. + /// + /// - Parameters: + /// - url: unused + /// - completionHandler: Closure which is delivered either data or an error. + @objc func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) { + completionHandler(data, error) + } +} + diff --git a/MasKitTests/Network/MockNetworkSessionFromFile.swift b/MasKitTests/Network/MockNetworkSessionFromFile.swift new file mode 100644 index 0000000..032b5bc --- /dev/null +++ b/MasKitTests/Network/MockNetworkSessionFromFile.swift @@ -0,0 +1,77 @@ +// +// MockURLSession.swift +// MasKitTests +// +// Created by Ben Chatelain on 2019-01-05. +// Copyright © 2019 mas-cli. All rights reserved. +// + +import MasKit + +/// Mock URLSession for testing. +class MockNetworkSessionFromFile: MockNetworkSession { + private let responseFile: String + + /// Initializes a mock URL session with a file for the response. + /// + /// - Parameter responseFile: Name of file containing JSON response body. + init(responseFile: String) { + self.responseFile = responseFile + } + + /// Loads data from a file. + /// + /// - Parameters: + /// - url: unused + /// - completionHandler: Closure which is delivered either data or an error. + @objc override func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) { + guard let fileURL = Bundle.jsonResponse(fileName: responseFile) + else { fatalError("Unable to load file \(responseFile)") } + + do { + let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) + completionHandler(data, nil) + } catch { + print("Error opening file: \(error)") + completionHandler(nil, error) + } + } + + /// Override which returns Data from a file. + /// + /// - Parameter requestString: Ignored URL string + /// - Returns: Contents of responseFile + @objc func requestSynchronousDataWithURLString(_ requestString: String) -> Data? { + guard let fileURL = Bundle.jsonResponse(fileName: responseFile) + else { fatalError("Unable to load file \(responseFile)") } + + do { + let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) + return data + } catch { + print("Error opening file: \(error)") + } + + return nil + } + + /// Override which returns JSON contents from a file. + /// + /// - Parameter requestString: Ignored URL string + /// - Returns: Parsed contents of responseFile + @objc func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? { + guard let data = requestSynchronousDataWithURLString(requestString) + else { return nil } + + do { + let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) + if let jsonResult = jsonResult as? Dictionary { + return jsonResult + } + } catch { + print("Error parsing JSON: \(error)") + } + + return nil + } +} diff --git a/MasKitTests/Mocks/MockURLSessionDataTask.swift b/MasKitTests/Network/MockURLSessionDataTask.swift similarity index 90% rename from MasKitTests/Mocks/MockURLSessionDataTask.swift rename to MasKitTests/Network/MockURLSessionDataTask.swift index ed67fa2..e71f5a7 100644 --- a/MasKitTests/Mocks/MockURLSessionDataTask.swift +++ b/MasKitTests/Network/MockURLSessionDataTask.swift @@ -9,7 +9,7 @@ import Foundation // Partial mock subclassing the original class -class URLSessionDataTaskMock: URLSessionDataTask { +class MockURLSessionDataTask: URLSessionDataTask { private let closure: () -> Void init(closure: @escaping () -> Void) { diff --git a/MasKitTests/Network/NetworkManagerTests.swift b/MasKitTests/Network/NetworkManagerTests.swift index c3e0640..52a8d7c 100644 --- a/MasKitTests/Network/NetworkManagerTests.swift +++ b/MasKitTests/Network/NetworkManagerTests.swift @@ -12,7 +12,7 @@ import XCTest class NetworkManagerTests: XCTestCase { func testSuccessfulAsyncResponse() { // Setup our objects - let session = MockURLSession(responseFile: "") + let session = MockNetworkSession() let manager = NetworkManager(session: session) // Create data and tell the session to always return it @@ -30,7 +30,7 @@ class NetworkManagerTests: XCTestCase { func testSuccessfulSyncResponse() { // Setup our objects - let session = MockURLSession(responseFile: "") + let session = MockNetworkSession() let manager = NetworkManager(session: session) // Create data and tell the session to always return it @@ -47,7 +47,7 @@ class NetworkManagerTests: XCTestCase { func testFailureAsyncResponse() { // Setup our objects - let session = MockURLSession(responseFile: "") + let session = MockNetworkSession() let manager = NetworkManager(session: session) session.error = NetworkManager.NetworkError.timeout @@ -63,7 +63,7 @@ class NetworkManagerTests: XCTestCase { func testFailureSyncResponse() { // Setup our objects - let session = MockURLSession(responseFile: "") + let session = MockNetworkSession() let manager = NetworkManager(session: session) session.error = NetworkManager.NetworkError.timeout diff --git a/MasKitTests/String+FileExtension.swift b/MasKitTests/String+FileExtension.swift new file mode 100644 index 0000000..8fa2cff --- /dev/null +++ b/MasKitTests/String+FileExtension.swift @@ -0,0 +1,21 @@ +// +// String+FileExtension.swift +// MasKitTests +// +// Created by Ben Chatelain on 1/5/19. +// Copyright © 2019 mas-cli. All rights reserved. +// + +import Foundation + +extension String { + /// Returns the file name before the extension. + var fileNameWithoutExtension: String { + return (self as NSString).deletingPathExtension + } + + /// Returns the file extension. + var fileExtension: String { + return (self as NSString).pathExtension + } +} diff --git a/mas-cli.xcodeproj/project.pbxproj b/mas-cli.xcodeproj/project.pbxproj index 337a042..1f13c2e 100644 --- a/mas-cli.xcodeproj/project.pbxproj +++ b/mas-cli.xcodeproj/project.pbxproj @@ -31,8 +31,12 @@ B576FE0621E114470016B39D /* MockURLSessionDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0521E114470016B39D /* MockURLSessionDataTask.swift */; }; B576FE0821E114A80016B39D /* NetworkResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0721E114A80016B39D /* NetworkResult.swift */; }; B576FE0C21E116590016B39D /* NetworkManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0B21E116590016B39D /* NetworkManagerTests.swift */; }; + B576FE0E21E1D6310016B39D /* String+PercentEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */; }; + B576FE1221E1D82D0016B39D /* MockNetworkSessionFromFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1121E1D82D0016B39D /* MockNetworkSessionFromFile.swift */; }; + B576FE1421E1D8A90016B39D /* Bundle+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */; }; + B576FE1621E1D8CB0016B39D /* String+FileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */; }; B5793E29219BDD4800135B39 /* JSON in Resources */ = {isa = PBXBuildFile; fileRef = B5793E28219BDD4800135B39 /* JSON */; }; - B5793E2B219BE0CD00135B39 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5793E2A219BE0CD00135B39 /* MockURLSession.swift */; }; + B5793E2B219BE0CD00135B39 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5793E2A219BE0CD00135B39 /* MockNetworkSession.swift */; }; B588CE0221DC89490047D305 /* ExternalCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B588CE0121DC89490047D305 /* ExternalCommand.swift */; }; B588CE0421DC8AFB0047D305 /* TrashCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B588CE0321DC8AFB0047D305 /* TrashCommand.swift */; }; B594B12021D53A8200F3AC59 /* Uninstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B11F21D53A8200F3AC59 /* Uninstall.swift */; }; @@ -117,7 +121,6 @@ F8FB717920F2B4DD00F56FDC /* Upgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD3B3621C34709400B56B88 /* Upgrade.swift */; }; F8FB717A20F2B4DD00F56FDC /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB6CE8B1BAEC3D400648B4D /* Version.swift */; }; F8FB717B20F2B4DD00F56FDC /* MASError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0F238C1B8756E600AE40CD /* MASError.swift */; }; - F8FB717C20F2B4DD00F56FDC /* URLSession+Synchronous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693A989A1CBFFAAA0004D3B4 /* URLSession+Synchronous.swift */; }; F8FB717D20F2B4DD00F56FDC /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCBF9541D89CFC7000039C6 /* Utilities.swift */; }; /* End PBXBuildFile section */ @@ -188,7 +191,6 @@ /* Begin PBXFileReference section */ 693A98981CBFFA760004D3B4 /* Search.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; - 693A989A1CBFFAAA0004D3B4 /* URLSession+Synchronous.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLSession+Synchronous.swift"; sourceTree = ""; }; 8078FAA71EC4F2FB004B5B3F /* Lucky.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Lucky.swift; sourceTree = ""; }; 900A1E801DBAC8CB0069B1A8 /* Info.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Info.swift; sourceTree = ""; }; 90CB4069213F4DDD0044E445 /* Result.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Result.framework; sourceTree = ""; }; @@ -208,8 +210,12 @@ B576FE0521E114470016B39D /* MockURLSessionDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSessionDataTask.swift; sourceTree = ""; }; B576FE0721E114A80016B39D /* NetworkResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResult.swift; sourceTree = ""; }; B576FE0B21E116590016B39D /* NetworkManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManagerTests.swift; sourceTree = ""; }; + B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+PercentEncoding.swift"; sourceTree = ""; }; + B576FE1121E1D82D0016B39D /* MockNetworkSessionFromFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockNetworkSessionFromFile.swift; sourceTree = ""; }; + B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+JSON.swift"; sourceTree = ""; }; + B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FileExtension.swift"; sourceTree = ""; }; B5793E28219BDD4800135B39 /* JSON */ = {isa = PBXFileReference; lastKnownFileType = folder; path = JSON; sourceTree = ""; }; - B5793E2A219BE0CD00135B39 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + B5793E2A219BE0CD00135B39 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = ""; }; B588CE0121DC89490047D305 /* ExternalCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalCommand.swift; sourceTree = ""; }; B588CE0321DC8AFB0047D305 /* TrashCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrashCommand.swift; sourceTree = ""; }; B594B11F21D53A8200F3AC59 /* Uninstall.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Uninstall.swift; sourceTree = ""; }; @@ -354,7 +360,6 @@ B576FE0721E114A80016B39D /* NetworkResult.swift */, B576FDFF21E113610016B39D /* NetworkSession.swift */, B576FE0121E1139E0016B39D /* URLSession+NetworkSession.swift */, - 693A989A1CBFFAAA0004D3B4 /* URLSession+Synchronous.swift */, ); path = Network; sourceTree = ""; @@ -362,6 +367,9 @@ B576FE0A21E116470016B39D /* Network */ = { isa = PBXGroup; children = ( + B5793E2A219BE0CD00135B39 /* MockNetworkSession.swift */, + B576FE1121E1D82D0016B39D /* MockNetworkSessionFromFile.swift */, + B576FE0521E114470016B39D /* MockURLSessionDataTask.swift */, B576FE0B21E116590016B39D /* NetworkManagerTests.swift */, B576FDFB21E10A610016B39D /* TestURLSessionConfiguration.swift */, B576FDFD21E10B660016B39D /* TestURLSessionDelegate.swift */, @@ -410,8 +418,6 @@ B5DBF81621E02E3400F3B151 /* MockOpenSystemCommand.swift */, B576FDF821E107CA0016B39D /* MockSoftwareProduct.swift */, B5DBF81421E02BA900F3B151 /* MockStoreSearch.swift */, - B5793E2A219BE0CD00135B39 /* MockURLSession.swift */, - B576FE0521E114470016B39D /* MockURLSessionDataTask.swift */, ); path = Mocks; sourceTree = ""; @@ -511,6 +517,7 @@ B594B15521D89F5200F3AC59 /* SearchResultList.swift */, B594B12821D5831D00F3AC59 /* SoftwareProduct.swift */, B594B14F21D8998000F3AC59 /* StoreSearch.swift */, + B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */, EDCBF9541D89CFC7000039C6 /* Utilities.swift */, ); path = MasKit; @@ -519,6 +526,7 @@ F8FB715E20F2B41400F56FDC /* MasKitTests */ = { isa = PBXGroup; children = ( + B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */, B594B12321D57FF300F3AC59 /* Commands */, F8FB716120F2B41400F56FDC /* Info.plist */, B5793E28219BDD4800135B39 /* JSON */, @@ -527,6 +535,7 @@ B594B12C21D584E800F3AC59 /* Mocks */, B576FE0A21E116470016B39D /* Network */, B594B13121D5876200F3AC59 /* ResultPredicates.swift */, + B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */, ); path = MasKitTests; sourceTree = ""; @@ -778,7 +787,6 @@ B576FDF721E107AA0016B39D /* OpenSystemCommand.swift in Sources */, F8FB717B20F2B4DD00F56FDC /* MASError.swift in Sources */, B594B15221D89A8B00F3AC59 /* MasStoreSearch.swift in Sources */, - F8FB717C20F2B4DD00F56FDC /* URLSession+Synchronous.swift in Sources */, B5DBF80D21DEE4E600F3B151 /* Open.swift in Sources */, F8FB717420F2B4DD00F56FDC /* Outdated.swift in Sources */, F8FB716C20F2B4DD00F56FDC /* PurchaseDownloadObserver.swift in Sources */, @@ -786,6 +794,7 @@ F8FB717620F2B4DD00F56FDC /* Search.swift in Sources */, B576FE0021E113610016B39D /* NetworkSession.swift in Sources */, B594B15421D89DF400F3AC59 /* SearchResult.swift in Sources */, + B576FE0E21E1D6310016B39D /* String+PercentEncoding.swift in Sources */, B576FDFA21E1081C0016B39D /* SearchResultList.swift in Sources */, F8FB717720F2B4DD00F56FDC /* SignIn.swift in Sources */, B576FE0221E1139E0016B39D /* URLSession+NetworkSession.swift in Sources */, @@ -817,15 +826,17 @@ B594B12E21D5850700F3AC59 /* MockAppLibrary.swift in Sources */, B5DBF81721E02E3400F3B151 /* MockOpenSystemCommand.swift in Sources */, B5DBF81521E02BA900F3B151 /* MockStoreSearch.swift in Sources */, - B5793E2B219BE0CD00135B39 /* MockURLSession.swift in Sources */, + B5793E2B219BE0CD00135B39 /* MockNetworkSession.swift in Sources */, B5DBF81321DEEC7C00F3B151 /* OpenCommandSpec.swift in Sources */, B594B14221D6D8EC00F3AC59 /* OutdatedCommandSpec.swift in Sources */, B576FE0621E114470016B39D /* MockURLSessionDataTask.swift in Sources */, B594B14021D6D8BF00F3AC59 /* ResetCommandSpec.swift in Sources */, + B576FE1421E1D8A90016B39D /* Bundle+JSON.swift in Sources */, B576FDF921E107CA0016B39D /* MockSoftwareProduct.swift in Sources */, B576FE0C21E116590016B39D /* NetworkManagerTests.swift in Sources */, B594B13221D5876200F3AC59 /* ResultPredicates.swift in Sources */, B594B13E21D6D78900F3AC59 /* SearchCommandSpec.swift in Sources */, + B576FE1621E1D8CB0016B39D /* String+FileExtension.swift in Sources */, B555292D219A1FE700ACB4CA /* SearchSpec.swift in Sources */, B594B13C21D6D72E00F3AC59 /* SignInCommandSpec.swift in Sources */, B594B13A21D6D70400F3AC59 /* SignOutCommandSpec.swift in Sources */, @@ -833,6 +844,7 @@ B594B13821D6D6C100F3AC59 /* UpgradeCommandSpec.swift in Sources */, B576FDFE21E10B660016B39D /* TestURLSessionDelegate.swift in Sources */, B5DBF81121DEEC4200F3B151 /* VendorCommandSpec.swift in Sources */, + B576FE1221E1D82D0016B39D /* MockNetworkSessionFromFile.swift in Sources */, B576FDF521E1078F0016B39D /* MASErrorTestCase.swift in Sources */, B594B13621D6D68600F3AC59 /* VersionCommandSpec.swift in Sources */, B576FDFC21E10A610016B39D /* TestURLSessionConfiguration.swift in Sources */,