From 1378fb3047e89657bb4b13bf9f73249b8b9ceaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sat, 15 Aug 2026 23:14:33 -0700 Subject: [PATCH 1/6] Inject Wealthsimple downloader dependencies --- .../DownloaderDependencies.swift | 37 ++ Sources/WealthsimpleDownloader/Token.swift | 59 +- .../URLConfiguration.swift | 36 +- .../WealthsimpleAPI.swift | 18 +- .../WealthsimpleAccount.swift | 10 +- .../WealthsimplePosition.swift | 41 +- .../WealthsimpleTransaction.swift | 71 ++- .../CreditCardPositionTests.swift | 155 +++--- .../Extensions/XCTestCase.swift | 21 - .../Helpers/DownloaderTestCase.swift | 25 +- .../Helpers/MockHTTPClient.swift | 96 ++++ .../Helpers/MockURLProtocol.swift | 130 ----- .../TokenTests.swift | 316 ++++++----- .../URLConfigurationTests.swift | 153 ++--- .../WealthsimpleAccountTests.swift | 157 +++--- .../WealthsimpleDownloaderTests.swift | 267 ++++----- .../WealthsimplePositionTests.swift | 196 ++++--- .../WealthsimpleTransactionTests.swift | 521 ++++++++++-------- 18 files changed, 1211 insertions(+), 1098 deletions(-) create mode 100644 Sources/WealthsimpleDownloader/DownloaderDependencies.swift delete mode 100644 Tests/WealthsimpleDownloaderTests/Extensions/XCTestCase.swift create mode 100644 Tests/WealthsimpleDownloaderTests/Helpers/MockHTTPClient.swift delete mode 100644 Tests/WealthsimpleDownloaderTests/Helpers/MockURLProtocol.swift diff --git a/Sources/WealthsimpleDownloader/DownloaderDependencies.swift b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift new file mode 100644 index 00000000..88176ab0 --- /dev/null +++ b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift @@ -0,0 +1,37 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +protocol HTTPClient { + func send( + _ request: URLRequest, + body: Data?, + completion: @escaping (Data?, URLResponse?, Error?) -> Void + ) +} + +struct URLSessionHTTPClient: HTTPClient { + let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func send( + _ request: URLRequest, + body: Data?, + completion: @escaping (Data?, URLResponse?, Error?) -> Void + ) { + var request = request + request.httpBody = body + session.dataTask(with: request, completionHandler: completion).resume() + } +} + +struct DownloaderDependencies { + static let live = Self(httpClient: URLSessionHTTPClient(), configuration: URLConfiguration()) + + let httpClient: HTTPClient + let configuration: URLConfiguration +} diff --git a/Sources/WealthsimpleDownloader/Token.swift b/Sources/WealthsimpleDownloader/Token.swift index 7a763ddd..afd6558d 100644 --- a/Sources/WealthsimpleDownloader/Token.swift +++ b/Sources/WealthsimpleDownloader/Token.swift @@ -33,9 +33,9 @@ struct Token { private static let credentialStorageKeyAccessToken = "accessToken" private static let credentialStorageKeyRefreshToken = "refreshToken" private static let credentialStorageKeyExpiry = "expiry" + private static let tokenPath = "oauth/v2/token" + private static let tokenValidationPath = "oauth/v2/token/info" - private static var url: URL { URLConfiguration.shared.urlObject(for: "oauth/v2/token")! } - private static var testUrl: URL { URLConfiguration.shared.urlObject(for: "oauth/v2/token/info")! } private static var clientId = "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa" // From the website private static var scope = "read" // the clientId supports some write scopes, but as this library only reads we limit it for safety @@ -43,15 +43,17 @@ struct Token { private let refreshToken: String private let expiry: Date private let credentialStorage: CredentialStorage + private let dependencies: DownloaderDependencies - private init(accessToken: String, refreshToken: String, expiry: Date, credentialStorage: CredentialStorage) { + private init(accessToken: String, refreshToken: String, expiry: Date, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) { self.accessToken = accessToken self.refreshToken = refreshToken self.expiry = expiry self.credentialStorage = credentialStorage + self.dependencies = dependencies } - private init(json: [String: Any], credentialStorage: CredentialStorage) throws { + private init(json: [String: Any], credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) throws { guard let accessToken = json["access_token"] as? String, let expiresIn = json["expires_in"] as? Int, let createdAt = json["created_at"] as? Int, @@ -63,10 +65,18 @@ struct Token { let expiresAt = createdAt + expiresIn self.expiry = Date(timeIntervalSince1970: TimeInterval(expiresAt)) self.credentialStorage = credentialStorage + self.dependencies = dependencies } - static func getToken(username: String, password: String, otp: String, credentialStorage: CredentialStorage, completion: @escaping (Result) -> Void) { - var request = URLRequest(url: url) + static func getToken( + username: String, + password: String, + otp: String, + credentialStorage: CredentialStorage, + dependencies: DownloaderDependencies = .live, + completion: @escaping (Result) -> Void + ) { + var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenPath)!) request.setValue(otp, forHTTPHeaderField: "x-wealthsimple-otp") let json = [ "grant_type": "password", @@ -75,10 +85,10 @@ struct Token { "scope": scope, "client_id": clientId ] - sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, completion: completion) + sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion) } - static func getToken(from credentialStorage: CredentialStorage, completion: @escaping (Self?) -> Void) { + static func getToken(from credentialStorage: CredentialStorage, dependencies: DownloaderDependencies = .live, completion: @escaping (Self?) -> Void) { guard let accessToken = credentialStorage.read(credentialStorageKeyAccessToken), let refreshToken = credentialStorage.read(credentialStorageKeyRefreshToken), let expiryString = credentialStorage.read(credentialStorageKeyExpiry), @@ -86,7 +96,13 @@ struct Token { completion(nil) return } - let token = Self(accessToken: accessToken, refreshToken: refreshToken, expiry: Date(timeIntervalSince1970: expiryDouble), credentialStorage: credentialStorage) + let token = Self( + accessToken: accessToken, + refreshToken: refreshToken, + expiry: Date(timeIntervalSince1970: expiryDouble), + credentialStorage: credentialStorage, + dependencies: dependencies + ) token.refreshIfNeeded { switch $0 { case .failure: @@ -107,27 +123,28 @@ struct Token { parameters json: [String: String], request urlRequest: URLRequest, credentialStorage: CredentialStorage, + dependencies: DownloaderDependencies, completion: @escaping (Result) -> Void ) { var request = urlRequest - let session = URLSession.shared request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else { completion(.failure(TokenError.invalidParameters(parameters: json))) return } - let task = session.uploadTask(with: request, from: jsonData) { data, response, error in - handleTokenResponse(data: data, response: response, error: error, credentialStorage: credentialStorage, completion: completion) + dependencies.httpClient.send(request, body: jsonData) { data, response, error in + handleTokenResponse(data: data, response: response, error: error, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion) } - task.resume() } + // swiftlint:disable:next function_parameter_count private static func handleTokenResponse( data: Data?, response: URLResponse?, error: Error?, credentialStorage: CredentialStorage, + dependencies: DownloaderDependencies, completion: (Result) -> Void ) { guard let data else { @@ -146,15 +163,15 @@ struct Token { completion(.failure(TokenError.httpError(error: "Status code \(httpResponse.statusCode)"))) return } - completion(parse(data: data, credentialStorage: credentialStorage)) + completion(parse(data: data, credentialStorage: credentialStorage, dependencies: dependencies)) } - private static func parse(data: Data, credentialStorage: CredentialStorage) -> Result { + private static func parse(data: Data, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) -> Result { guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return .failure(TokenError.invalidJsonType(json: data)) } do { - let token = try Self(json: json, credentialStorage: credentialStorage) + let token = try Self(json: json, credentialStorage: credentialStorage, dependencies: dependencies) token.saveToken() return .success(token) } catch { @@ -163,11 +180,10 @@ struct Token { } private func testIfValid(completion: @escaping (Bool) -> Void) { - var request = URLRequest(url: Self.testUrl) - let session = URLSession.shared + var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenValidationPath)!) request.setValue("application/json", forHTTPHeaderField: "Content-Type") authenticateRequest(request) { request in - let task = session.dataTask(with: request) { _, response, error in + dependencies.httpClient.send(request, body: nil) { _, response, error in guard error == nil else { completion(false) return @@ -178,7 +194,6 @@ struct Token { } completion(httpResponse.statusCode == 200) } - task.resume() } } @@ -201,13 +216,13 @@ struct Token { } private func refresh(completion: @escaping (Result) -> Void) { - let request = URLRequest(url: Self.url) + let request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenPath)!) let json = [ "grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": Self.clientId ] - Self.sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, completion: completion) + Self.sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion) } private func saveToken() { diff --git a/Sources/WealthsimpleDownloader/URLConfiguration.swift b/Sources/WealthsimpleDownloader/URLConfiguration.swift index e0d5dfa8..76bdcf14 100644 --- a/Sources/WealthsimpleDownloader/URLConfiguration.swift +++ b/Sources/WealthsimpleDownloader/URLConfiguration.swift @@ -10,19 +10,16 @@ import Foundation import FoundationNetworking #endif -/// Singleton class that manages the base URL configuration for all Wealthsimple API endpoints +/// Configures the REST and GraphQL endpoints used by Wealthsimple requests. final class URLConfiguration { private static let defaultBaseURL = "https://api.production.wealthsimple.com/v1/" private static let defaultGraphQLURL = "https://my.wealthsimple.com/graphql" - /// Shared singleton instance - static let shared = URLConfiguration() - /// Base URL for all Wealthsimple API endpoints - private var baseURL: String = URLConfiguration.defaultBaseURL + private let baseURL: String /// GraphQL URL for Wealthsimple - private var graphQLURL: String = URLConfiguration.defaultGraphQLURL + private let graphQLURL: String /// Get the current base URL var base: String { @@ -34,21 +31,13 @@ final class URLConfiguration { graphQLURL } - /// Private initializer to enforce singleton pattern - private init() { - // Singleton initialization - } - - /// Set a new base URL (internal access for testing) - /// - Parameter url: The new base URL to use - func setBaseURL(_ url: String) { - baseURL = url - } - - /// Set a new graphQL URL (internal access for testing) - /// - Parameter url: The new base URL to use - func setGraphQLURL(_ url: String) { - graphQLURL = url + /// Creates a configuration for the provided REST and GraphQL endpoints. + init( + baseURL: String = URLConfiguration.defaultBaseURL, + graphQLURL: String = URLConfiguration.defaultGraphQLURL + ) { + self.baseURL = baseURL + self.graphQLURL = graphQLURL } /// Create a full URL by appending a path to the base URL @@ -85,9 +74,4 @@ final class URLConfiguration { return request } - func reset() { - baseURL = Self.defaultBaseURL - graphQLURL = Self.defaultGraphQLURL - } - } diff --git a/Sources/WealthsimpleDownloader/WealthsimpleAPI.swift b/Sources/WealthsimpleDownloader/WealthsimpleAPI.swift index 9f3eccfe..d1825381 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleAPI.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleAPI.swift @@ -34,6 +34,7 @@ public final class WealthsimpleAPI { private let authenticationCallback: AuthenticationCallback private let credentialStorage: CredentialStorage + private let dependencies: DownloaderDependencies private var token: Token? /// Creates the Downloader instance @@ -45,9 +46,14 @@ public final class WealthsimpleAPI { /// Needs to return username, password, and one time password. Might be called during any call. /// - credentialStorage: A CredentialStore to save API tokens to. Implementation can be empty, /// in this case the authenticationCallback will be called every time and not only when the refresh token expired - public init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage) { + public convenience init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage) { + self.init(authenticationCallback: authenticationCallback, credentialStorage: credentialStorage, dependencies: .live) + } + + init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) { self.authenticationCallback = authenticationCallback self.credentialStorage = credentialStorage + self.dependencies = dependencies } /// Authneticates against the API. Call before calling any other method. @@ -65,7 +71,7 @@ public final class WealthsimpleAPI { } return } - Token.getToken(from: credentialStorage) { + Token.getToken(from: credentialStorage, dependencies: dependencies) { if let token = $0 { self.token = token completion(nil) @@ -84,7 +90,7 @@ public final class WealthsimpleAPI { completion(.failure(.tokenError(.noToken))) return } - WealthsimpleAccount.getAccounts(token: token) { + WealthsimpleAccount.getAccounts(token: token, dependencies: dependencies) { completion($0) } } @@ -99,7 +105,7 @@ public final class WealthsimpleAPI { completion(.failure(.tokenError(.noToken))) return } - WealthsimplePosition.getPositions(token: token, account: account, date: date) { + WealthsimplePosition.getPositions(token: token, account: account, date: date, dependencies: dependencies) { completion($0) } } @@ -114,14 +120,14 @@ public final class WealthsimpleAPI { completion(.failure(.tokenError(.noToken))) return } - WealthsimpleTransaction.getTransactions(token: token, account: account, startDate: startDate) { + WealthsimpleTransaction.getTransactions(token: token, account: account, startDate: startDate, dependencies: dependencies) { completion($0) } } private func getNewToken(completion: @escaping (Error?) -> Void) { authenticationCallback { username, password, otp in - Token.getToken(username: username, password: password, otp: otp, credentialStorage: self.credentialStorage) { + Token.getToken(username: username, password: password, otp: otp, credentialStorage: self.credentialStorage, dependencies: self.dependencies) { switch $0 { case let .failure(error): completion(error) diff --git a/Sources/WealthsimpleDownloader/WealthsimpleAccount.swift b/Sources/WealthsimpleDownloader/WealthsimpleAccount.swift index d64fc97e..44a42e64 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleAccount.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleAccount.swift @@ -70,7 +70,7 @@ public protocol Account { struct WealthsimpleAccount: Account { - private static var url: URL { URLConfiguration.shared.urlObject(for: "accounts")! } + private static let path = "accounts" let accountType: AccountType let currency: String @@ -94,15 +94,13 @@ struct WealthsimpleAccount: Account { self.number = number } - static func getAccounts(token: Token, completion: @escaping (Result<[Account], AccountError>) -> Void) { - var request = URLRequest(url: url) - let session = URLSession.shared + static func getAccounts(token: Token, dependencies: DownloaderDependencies = .live, completion: @escaping (Result<[Account], AccountError>) -> Void) { + var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.path)!) request.setValue("application/json", forHTTPHeaderField: "Content-Type") token.authenticateRequest(request) { request in - let task = session.dataTask(with: request) { data, response, error in + dependencies.httpClient.send(request, body: nil) { data, response, error in handleResponse(data: data, response: response, error: error, completion: completion) } - task.resume() } } diff --git a/Sources/WealthsimpleDownloader/WealthsimplePosition.swift b/Sources/WealthsimpleDownloader/WealthsimplePosition.swift index 1a140cbb..52cfdb89 100644 --- a/Sources/WealthsimpleDownloader/WealthsimplePosition.swift +++ b/Sources/WealthsimpleDownloader/WealthsimplePosition.swift @@ -48,7 +48,7 @@ public protocol Position { struct WealthsimplePosition: Position { - private static var baseUrl: URLComponents { URLConfiguration.shared.urlComponents(for: "positions")! } + private static let path = "positions" private static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() @@ -123,21 +123,33 @@ struct WealthsimplePosition: Position { self.asset = WealthsimpleAsset(currency: account.currency) } - static func getPositions(token: Token, account: Account, date: Date?, completion: @escaping (Result<[Position], PositionError>) -> Void) { + static func getPositions( + token: Token, + account: Account, + date: Date?, + dependencies: DownloaderDependencies = .live, + completion: @escaping (Result<[Position], PositionError>) -> Void + ) { if account.accountType == .creditCard { if date != nil { // Credit card positions do not support date parameter completion(.failure(.invalidRequestParameter(error: "Date parameter is not supported for credit card accounts"))) return } - getCreditCardPosition(token: token, account: account, completion: completion) + getCreditCardPosition(token: token, account: account, dependencies: dependencies, completion: completion) } else { - getRESTPositions(token: token, account: account, date: date, completion: completion) + getRESTPositions(token: token, account: account, date: date, dependencies: dependencies, completion: completion) } } - private static func getRESTPositions(token: Token, account: Account, date: Date?, completion: @escaping (Result<[Position], PositionError>) -> Void) { - var url = baseUrl + private static func getRESTPositions( + token: Token, + account: Account, + date: Date?, + dependencies: DownloaderDependencies, + completion: @escaping (Result<[Position], PositionError>) -> Void + ) { + var url = dependencies.configuration.urlComponents(for: Self.path)! url.queryItems = [ URLQueryItem(name: "account_id", value: account.id), URLQueryItem(name: "limit", value: "250") @@ -146,18 +158,21 @@ struct WealthsimplePosition: Position { url.queryItems?.append(URLQueryItem(name: "date", value: dateFormatter.string(from: date))) } var request = URLRequest(url: url.url!) - let session = URLSession.shared request.setValue("application/json", forHTTPHeaderField: "Content-Type") token.authenticateRequest(request) { request in - let task = session.dataTask(with: request) { data, response, error in + dependencies.httpClient.send(request, body: nil) { data, response, error in handleResponse(data: data, response: response, error: error, completion: completion) } - task.resume() } } - private static func getCreditCardPosition(token: Token, account: Account, completion: @escaping (Result<[Position], PositionError>) -> Void) { - guard var request = URLConfiguration.shared.graphQLURLRequest() else { + private static func getCreditCardPosition( + token: Token, + account: Account, + dependencies: DownloaderDependencies, + completion: @escaping (Result<[Position], PositionError>) -> Void + ) { + guard var request = dependencies.configuration.graphQLURLRequest() else { completion(.failure(PositionError.httpError(error: "Invalid GraphQL URL"))) return } @@ -171,12 +186,10 @@ struct WealthsimplePosition: Position { return } request.httpBody = jsonData - let session = URLSession.shared token.authenticateRequest(request) { request in - let task = session.dataTask(with: request) { data, response, error in + dependencies.httpClient.send(request, body: request.httpBody) { data, response, error in handleCreditCardResponse(data: data, response: response, error: error, account: account, completion: completion) } - task.resume() } } diff --git a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift index 3cfe3f23..4bd90588 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length // // Transaction.swift // @@ -14,7 +15,7 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod public typealias TransactionsCompletion = (Result<[Transaction], TransactionError>) -> Void - private static var baseUrl: URLComponents { URLConfiguration.shared.urlComponents(for: "transactions")! } + private static let path = "transactions" private static let graphQLQuery = """ query FetchActivityFeedItems($cursor: Cursor, $condition: ActivityCondition) { \ @@ -140,27 +141,35 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod self.processDate = processDate } - static func getTransactions(token: Token, account: Account, startDate: Date, completion: @escaping TransactionsCompletion) { + static func getTransactions(token: Token, account: Account, startDate: Date, dependencies: DownloaderDependencies = .live, completion: @escaping TransactionsCompletion) { // Call internal version with curser = nil. This prevents setting the curser from outside this class - getTransactions(token: token, account: account, startDate: startDate, cursor: nil, completion: completion) + getTransactions(token: token, account: account, startDate: startDate, dependencies: dependencies, cursor: nil, completion: completion) } - private static func getTransactions(token: Token, account: Account, startDate: Date, cursor: String? = nil, completion: @escaping TransactionsCompletion) { + private static func getTransactions( + token: Token, + account: Account, + startDate: Date, + dependencies: DownloaderDependencies, + cursor: String? = nil, + completion: @escaping TransactionsCompletion + ) { let endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date())! let isGraphQL = account.accountType == .creditCard do { guard isGraphQL || cursor == nil else { // Curser is only for GraphQL - throw TransactionError.invalidParameter + throw TransactionError.invalidParameter } - let request = isGraphQL ? try getTransactionsGraphQLRequest(accountID: account.id, startDate: startDate, endDate: endDate, cursor: cursor) : - getTransactionsRESTRequest(accountID: account.id, startDate: startDate, endDate: endDate) + let request = isGraphQL ? + try getTransactionsGraphQLRequest(accountID: account.id, startDate: startDate, endDate: endDate, cursor: cursor, dependencies: dependencies) + : getTransactionsRESTRequest(accountID: account.id, startDate: startDate, endDate: endDate, dependencies: dependencies) token.authenticateRequest(request) { request in - let task = URLSession.shared.dataTask(with: request) { data, response, error in + dependencies.httpClient.send(request, body: request.httpBody) { data, response, error in handleResponse(data: data, response: response, error: error) { switch $0 { case .success(let data): if isGraphQL { - processGraphQLTransactions(data: data, token: token, account: account, startDate: startDate, completion: completion) + processGraphQLTransactions(data: data, token: token, account: account, startDate: startDate, dependencies: dependencies) { completion($0) } } else { completion(parseREST(data: data)) } @@ -169,7 +178,6 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod } } } - task.resume() } } catch { completion(.failure(error as! TransactionError)) // swiftlint:disable:this force_cast @@ -177,7 +185,7 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod } } - private static func fxRequest(json: [[String: Any]]) throws -> URLRequest { + private static func fxRequest(json: [[String: Any]], dependencies: DownloaderDependencies) throws -> URLRequest { var queryPart1 = "query CreditCardActivity(", queryPart2 = "", variables = "" var index = 0 for result in json { @@ -194,14 +202,14 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod variables.removeLast(2) let query = queryPart1 + ") { " + queryPart2 + "} " + Self.graphQLQueryDetailsFragment let requestData: String = #"{"query": "\#(query)", "operationName": "\#(Self.graphQLOperationDetails)", "variables": { \#(variables) } }"# - guard var request = URLConfiguration.shared.graphQLURLRequest() else { + guard var request = dependencies.configuration.graphQLURLRequest() else { throw TransactionError.httpError(error: "Invalid URL") } request.httpBody = Data(requestData.utf8) return request } - private static func enrichWithFXInfo(edges: [[String: Any]], token: Token) throws -> [[String: Any]] { + private static func enrichWithFXInfo(edges: [[String: Any]], token: Token, dependencies: DownloaderDependencies) throws -> [[String: Any]] { var results = [[String: Any]]() // Invididual JSON Objects, without node wrapper for result in edges { guard let node = result["node"] as? [String: Any] else { @@ -210,13 +218,13 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod results.append(node) } - let request = try fxRequest(json: results) + let request = try fxRequest(json: results, dependencies: dependencies) var resultError: Error?, resultData: Data? let group = DispatchGroup() group.enter() token.authenticateRequest(request) { request in - let task = URLSession.shared.dataTask(with: request) { data, response, error in + dependencies.httpClient.send(request, body: request.httpBody) { data, response, error in handleResponse(data: data, response: response, error: error) { result in switch result { case .failure(let failure): @@ -227,7 +235,6 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod group.leave() } } - task.resume() } group.wait() @@ -260,12 +267,12 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return result } - private static func loadNextPage(cursor: String, token: Token, account: Account, startDate: Date) throws -> [Transaction] { + private static func loadNextPage(cursor: String, token: Token, account: Account, startDate: Date, dependencies: DownloaderDependencies) throws -> [Transaction] { var nextResult: Result<[Transaction], TransactionError>! let group = DispatchGroup() group.enter() DispatchQueue.global(qos: .userInitiated).async { - getTransactions(token: token, account: account, startDate: startDate, cursor: cursor) { + getTransactions(token: token, account: account, startDate: startDate, dependencies: dependencies, cursor: cursor) { nextResult = $0 group.leave() } @@ -281,7 +288,15 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod } } - private static func processGraphQLTransactions(data: Data, token: Token, account: Account, startDate: Date, completion: TransactionsCompletion) { + // swiftlint:disable:next function_parameter_count + private static func processGraphQLTransactions( + data: Data, + token: Token, + account: Account, + startDate: Date, + dependencies: DownloaderDependencies, + completion: TransactionsCompletion + ) { do { let json = try parseGraphQL(data: data) guard let page = json["pageInfo"] as? [String: Any], let edges = json["edges"] as? [[String: Any]], @@ -290,14 +305,14 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod throw TransactionError.invalidResultParameter(json: json) } - let transactionInfo = try enrichWithFXInfo(edges: edges, token: token) + let transactionInfo = try enrichWithFXInfo(edges: edges, token: token, dependencies: dependencies) var transactions = [Transaction]() for transaction in transactionInfo { transactions.append(try Self(graphQL: transaction)) } if hasNextPage { - let nextTransactions = try loadNextPage(cursor: cursor, token: token, account: account, startDate: startDate) + let nextTransactions = try loadNextPage(cursor: cursor, token: token, account: account, startDate: startDate, dependencies: dependencies) transactions.append(contentsOf: nextTransactions) } completion(.success(transactions)) @@ -307,8 +322,14 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod } } - private static func getTransactionsGraphQLRequest(accountID: String, startDate: Date, endDate: Date, cursor: String?) throws -> URLRequest { - guard var request = URLConfiguration.shared.graphQLURLRequest() else { + private static func getTransactionsGraphQLRequest( + accountID: String, + startDate: Date, + endDate: Date, + cursor: String?, + dependencies: DownloaderDependencies + ) throws -> URLRequest { + guard var request = dependencies.configuration.graphQLURLRequest() else { throw TransactionError.httpError(error: "Invalid URL") } let startDateString = dateFormatterGraphQLRequest.string(from: startDate) @@ -320,8 +341,8 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return request } - private static func getTransactionsRESTRequest(accountID: String, startDate: Date, endDate: Date) -> URLRequest { - var url = baseUrl + private static func getTransactionsRESTRequest(accountID: String, startDate: Date, endDate: Date, dependencies: DownloaderDependencies) -> URLRequest { + var url = dependencies.configuration.urlComponents(for: Self.path)! url.queryItems = [ URLQueryItem(name: "account_id", value: accountID), URLQueryItem(name: "limit", value: "250"), diff --git a/Tests/WealthsimpleDownloaderTests/CreditCardPositionTests.swift b/Tests/WealthsimpleDownloaderTests/CreditCardPositionTests.swift index 8b7c0842..9a43bbe3 100644 --- a/Tests/WealthsimpleDownloaderTests/CreditCardPositionTests.swift +++ b/Tests/WealthsimpleDownloaderTests/CreditCardPositionTests.swift @@ -9,9 +9,10 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class CreditCardPositionTests: DownloaderTestCase { private static let creditCardAccount = MockAccount( @@ -24,10 +25,10 @@ final class CreditCardPositionTests: DownloaderTestCase { // MARK: - Helper Methods private func createValidToken() throws -> Token { - let expectation = XCTestExpectation(description: "createValidToken completion") + let expectation = DispatchSemaphore(value: 0) var resultToken: Token? - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -36,21 +37,21 @@ final class CreditCardPositionTests: DownloaderTestCase { mockCredentialStorage.storage["refreshToken"] = "valid_refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in resultToken = token - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) - return try XCTUnwrap(resultToken) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + return try #require(resultToken) } - private func setupMockForSuccess(balance: String, expectation: XCTestExpectation) { - MockURLProtocol.graphQLRequestHandler = { url, request in - XCTAssertEqual(request.httpMethod, "POST") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token3") - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - expectation.fulfill() + private func setupMockForSuccess(balance: String, expectation: DispatchSemaphore) { + mockHTTPClient.graphQLRequestHandler = { url, request in + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token3") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + expectation.signal() let responseJSON: [String: Any] = [ "data": [ "creditCardAccount": [ @@ -66,74 +67,75 @@ final class CreditCardPositionTests: DownloaderTestCase { } private func assertCreditCardPosition(_ position: Position, balance: String) { - XCTAssertEqual(position.accountId, Self.creditCardAccount.id) - XCTAssertEqual(position.quantity, balance) - XCTAssertEqual(position.priceAmount, "1") - XCTAssertEqual(position.priceCurrency, "CAD") - XCTAssertEqual(position.asset.symbol, "CAD") - XCTAssertEqual(position.asset.name, "CAD") - XCTAssertEqual(position.asset.currency, "CAD") - XCTAssertEqual(position.asset.type, .currency) + #expect(position.accountId == Self.creditCardAccount.id) + #expect(position.quantity == balance) + #expect(position.priceAmount == "1") + #expect(position.priceCurrency == "CAD") + #expect(position.asset.symbol == "CAD") + #expect(position.asset.name == "CAD") + #expect(position.asset.currency == "CAD") + #expect(position.asset.type == .currency) } private func testCreditCardFailure( handler: @escaping (URL, URLRequest) throws -> (URLResponse, Data), - validate: @escaping (PositionError) -> Void, - file: StaticString = #file, - line: UInt = #line + validate: @escaping (PositionError) -> Void ) throws { - let expectation = XCTestExpectation(description: "getPositions completion") - MockURLProtocol.graphQLRequestHandler = handler - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil) { result in + let expectation = DispatchSemaphore(value: 0) + mockHTTPClient.graphQLRequestHandler = handler + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil, dependencies: dependencies) { result in switch result { case .success: - XCTFail("Expected failure", file: file, line: line) + Issue.record("Expected failure") case .failure(let error): validate(error) } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Successful Tests - func testGetCreditCardPositionSuccess() throws { - let expectation = XCTestExpectation(description: "getPositions completion") - let mockExpectation = XCTestExpectation(description: "mock GraphQL server called") + @Test + func getCreditCardPositionSuccess() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupMockForSuccess(balance: "1234.56", expectation: mockExpectation) - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil, dependencies: dependencies) { result in switch result { case .success(let positions): - XCTAssertEqual(positions.count, 1) + #expect(positions.count == 1) self.assertCreditCardPosition(positions[0], balance: "-1234.56") case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetCreditCardPositionVerifiesRequestBody() throws { - let expectation = XCTestExpectation(description: "getPositions completion") - let mockExpectation = XCTestExpectation(description: "mock GraphQL server called") + @Test + func getCreditCardPositionVerifiesRequestBody() throws { + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.graphQLRequestHandler = { url, request in + let mockExpectation = DispatchSemaphore(value: 0) + mockHTTPClient.graphQLRequestHandler = { url, request in #if canImport(FoundationNetworking) // body seems to be missing? #else let inputData = try Data(reading: request.httpBodyStream!) let json = try JSONSerialization.jsonObject(with: inputData, options: []) as? [String: Any] - XCTAssertEqual(json?["operationName"] as? String, "FetchCreditCardAccountSummary") + #expect(json?["operationName"] as? String == "FetchCreditCardAccountSummary") let variables = json?["variables"] as? [String: Any] - XCTAssertEqual(variables?["id"] as? String, Self.creditCardAccount.id) - XCTAssertNotNil(json?["query"] as? String) + #expect(variables?["id"] as? String == Self.creditCardAccount.id) + #expect(json?["query"] is String) #endif - mockExpectation.fulfill() + mockExpectation.signal() let creditCardData: [String: Any] = [ "id": Self.creditCardAccount.id, "balance": ["current": "42.00", "__typename": "Balance"], @@ -146,55 +148,62 @@ final class CreditCardPositionTests: DownloaderTestCase { try JSONSerialization.data(withJSONObject: responseJSON, options: [])) } - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil) { _ in - expectation.fulfill() + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: nil, dependencies: dependencies) { _ in + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Failure Tests - func testGetCreditCardPositionNetworkError() throws { + @Test + func getCreditCardPositionNetworkError() throws { try testCreditCardFailure( handler: { _, _ in throw URLError(.networkConnectionLost) }, validate: { guard case .httpError = $0 else { - return XCTFail("Expected httpError but got \($0)") + Issue.record("Expected httpError but got \($0)") + return } } ) } - func testGetCreditCardPositionHTTPError() throws { + @Test + func getCreditCardPositionHTTPError() throws { try testCreditCardFailure( handler: { url, _ in (HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) }, - validate: { XCTAssertEqual($0, PositionError.httpError(error: "Status code 500")) } + validate: { #expect($0 == PositionError.httpError(error: "Status code 500")) } ) } - func testGetCreditCardPositionWrongResponseType() throws { + @Test + func getCreditCardPositionWrongResponseType() throws { try testCreditCardFailure( handler: { url, _ in (URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data("test".utf8)) }, - validate: { XCTAssertEqual($0, PositionError.httpError(error: "No HTTPURLResponse")) } + validate: { #expect($0 == PositionError.httpError(error: "No HTTPURLResponse")) } ) } - func testGetCreditCardPositionInvalidJSON() throws { + @Test + func getCreditCardPositionInvalidJSON() throws { let data = Data("NOT VALID JSON".utf8) try testCreditCardFailure( handler: { url, _ in (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, data) }, - validate: { XCTAssertEqual($0, PositionError.invalidJson(json: data)) } + validate: { #expect($0 == PositionError.invalidJson(json: data)) } ) } - func testGetCreditCardPositionMissingData() throws { + @Test + func getCreditCardPositionMissingData() throws { try testCreditCardFailure( handler: { url, _ in let responseJSON: [String: Any] = ["errors": []] @@ -203,33 +212,37 @@ final class CreditCardPositionTests: DownloaderTestCase { }, validate: { guard case .missingResultParamenter = $0 else { - return XCTFail("Expected missingResultParamenter but got \($0)") + Issue.record("Expected missingResultParamenter but got \($0)") + return } } ) } - func testGetCreditCardPositionDate() throws { - let expectation = XCTestExpectation(description: "getPositions completion") + @Test + func getCreditCardPositionDate() throws { + let expectation = DispatchSemaphore(value: 0) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let testDate = dateFormatter.date(from: "2023-12-01")! - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: testDate) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.creditCardAccount, date: testDate, dependencies: dependencies) { result in switch result { case .success: - return XCTFail("Expected failure") + Issue.record("Expected failure") + return case .failure(let error): - XCTAssertEqual(error, PositionError.invalidRequestParameter(error: "Date parameter is not supported for credit card accounts")) + #expect(error == PositionError.invalidRequestParameter(error: "Date parameter is not supported for credit card accounts")) } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGetCreditCardPositionMissingBalance() throws { + @Test + func getCreditCardPositionMissingBalance() throws { try testCreditCardFailure( handler: { url, _ in let responseJSON: [String: Any] = [ @@ -242,18 +255,20 @@ final class CreditCardPositionTests: DownloaderTestCase { }, validate: { guard case .missingResultParamenter = $0 else { - return XCTFail("Expected missingResultParamenter but got \($0)") + Issue.record("Expected missingResultParamenter but got \($0)") + return } } ) } - func testGetCreditCardPositionEmptyData() throws { + @Test + func getCreditCardPositionEmptyData() throws { try testCreditCardFailure( handler: { url, _ in (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data()) }, - validate: { XCTAssertEqual($0, PositionError.invalidJson(json: Data())) } + validate: { #expect($0 == PositionError.invalidJson(json: Data())) } ) } diff --git a/Tests/WealthsimpleDownloaderTests/Extensions/XCTestCase.swift b/Tests/WealthsimpleDownloaderTests/Extensions/XCTestCase.swift deleted file mode 100644 index a4c7a0bb..00000000 --- a/Tests/WealthsimpleDownloaderTests/Extensions/XCTestCase.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import XCTest - -extension XCTestCase { - - func assert(_ expression: @autoclosure () throws -> T, throws expectedError: E, in file: StaticString = #file, line: UInt = #line) { - var caughtError: Error? - - XCTAssertThrowsError(try expression(), file: file, line: line) { - caughtError = $0 - } - - guard let error = caughtError as? E else { - XCTFail("Unexpected error type, got \(type(of: caughtError!)) instead of \(E.self)", file: file, line: line) - return - } - - XCTAssertEqual(error, expectedError, file: file, line: line) - } - -} diff --git a/Tests/WealthsimpleDownloaderTests/Helpers/DownloaderTestCase.swift b/Tests/WealthsimpleDownloaderTests/Helpers/DownloaderTestCase.swift index d348e5c9..244254f9 100644 --- a/Tests/WealthsimpleDownloaderTests/Helpers/DownloaderTestCase.swift +++ b/Tests/WealthsimpleDownloaderTests/Helpers/DownloaderTestCase.swift @@ -9,22 +9,19 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest -class DownloaderTestCase: XCTestCase { // swiftlint:disable:this final_test_case +class DownloaderTestCase { + let mockCredentialStorage = MockCredentialStorage() + let mockHTTPClient = MockHTTPClient() + let dependencies: DownloaderDependencies - var mockCredentialStorage: MockCredentialStorage! // swiftlint:disable:this test_case_accessibility - - override func setUp() { - super.setUp() - mockCredentialStorage = MockCredentialStorage() - MockURLProtocol.setup() - } - - override func tearDown() { - MockURLProtocol.reset() - super.tearDown() + init() { + let configuration = URLConfiguration( + baseURL: "http://localhost:8080/v1/", + graphQLURL: "http://localhost:8080/graphql" + ) + dependencies = DownloaderDependencies(httpClient: mockHTTPClient, configuration: configuration) } - } diff --git a/Tests/WealthsimpleDownloaderTests/Helpers/MockHTTPClient.swift b/Tests/WealthsimpleDownloaderTests/Helpers/MockHTTPClient.swift new file mode 100644 index 00000000..b8d9097f --- /dev/null +++ b/Tests/WealthsimpleDownloaderTests/Helpers/MockHTTPClient.swift @@ -0,0 +1,96 @@ +// +// MockHTTPClient.swift +// +// +// Created by Steffen Kötte on 2025-08-31. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import WealthsimpleDownloader + +/// An instance-scoped HTTP client for downloader tests. +final class MockHTTPClient: HTTPClient { + var newTokenRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + var tokenValidationRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + var accountsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + var transactionsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + var positionsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + var graphQLRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest + + // https://github.com/realm/SwiftLint/issues/6491 + // swiftlint:disable:next unneeded_throws_rethrows + static func failTest(url: URL, _: URLRequest) throws -> (HTTPURLResponse, Data) { + Issue.record("Call network request which should not have been called") + let response = HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + + func send(_ request: URLRequest, body: Data?, completion: (Data?, URLResponse?, Error?) -> Void) { + var request = request + request.httpBody = body + if let body { + request.httpBodyStream = InputStream(data: body) + } + do { + let (response, data) = try handleRequest(request) + completion(data, response, nil) + } catch { + completion(nil, nil, error) + } + } + + private func handleRequest(_ request: URLRequest) throws -> (URLResponse, Data) { + guard let url = request.url else { + throw URLError(.badURL) + } + if url.path.contains("/oauth/v2/token") && request.httpMethod == "POST" { + return try newTokenRequestHandler(url, request) + } + if url.path.contains("/oauth/v2/token/info") && request.httpMethod == "GET" { + return try tokenValidationRequestHandler(url, request) + } + if url.path.contains("/accounts") && request.httpMethod == "GET" { + return try accountsRequestHandler(url, request) + } + if url.path.contains("/transactions") && request.httpMethod == "GET" { + return try transactionsRequestHandler(url, request) + } + if url.path.contains("/positions") && request.httpMethod == "GET" { + return try positionsRequestHandler(url, request) + } + if url.path.contains("/graphql") && request.httpMethod == "POST" { + return try graphQLRequestHandler(url, request) + } + Issue.record("Unexpected request: \(url)") + throw URLError(.unsupportedURL) + } +} + +extension Data { + init(reading input: InputStream) throws { + self.init() + input.open() + defer { + input.close() + } + let bufferSize = 4_096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + } + while input.hasBytesAvailable { + let read = input.read(buffer, maxLength: bufferSize) + if read < 0 { + throw input.streamError! + } + if read == 0 { + break + } + self.append(buffer, count: read) + } + } +} diff --git a/Tests/WealthsimpleDownloaderTests/Helpers/MockURLProtocol.swift b/Tests/WealthsimpleDownloaderTests/Helpers/MockURLProtocol.swift deleted file mode 100644 index be7ee361..00000000 --- a/Tests/WealthsimpleDownloaderTests/Helpers/MockURLProtocol.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// MockURLProtocol.swift -// -// -// Created by Steffen Kötte on 2025-08-31. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -@testable import WealthsimpleDownloader -import XCTest - -/// A mock URLProtocol implementation for intercepting HTTP requests during testing. -class MockURLProtocol: URLProtocol { - static var newTokenRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - static var tokenValidationRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - static var accountsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - static var transactionsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - static var positionsRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - static var graphQLRequestHandler: ((URL, URLRequest) throws -> (URLResponse, Data)) = failTest - - // MARK: - Static Methods - - override class func canInit(with request: URLRequest) -> Bool { - // Only handle requests to localhost - request.url?.host == "localhost" - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - static func setup() { - URLConfiguration.shared.setBaseURL("http://localhost:8080/v1/") - URLConfiguration.shared.setGraphQLURL("http://localhost:8080/graphql") - _ = URLProtocol.registerClass(Self.self) - } - - static func reset() { - URLConfiguration.shared.reset() - newTokenRequestHandler = failTest - tokenValidationRequestHandler = failTest - accountsRequestHandler = failTest - transactionsRequestHandler = failTest - positionsRequestHandler = failTest - graphQLRequestHandler = failTest - URLProtocol.unregisterClass(Self.self) - } - - private static func handleMockRequest(_ request: URLRequest) throws -> (URLResponse, Data) { - guard let url = request.url else { - throw URLError(.badURL) - } - - if url.path.contains("/oauth/v2/token") && request.httpMethod == "POST" { - return try newTokenRequestHandler(url, request) - } - if url.path.contains("/oauth/v2/token/info") && request.httpMethod == "GET" { - return try tokenValidationRequestHandler(url, request) - } - if url.path.contains("/accounts") && request.httpMethod == "GET" { - return try accountsRequestHandler(url, request) - } - if url.path.contains("/transactions") && request.httpMethod == "GET" { - return try transactionsRequestHandler(url, request) - } - if url.path.contains("/positions") && request.httpMethod == "GET" { - return try positionsRequestHandler(url, request) - } - if url.path.contains("/graphql") && request.httpMethod == "POST" { - return try graphQLRequestHandler(url, request) - } - - XCTFail("Unexpected request: \(url)") - throw URLError(.unsupportedURL) - } - - // https://github.com/realm/SwiftLint/issues/6491 - // swiftlint:disable:next unneeded_throws_rethrows - static func failTest(url: URL, _: URLRequest) throws -> (HTTPURLResponse, Data) { - XCTFail("Call network request which should not have been called") - let response = HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil)! - return (response, Data()) - } - - // MARK: - Instance Methods - - override func startLoading() { - do { - let (response, data) = try Self.handleMockRequest(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() { - // No-op - } -} - -extension Data { - init(reading input: InputStream) throws { - self.init() - input.open() - defer { - input.close() - } - - let bufferSize = 4_096 - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) - defer { - buffer.deallocate() - } - while input.hasBytesAvailable { - let read = input.read(buffer, maxLength: bufferSize) - if read < 0 { - throw input.streamError! - } - if read == 0 { - break - } - self.append(buffer, count: read) - } - } -} diff --git a/Tests/WealthsimpleDownloaderTests/TokenTests.swift b/Tests/WealthsimpleDownloaderTests/TokenTests.swift index a320b01f..77afbfe4 100644 --- a/Tests/WealthsimpleDownloaderTests/TokenTests.swift +++ b/Tests/WealthsimpleDownloaderTests/TokenTests.swift @@ -9,17 +9,19 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body_length // MARK: - getToken with Credential Storage - func testGetTokenFromCredentialStorageWithValidToken() { - let expectation = XCTestExpectation(description: "getToken completion") + @Test + func getTokenFromCredentialStorageWithValidToken() { + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -30,77 +32,82 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) // This will just locally check expiry and do not do any network calls - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNotNil(token) - expectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token != nil) + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTokenFromCredentialStorageWithMissingAccessToken() { - let expectation = XCTestExpectation(description: "getToken completion") + @Test + func getTokenFromCredentialStorageWithMissingAccessToken() { + let expectation = DispatchSemaphore(value: 0) mockCredentialStorage.storage["refreshToken"] = "test_refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - expectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + expectation.signal() } - wait(for: [expectation], timeout: 1.0) + #expect(expectation.wait(timeout: .now() + 1.0) == .success) } - func testGetTokenFromCredentialStorageWithMissingRefreshToken() { - let expectation = XCTestExpectation(description: "getToken completion") + @Test + func getTokenFromCredentialStorageWithMissingRefreshToken() { + let expectation = DispatchSemaphore(value: 0) mockCredentialStorage.storage["accessToken"] = "test_access_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - expectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + expectation.signal() } - wait(for: [expectation], timeout: 1.0) + #expect(expectation.wait(timeout: .now() + 1.0) == .success) } - func testGetTokenFromCredentialStorageWithMissingExpiry() { - let expectation = XCTestExpectation(description: "getToken completion") + @Test + func getTokenFromCredentialStorageWithMissingExpiry() { + let expectation = DispatchSemaphore(value: 0) mockCredentialStorage.storage["accessToken"] = "test_access_token" mockCredentialStorage.storage["refreshToken"] = "test_refresh_token" - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - expectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + expectation.signal() } - wait(for: [expectation], timeout: 1.0) + #expect(expectation.wait(timeout: .now() + 1.0) == .success) } - func testGetTokenFromCredentialStorageWithInvalidExpiry() { - let expectation = XCTestExpectation(description: "getToken completion") + @Test + func getTokenFromCredentialStorageWithInvalidExpiry() { + let expectation = DispatchSemaphore(value: 0) mockCredentialStorage.storage["accessToken"] = "test_access_token" mockCredentialStorage.storage["refreshToken"] = "test_refresh_token" mockCredentialStorage.storage["expiry"] = "invalid_date" - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - expectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + expectation.signal() } - wait(for: [expectation], timeout: 1.0) + #expect(expectation.wait(timeout: .now() + 1.0) == .success) } - func testExpiredTokenFailsRefresh() { - let requestExpectation = XCTestExpectation(description: "mock server called") - let getTokenExpectation = XCTestExpectation(description: "getToken completion") + @Test + func expiredTokenFailsRefresh() { + let requestExpectation = DispatchSemaphore(value: 0) + let getTokenExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.newTokenRequestHandler = { url, _ in - requestExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + requestExpectation.signal() let response = HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -109,28 +116,30 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["refreshToken"] = "refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - getTokenExpectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + getTokenExpectation.signal() } - wait(for: [getTokenExpectation, requestExpectation], timeout: 10.0) + #expect(getTokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(requestExpectation.wait(timeout: .now() + 10.0) == .success) } - func testExpiredTokenRefreshFailsValidation() { - let refreshExpectation = XCTestExpectation(description: "mock server called") - let validateExpectation = XCTestExpectation(description: "mock server called") - let getTokenExpectation = XCTestExpectation(description: "getToken completion") + @Test + func expiredTokenRefreshFailsValidation() { + let refreshExpectation = DispatchSemaphore(value: 0) + let validateExpectation = DispatchSemaphore(value: 0) + let getTokenExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.newTokenRequestHandler = { url, _ in - refreshExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + refreshExpectation.signal() let jsonResponse = [ "access_token": "atoken12345", "refresh_token": "rtoken67890", "expires_in": 3_600, "created_at": Int(Date().timeIntervalSince1970), "token_type": "Bearer" ] return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } - MockURLProtocol.tokenValidationRequestHandler = { _, _ in - validateExpectation.fulfill() + mockHTTPClient.tokenValidationRequestHandler = { _, _ in + validateExpectation.signal() throw URLError(.networkConnectionLost) } @@ -138,28 +147,31 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["refreshToken"] = "refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - getTokenExpectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + getTokenExpectation.signal() } - wait(for: [getTokenExpectation, refreshExpectation, validateExpectation], timeout: 10.0) + #expect(getTokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(refreshExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(validateExpectation.wait(timeout: .now() + 10.0) == .success) } - func testExpiredTokenRefreshFailsValidationWithWrongResponseType() { - let refreshExpectation = XCTestExpectation(description: "mock server called for token refresh") - let validateExpectation = XCTestExpectation(description: "mock server called for token validation") - let getTokenExpectation = XCTestExpectation(description: "getToken completion") + @Test + func expiredTokenRefreshFailsValidationWithWrongResponseType() { + let refreshExpectation = DispatchSemaphore(value: 0) + let validateExpectation = DispatchSemaphore(value: 0) + let getTokenExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.newTokenRequestHandler = { url, _ in - refreshExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + refreshExpectation.signal() let jsonResponse = [ "access_token": "atoken12345", "refresh_token": "rtoken67890", "expires_in": 3_600, "created_at": Int(Date().timeIntervalSince1970), "token_type": "Bearer" ] return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } - MockURLProtocol.tokenValidationRequestHandler = { url, _ in - validateExpectation.fulfill() + mockHTTPClient.tokenValidationRequestHandler = { url, _ in + validateExpectation.signal() return (URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data()) } @@ -167,36 +179,40 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["refreshToken"] = "refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - getTokenExpectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + getTokenExpectation.signal() } - wait(for: [getTokenExpectation, refreshExpectation, validateExpectation], timeout: 10.0) + #expect(getTokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(refreshExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(validateExpectation.wait(timeout: .now() + 10.0) == .success) } - func testExpiredTokenRefresh() { - let refreshExpectation = XCTestExpectation(description: "refresh called"), validateExpectation = XCTestExpectation(description: "validate called") - let getTokenExpectation = XCTestExpectation(description: "getToken completion") + @Test + func expiredTokenRefresh() { // swiftlint:disable:this function_body_length + let refreshExpectation = DispatchSemaphore(value: 0) + let validateExpectation = DispatchSemaphore(value: 0) + let getTokenExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.newTokenRequestHandler = { url, request in + mockHTTPClient.newTokenRequestHandler = { url, request in #if canImport(FoundationNetworking) // body seems to be missing? #else // get JSON from POST request body stream let inputData = try Data(reading: request.httpBodyStream!), json = try JSONSerialization.jsonObject(with: inputData, options: []) as? [String: Any] - XCTAssertEqual(json?["grant_type"] as? String, "refresh_token") - XCTAssertEqual(json?["refresh_token"] as? String, "refresh_token_234") - XCTAssertEqual(json?["client_id"] as? String, "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa") + #expect(json?["grant_type"] as? String == "refresh_token") + #expect(json?["refresh_token"] as? String == "refresh_token_234") + #expect(json?["client_id"] as? String == "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa") #endif - refreshExpectation.fulfill() + refreshExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: [ "access_token": "a34324532", "refresh_token": "r432432", "expires_in": 3_600, "created_at": Int(Date().timeIntervalSince1970), "token_type": "Bearer" ], options: [])) } - MockURLProtocol.tokenValidationRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer a34324532") - validateExpectation.fulfill() + mockHTTPClient.tokenValidationRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer a34324532") + validateExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data()) } @@ -204,23 +220,26 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["refreshToken"] = "refresh_token_234" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNotNil(token) - getTokenExpectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token != nil) + getTokenExpectation.signal() } - wait(for: [getTokenExpectation, refreshExpectation, validateExpectation], timeout: 10.0) + #expect(getTokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(refreshExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(validateExpectation.wait(timeout: .now() + 10.0) == .success) - XCTAssertEqual(mockCredentialStorage.read("accessToken"), "a34324532") - XCTAssertEqual(mockCredentialStorage.read("refreshToken"), "r432432") + #expect(mockCredentialStorage.read("accessToken") == "a34324532") + #expect(mockCredentialStorage.read("refreshToken") == "r432432") } - func testTokenInitMissingParameter() { - let refreshExpectation = XCTestExpectation(description: "mock server called") - let getTokenExpectation = XCTestExpectation(description: "getToken completion") + @Test + func tokenInitMissingParameter() { + let refreshExpectation = DispatchSemaphore(value: 0) + let getTokenExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.newTokenRequestHandler = { url, _ in - refreshExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + refreshExpectation.signal() let jsonResponse = [ "refresh_token": "rtoken67890", "expires_in": 3_600, "created_at": Int(Date().timeIntervalSince1970), "token_type": "Bearer" ] @@ -230,62 +249,65 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body mockCredentialStorage.storage["refreshToken"] = "refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in - XCTAssertNil(token) - getTokenExpectation.fulfill() + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in + #expect(token == nil) + getTokenExpectation.signal() } - wait(for: [getTokenExpectation, refreshExpectation], timeout: 10.0) + #expect(getTokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(refreshExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - getToken with Username/Password/OTP - func testGetTokenWithUsernamePasswordOTPSuccess() { - let tokenExpectation = XCTestExpectation(description: "getToken completion"), mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTokenWithUsernamePasswordOTPSuccess() { - MockURLProtocol.newTokenRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "x-wealthsimple-otp"), "123456") + let tokenExpectation = DispatchSemaphore(value: 0), mockExpectation = DispatchSemaphore(value: 0) + mockHTTPClient.newTokenRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "x-wealthsimple-otp") == "123456") #if canImport(FoundationNetworking) // body seems to be missing? #else // get JSON from POST request body stream let inputData = try Data(reading: request.httpBodyStream!), json = try JSONSerialization.jsonObject(with: inputData, options: []) as? [String: Any] - XCTAssertEqual(json?["username"] as? String, "test@example.com") - XCTAssertEqual(json?["password"] as? String, "password1") - XCTAssertEqual(json?["grant_type"] as? String, "password") - XCTAssertEqual(json?["client_id"] as? String, "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa") - XCTAssertEqual(json?["scope"] as? String, "read") + #expect(json?["username"] as? String == "test@example.com") + #expect(json?["password"] as? String == "password1") + #expect(json?["grant_type"] as? String == "password") + #expect(json?["client_id"] as? String == "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa") + #expect(json?["scope"] as? String == "read") #endif let jsonResponse = [ "access_token": "atoken12345", "refresh_token": "rtoken67890", "expires_in": 3_600, "created_at": Int(Date().timeIntervalSince1970), "token_type": "Bearer" ] - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } - Token.getToken(username: "test@example.com", password: "password1", otp: "123456", credentialStorage: mockCredentialStorage) { result in - if case .success(let token) = result { - XCTAssertNotNil(token) + Token.getToken(username: "test@example.com", password: "password1", otp: "123456", credentialStorage: mockCredentialStorage, dependencies: dependencies) { result in + if case .success = result { // Verify token was saved to credential storage - XCTAssertEqual(self.mockCredentialStorage.read("accessToken"), "atoken12345") - XCTAssertEqual(self.mockCredentialStorage.read("refreshToken"), "rtoken67890") - XCTAssertNotNil(self.mockCredentialStorage.read("expiry")) - tokenExpectation.fulfill() + #expect(self.mockCredentialStorage.read("accessToken") == "atoken12345") + #expect(self.mockCredentialStorage.read("refreshToken") == "rtoken67890") + #expect(self.mockCredentialStorage.read("expiry") != nil) + tokenExpectation.signal() } else { - XCTFail("Expected success but got error") + Issue.record("Expected success but got error") } } - wait(for: [mockExpectation, tokenExpectation], timeout: 10.0) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(tokenExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTokenWithUsernamePasswordOTPNetworkFailure() { - let tokenExpectation = XCTestExpectation(description: "getToken completion") - let serverExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTokenWithUsernamePasswordOTPNetworkFailure() { + let tokenExpectation = DispatchSemaphore(value: 0) + let serverExpectation = DispatchSemaphore(value: 0) // Set up the mock to throw an error for this test - MockURLProtocol.newTokenRequestHandler = { _, _ in - serverExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { _, _ in + serverExpectation.signal() throw URLError(.networkConnectionLost) } @@ -293,27 +315,30 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body username: "test@example.com", password: "password", otp: "123456", - credentialStorage: mockCredentialStorage + credentialStorage: mockCredentialStorage, + dependencies: dependencies ) { result in switch result { case .success: - XCTFail("Expected failure due to network error") - case .failure(let error): - XCTAssertNotNil(error) + Issue.record("Expected failure due to network error") + case .failure: + break } - tokenExpectation.fulfill() + tokenExpectation.signal() } - wait(for: [tokenExpectation, serverExpectation], timeout: 10.0) + #expect(tokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(serverExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTokenWithWrongResponseType() { - let tokenExpectation = XCTestExpectation(description: "getToken completion") - let serverExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTokenWithWrongResponseType() { + let tokenExpectation = DispatchSemaphore(value: 0) + let serverExpectation = DispatchSemaphore(value: 0) // Set up the mock to return an URLResponse which is not a HTTPURLResponse for this test - MockURLProtocol.newTokenRequestHandler = { url, _ in - serverExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + serverExpectation.signal() return (URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data()) } @@ -321,27 +346,30 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body username: "test@example.com", password: "password", otp: "123456", - credentialStorage: mockCredentialStorage + credentialStorage: mockCredentialStorage, + dependencies: dependencies ) { result in switch result { case .success: - XCTFail("Expected failure due to wrong response type") - case .failure(let error): - XCTAssertNotNil(error) + Issue.record("Expected failure due to wrong response type") + case .failure: + break } - tokenExpectation.fulfill() + tokenExpectation.signal() } - wait(for: [tokenExpectation, serverExpectation], timeout: 10.0) + #expect(tokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(serverExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTokenWithInvalidJSON() { - let tokenExpectation = XCTestExpectation(description: "getToken completion") - let serverExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTokenWithInvalidJSON() { + let tokenExpectation = DispatchSemaphore(value: 0) + let serverExpectation = DispatchSemaphore(value: 0) // Set up the mock to throw return an URLResponse which is not a HTTPURLResponse for this test - MockURLProtocol.newTokenRequestHandler = { url, _ in - serverExpectation.fulfill() + mockHTTPClient.newTokenRequestHandler = { url, _ in + serverExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("NOT JSON".utf8)) } @@ -349,18 +377,20 @@ final class TokenTests: DownloaderTestCase { // swiftlint:disable:this type_body username: "test@example.com", password: "password", otp: "123456", - credentialStorage: mockCredentialStorage + credentialStorage: mockCredentialStorage, + dependencies: dependencies ) { result in switch result { case .success: - XCTFail("Expected failure due to wrong response type") - case .failure(let error): - XCTAssertNotNil(error) + Issue.record("Expected failure due to wrong response type") + case .failure: + break } - tokenExpectation.fulfill() + tokenExpectation.signal() } - wait(for: [tokenExpectation, serverExpectation], timeout: 10.0) + #expect(tokenExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(serverExpectation.wait(timeout: .now() + 10.0) == .success) } } diff --git a/Tests/WealthsimpleDownloaderTests/URLConfigurationTests.swift b/Tests/WealthsimpleDownloaderTests/URLConfigurationTests.swift index 99b29965..8893bab9 100644 --- a/Tests/WealthsimpleDownloaderTests/URLConfigurationTests.swift +++ b/Tests/WealthsimpleDownloaderTests/URLConfigurationTests.swift @@ -6,129 +6,98 @@ // import Foundation -@testable import WealthsimpleDownloader -import XCTest - -final class URLConfigurationTests: XCTestCase { +import Testing - override func setUp() { - super.setUp() - // Reset to default base URL before each test - URLConfiguration.shared.setBaseURL("https://api.production.wealthsimple.com/v1/") - URLConfiguration.shared.setGraphQLURL("https://my.wealthsimple.com/graphql") - } +@testable import WealthsimpleDownloader - func testDefaultBaseURL() { - let config = URLConfiguration.shared - XCTAssertEqual(config.base, "https://api.production.wealthsimple.com/v1/") - XCTAssertEqual(config.graphQL, "https://my.wealthsimple.com/graphql") +@Suite +final class URLConfigurationTests { + @Test + func defaultBaseURL() { + let config = URLConfiguration() + #expect(config.base == "https://api.production.wealthsimple.com/v1/") + #expect(config.graphQL == "https://my.wealthsimple.com/graphql") } - func testSetBaseURL() { - let config = URLConfiguration.shared + @Test + func customBaseURL() { let testURL = "https://test.example.com/api/v2/" - - config.setBaseURL(testURL) - - XCTAssertEqual(config.base, testURL) + let config = URLConfiguration(baseURL: testURL) + #expect(config.base == testURL) } - func testSetGraphQLURL() { - let config = URLConfiguration.shared + @Test + func customGraphQLURL() { let testURL = "https://test.example.com/graphql" + let config = URLConfiguration(graphQLURL: testURL) + #expect(config.graphQL == testURL) + } - config.setGraphQLURL(testURL) - - XCTAssertEqual(config.graphQL, testURL) + @Test + func bothURLsCustom() { + let testURL = "https://mock.server.test/v1/" + let testGraphQLURL = "https://mock.server.test/graphql" + let config = URLConfiguration(baseURL: testURL, graphQLURL: testGraphQLURL) + #expect(config.base == testURL) + #expect(config.graphQL == testGraphQLURL) } - func testURLForPath() { - let config = URLConfiguration.shared + @Test + func uRLForPath() { + let config = URLConfiguration() let result = config.url(for: "accounts") - - XCTAssertEqual(result, "https://api.production.wealthsimple.com/v1/accounts") + #expect(result == "https://api.production.wealthsimple.com/v1/accounts") } - func testURLForPathWithCustomBase() { - let config = URLConfiguration.shared - config.setBaseURL("https://test.example.com/api/v2/") - + @Test + func uRLForPathWithCustomBase() { + let config = URLConfiguration(baseURL: "https://test.example.com/api/v2/") let result = config.url(for: "positions") - - XCTAssertEqual(result, "https://test.example.com/api/v2/positions") + #expect(result == "https://test.example.com/api/v2/positions") } - func testURLObjectForPath() { - let config = URLConfiguration.shared + @Test + func uRLObjectForPath() { + let config = URLConfiguration() let result = config.urlObject(for: "transactions") - - XCTAssertNotNil(result) - XCTAssertEqual(result?.absoluteString, "https://api.production.wealthsimple.com/v1/transactions") + #expect(result != nil) + #expect(result?.absoluteString == "https://api.production.wealthsimple.com/v1/transactions") } - func testURLComponentsForPath() { - let config = URLConfiguration.shared + @Test + func uRLComponentsForPath() { + let config = URLConfiguration() let result = config.urlComponents(for: "oauth/v2/token") - - XCTAssertNotNil(result) - XCTAssertEqual(result?.string, "https://api.production.wealthsimple.com/v1/oauth/v2/token") - } - - func testSingletonPattern() { - let config1 = URLConfiguration.shared - let config2 = URLConfiguration.shared - - XCTAssertIdentical(config1, config2) - } - - func testConfigurationPersistsBetweenAccesses() { - let config = URLConfiguration.shared - let testURL = "https://mock.server.test/v1/" - - config.setBaseURL(testURL) - - // Access through different references - let newConfig = URLConfiguration.shared - XCTAssertEqual(newConfig.base, testURL) + #expect(result != nil) + #expect(result?.string == "https://api.production.wealthsimple.com/v1/oauth/v2/token") } - func testResetBaseURL() { - let config = URLConfiguration.shared - let testURL = "https://mock.server.test/v1/" - let testGraphQLURL = "https://mock.server.test/graphql" - - config.setBaseURL(testURL) - XCTAssertEqual(config.base, testURL) - - config.setGraphQLURL(testGraphQLURL) - XCTAssertEqual(config.graphQL, testGraphQLURL) - - config.reset() - XCTAssertEqual(config.base, "https://api.production.wealthsimple.com/v1/") - XCTAssertEqual(config.graphQL, "https://my.wealthsimple.com/graphql") + @Test + func configurationsAreIndependent() { + let config1 = URLConfiguration(baseURL: "https://mock.server.test/v1/") + let config2 = URLConfiguration() + #expect(config1 !== config2) + #expect(config1.base == "https://mock.server.test/v1/") + #expect(config2.base == "https://api.production.wealthsimple.com/v1/") } - func testGraphQLURLRequest() { - let config = URLConfiguration.shared + @Test + func graphQLURLRequest() { let testGraphQLURL = "https://mock.server.test/graphql" - config.setGraphQLURL(testGraphQLURL) - + let config = URLConfiguration(graphQLURL: testGraphQLURL) guard let request = config.graphQLURLRequest() else { - XCTFail("Expected valid URLRequest") + Issue.record("Expected valid URLRequest") return } - - XCTAssertEqual(request.url?.absoluteString, testGraphQLURL) - XCTAssertEqual(request.httpMethod, "POST") - XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + #expect(request.url?.absoluteString == testGraphQLURL) + #expect(request.httpMethod == "POST") + #expect(request.allHTTPHeaderFields?["Content-Type"] == "application/json") } - func testInvalidGraphQLURLRequest() { - let config = URLConfiguration.shared + @Test + func invalidGraphQLURLRequest() { let testGraphQLURL = "Not a valid URL::::////" - config.setGraphQLURL(testGraphQLURL) - - XCTAssertNil(config.graphQLURLRequest()) + let config = URLConfiguration(graphQLURL: testGraphQLURL) + #expect(config.graphQLURLRequest() == nil) } - } diff --git a/Tests/WealthsimpleDownloaderTests/WealthsimpleAccountTests.swift b/Tests/WealthsimpleDownloaderTests/WealthsimpleAccountTests.swift index 5279fbb7..27bb9136 100644 --- a/Tests/WealthsimpleDownloaderTests/WealthsimpleAccountTests.swift +++ b/Tests/WealthsimpleDownloaderTests/WealthsimpleAccountTests.swift @@ -9,18 +9,19 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class WealthsimpleAccountTests: DownloaderTestCase { // MARK: - Helper Methods private func createValidToken() throws -> Token { - let expectation = XCTestExpectation(description: "createValidToken completion") + let expectation = DispatchSemaphore(value: 0) var resultToken: Token? - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -29,98 +30,93 @@ final class WealthsimpleAccountTests: DownloaderTestCase { mockCredentialStorage.storage["refreshToken"] = "valid_refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in resultToken = token - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) guard let resultToken else { - XCTFail("Did not get valid token") + Issue.record("Did not get valid token") throw TokenError.noToken } return resultToken } - private func setupMockForSuccess(accounts: [[String: Any]], expectation: XCTestExpectation) { - MockURLProtocol.accountsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token1") + private func setupMockForSuccess(accounts: [[String: Any]], expectation: DispatchSemaphore) { + mockHTTPClient.accountsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token1") let jsonResponse = [ "object": "account", "results": accounts ] - expectation.fulfill() + expectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } } - private func testAccountsFailure(response: (URLResponse, Data), expectedError: AccountError, file: StaticString = #file, line: UInt = #line) throws { - let mockExpectation = XCTestExpectation(description: "mock server called") + private func testAccountsFailure(response: (URLResponse, Data), expectedError: AccountError) throws { + let mockExpectation = DispatchSemaphore(value: 0) try testAccountsFailure( response: { _, _ in - mockExpectation.fulfill() + mockExpectation.signal() return response }, - expectedError: expectedError, - file: file, - line: line + expectedError: expectedError ) - wait(for: [mockExpectation], timeout: 10.0) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } private func testAccountsFailure( response: @escaping ((URL, URLRequest) throws -> (URLResponse, Data)), - expectedError: AccountError, - file: StaticString = #file, - line: UInt = #line + expectedError: AccountError ) throws { - let expectation = XCTestExpectation(description: "getAccounts completion") + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.accountsRequestHandler = response + mockHTTPClient.accountsRequestHandler = response - WealthsimpleAccount.getAccounts(token: try createValidToken()) { result in + WealthsimpleAccount.getAccounts(token: try createValidToken(), dependencies: dependencies) { result in switch result { case .success: - XCTFail("Expected failure", file: file, line: line) + Issue.record("Expected failure") case .failure(let error): - XCTAssertEqual(error, expectedError, file: file, line: line) + #expect(error == expectedError) } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - private func testJSONParsingFailure(jsonData: Data, expectedError: AccountError, file: StaticString = #file, line: UInt = #line) throws { + private func testJSONParsingFailure(jsonData: Data, expectedError: AccountError) throws { try testAccountsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, jsonData ), - expectedError: expectedError, - file: file, - line: line) + expectedError: expectedError) } - private func testJSONParsingFailure(jsonObject: [String: Any], expectedError: AccountError, file: StaticString = #file, line: UInt = #line) throws { + private func testJSONParsingFailure(jsonObject: [String: Any], expectedError: AccountError) throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject, options: []) else { - XCTFail("Failed to create JSON data", file: file, line: line) + Issue.record("Failed to create JSON data") return } - try testJSONParsingFailure(jsonData: jsonData, expectedError: expectedError, file: file, line: line) + try testJSONParsingFailure(jsonData: jsonData, expectedError: expectedError) } // MARK: - Successful getAccounts Tests - func testGetAccountsSuccess() throws { - let expectation = XCTestExpectation(description: "getAccounts completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getAccountsSuccess() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupMockForSuccess(accounts: [ ["id": "account-123", "type": "ca_tfsa", "object": "account", "base_currency": "CAD", "custodian_account_number": "12345-67890"], @@ -129,55 +125,59 @@ final class WealthsimpleAccountTests: DownloaderTestCase { expectation: mockExpectation ) - WealthsimpleAccount.getAccounts(token: try createValidToken()) { result in + WealthsimpleAccount.getAccounts(token: try createValidToken(), dependencies: dependencies) { result in switch result { case .success(let accounts): - XCTAssertEqual(accounts.count, 2) + #expect(accounts.count == 2) let firstAccount = accounts[0] - XCTAssertEqual(firstAccount.id, "account-123") - XCTAssertEqual(firstAccount.accountType, .tfsa) - XCTAssertEqual(firstAccount.currency, "CAD") - XCTAssertEqual(firstAccount.number, "12345-67890") + #expect(firstAccount.id == "account-123") + #expect(firstAccount.accountType == .tfsa) + #expect(firstAccount.currency == "CAD") + #expect(firstAccount.number == "12345-67890") let secondAccount = accounts[1] - XCTAssertEqual(secondAccount.id, "account-456") - XCTAssertEqual(secondAccount.accountType, .rrsp) - XCTAssertEqual(secondAccount.currency, "USD") - XCTAssertEqual(secondAccount.number, "98765-43210") + #expect(secondAccount.id == "account-456") + #expect(secondAccount.accountType == .rrsp) + #expect(secondAccount.currency == "USD") + #expect(secondAccount.number == "98765-43210") case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetAccountsEmptyResults() throws { - let expectation = XCTestExpectation(description: "getAccounts completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getAccountsEmptyResults() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupMockForSuccess(accounts: [], expectation: mockExpectation) - WealthsimpleAccount.getAccounts(token: try createValidToken()) { result in + WealthsimpleAccount.getAccounts(token: try createValidToken(), dependencies: dependencies) { result in switch result { case .success(let accounts): - XCTAssertEqual(accounts.count, 0) + #expect(accounts.isEmpty) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Network Error Tests #if canImport(FoundationNetworking) - func testGetAccountsNetworkFailure() throws { + @Test + func getAccountsNetworkFailure() throws { try testAccountsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -185,7 +185,8 @@ final class WealthsimpleAccountTests: DownloaderTestCase { ) } #else - func testGetAccountsNetworkFailure() throws { + @Test + func getAccountsNetworkFailure() throws { try testAccountsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -194,7 +195,8 @@ final class WealthsimpleAccountTests: DownloaderTestCase { } #endif - func testGetAccountsInvalidJSONEmptyData() throws { + @Test + func getAccountsInvalidJSONEmptyData() throws { try testAccountsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data() @@ -202,14 +204,16 @@ final class WealthsimpleAccountTests: DownloaderTestCase { ) } - func testGetAccountsWrongResponseType() throws { + @Test + func getAccountsWrongResponseType() throws { try testAccountsFailure(response: ( URLResponse(url: URL(string: "http://test.com")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data("test".utf8) ), expectedError: AccountError.httpError(error: "No HTTPURLResponse")) } - func testGetAccountsHTTPError() throws { + @Test + func getAccountsHTTPError() throws { try testAccountsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data() @@ -218,7 +222,8 @@ final class WealthsimpleAccountTests: DownloaderTestCase { // MARK: - JSON Parsing Error Tests - func testGetAccountsInvalidJSON() throws { + @Test + func getAccountsInvalidJSON() throws { let data = Data("NOT VALID JSON".utf8) try testJSONParsingFailure( jsonData: data, @@ -226,9 +231,10 @@ final class WealthsimpleAccountTests: DownloaderTestCase { ) } - func testGetAccountsInvalidJSONType() throws { + @Test + func getAccountsInvalidJSONType() throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: ["not", "a", "dictionary"], options: []) else { - XCTFail("Failed to create test JSON data") + Issue.record("Failed to create test JSON data") return } try testJSONParsingFailure( @@ -237,18 +243,21 @@ final class WealthsimpleAccountTests: DownloaderTestCase { ) } - func testGetAccountsMissingResults() throws { + @Test + func getAccountsMissingResults() throws { try testJSONParsingFailure(jsonObject: ["object": "account"], expectedError: AccountError.missingResultParamenter(json: "{\"object\":\"account\"}")) } - func testGetAccountsInvalidObject() throws { + @Test + func getAccountsInvalidObject() throws { try testJSONParsingFailure( jsonObject: ["object": "not_account", "results": []], expectedError: AccountError.invalidResultParamenter(json: "{\"object\":\"not_account\",\"results\":[]}") ) } - func testGetAccountsMissingAccountId() throws { + @Test + func getAccountsMissingAccountId() throws { try testJSONParsingFailure(jsonObject: [ "object": "account", "results": [ @@ -264,7 +273,8 @@ final class WealthsimpleAccountTests: DownloaderTestCase { )) } - func testGetAccountsInvalidAccountType() throws { + @Test + func getAccountsInvalidAccountType() throws { try testJSONParsingFailure(jsonObject: [ "object": "account", "results": [ @@ -281,7 +291,8 @@ final class WealthsimpleAccountTests: DownloaderTestCase { )) } - func testGetAccountsInvalidAccountObject() throws { + @Test + func getAccountsInvalidAccountObject() throws { try testJSONParsingFailure(jsonObject: [ "object": "account", "results": [ diff --git a/Tests/WealthsimpleDownloaderTests/WealthsimpleDownloaderTests.swift b/Tests/WealthsimpleDownloaderTests/WealthsimpleDownloaderTests.swift index 926331c4..81ec8530 100644 --- a/Tests/WealthsimpleDownloaderTests/WealthsimpleDownloaderTests.swift +++ b/Tests/WealthsimpleDownloaderTests/WealthsimpleDownloaderTests.swift @@ -10,9 +10,10 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disable:this type_body_length private let mockAccount = MockAccount(id: "account-123", accountType: .tfsa, currency: "CAD", number: "12345") @@ -22,13 +23,13 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab // MARK: - Helper Methods private func createDownloader(withAuthCallback callback: @escaping WealthsimpleAPI.AuthenticationCallback) -> WealthsimpleAPI { - WealthsimpleAPI(authenticationCallback: callback, credentialStorage: mockCredentialStorage) + WealthsimpleAPI(authenticationCallback: callback, credentialStorage: mockCredentialStorage, dependencies: dependencies) } private func authenticateDownloader() { - let expectation = XCTestExpectation(description: "authenticate finished") + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -39,63 +40,65 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) downloader = createDownloader { _ in - XCTFail("Auth callback should not be called when credential storage has valid token") + Issue.record("Auth callback should not be called when credential storage has valid token") } // Authenticate first to get token into downloader downloader.authenticate { error in - XCTAssertNil(error) - expectation.fulfill() + #expect(error == nil) + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Constructor Tests - func testInit() { - let downloader = createDownloader { _ in - XCTFail("Auth callback should not be called") + @Test + func initialization() { + _ = createDownloader { _ in + Issue.record("Auth callback should not be called") } - XCTAssertNotNil(downloader) } // MARK: - Authenticate Tests - func testAuthenticateWithExistingTokenSuccess() { + @Test + func authenticateWithExistingTokenSuccess() { authenticateDownloader() } - func testAuthenticateTwice() { - let expectation = XCTestExpectation(description: "authenticate completion") + @Test + func authenticateTwice() { + let expectation = DispatchSemaphore(value: 0) authenticateDownloader() downloader.authenticate { error in - XCTAssertNil(error) - expectation.fulfill() + #expect(error == nil) + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testAuthenticateWithExistingTokenRefreshRequired() { - let expectation = XCTestExpectation(description: "authenticate completion") + @Test + func authenticateWithExistingTokenRefreshRequired() { + let expectation = DispatchSemaphore(value: 0) - downloader = createDownloader { _ in XCTFail("Should not request credentials for refresh") } - - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + downloader = createDownloader { _ in Issue.record("Should not request credentials for refresh") } + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } - MockURLProtocol.newTokenRequestHandler = { url, request in + mockHTTPClient.newTokenRequestHandler = { url, request in #if canImport(FoundationNetworking) // body seems to be missing? #else let inputData = try Data(reading: request.httpBodyStream!), json = try JSONSerialization.jsonObject(with: inputData, options: []) as? [String: Any] - XCTAssertEqual(json?["grant_type"] as? String, "refresh_token") - XCTAssertEqual(json?["refresh_token"] as? String, "valid_refresh_token3") + #expect(json?["grant_type"] as? String == "refresh_token") + #expect(json?["refresh_token"] as? String == "valid_refresh_token3") #endif let jsonResponse = [ "access_token": "new_access_token", @@ -113,31 +116,32 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(-3_600).timeIntervalSince1970) downloader.authenticate { error in - XCTAssertNil(error) - expectation.fulfill() + #expect(error == nil) + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testAuthenticateWithoutToken() { - let expectation = XCTestExpectation(description: "authenticate completion") - let authExpectation = XCTestExpectation(description: "auth callback called") + @Test + func authenticateWithoutToken() { + let expectation = DispatchSemaphore(value: 0) + let authExpectation = DispatchSemaphore(value: 0) downloader = createDownloader { completion in - authExpectation.fulfill() + authExpectation.signal() completion("testuser", "testpass", "654321") } - MockURLProtocol.newTokenRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "x-wealthsimple-otp"), "654321") + mockHTTPClient.newTokenRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "x-wealthsimple-otp") == "654321") #if canImport(FoundationNetworking) // body seems to be missing? #else let inputData = try Data(reading: request.httpBodyStream!), json = try JSONSerialization.jsonObject(with: inputData, options: []) as? [String: Any] - XCTAssertEqual(json?["grant_type"] as? String, "password") - XCTAssertEqual(json?["username"] as? String, "testuser") - XCTAssertEqual(json?["password"] as? String, "testpass") + #expect(json?["grant_type"] as? String == "password") + #expect(json?["username"] as? String == "testuser") + #expect(json?["password"] as? String == "testpass") #endif let jsonResponse = [ "access_token": "new_access_token7", @@ -150,72 +154,77 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab } downloader.authenticate { error in - XCTAssertNil(error) - expectation.fulfill() + #expect(error == nil) + expectation.signal() } - wait(for: [expectation, authExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(authExpectation.wait(timeout: .now() + 10.0) == .success) } - func testAuthenticateWithNewTokenFailure() { - let expectation = XCTestExpectation(description: "authenticate completion") - let authExpectation = XCTestExpectation(description: "auth callback called") + @Test + func authenticateWithNewTokenFailure() { + let expectation = DispatchSemaphore(value: 0) + let authExpectation = DispatchSemaphore(value: 0) let authCallback: WealthsimpleAPI.AuthenticationCallback = { completion in - authExpectation.fulfill() + authExpectation.signal() completion("testuser", "testpass", "123456") } downloader = createDownloader(withAuthCallback: authCallback) - MockURLProtocol.newTokenRequestHandler = { _, _ in + mockHTTPClient.newTokenRequestHandler = { _, _ in throw URLError(.networkConnectionLost) } downloader.authenticate { error in - XCTAssertNotNil(error) - expectation.fulfill() + #expect(error != nil) + expectation.signal() } - wait(for: [expectation, authExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(authExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - getAccounts Tests - func testGetAccountsWithoutToken() { - let expectation = XCTestExpectation(description: "getAccounts completion") + @Test + func getAccountsWithoutToken() { + let expectation = DispatchSemaphore(value: 0) downloader = createDownloader { _ in - XCTFail("Auth callback should not be called without a call to authenticate") + Issue.record("Auth callback should not be called without a call to authenticate") } downloader.getAccounts { result in switch result { case .success: - XCTFail("Expected failure due to no token") + Issue.record("Expected failure due to no token") case .failure(let error): if case .tokenError(let tokenError) = error { - XCTAssertEqual(tokenError, .noToken) + #expect(tokenError == .noToken) } else { - XCTFail("Expected tokenError(.noToken)") + Issue.record("Expected tokenError(.noToken)") } } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGetAccountsWithTokenSuccess() throws { - let expectation = XCTestExpectation(description: "getAccounts completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getAccountsWithTokenSuccess() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) authenticateDownloader() // Setup mock for successful accounts response - MockURLProtocol.accountsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token") + mockHTTPClient.accountsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token") let jsonResponse = [ "object": "account", @@ -223,89 +232,92 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab ["id": "account-123", "type": "ca_tfsa", "object": "account", "base_currency": "CAD", "custodian_account_number": "12345-67890"] ] ] - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } downloader.getAccounts { result in switch result { case .success(let accounts): - XCTAssertEqual(accounts.count, 1) - XCTAssertEqual(accounts[0].id, "account-123") + #expect(accounts.count == 1) + #expect(accounts[0].id == "account-123") case .failure: - XCTFail("Expected success") + Issue.record("Expected success") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - // https://github.com/realm/SwiftLint/issues/6491 - // swiftlint:disable:next unneeded_throws_rethrows - func testGetAccountsWithHttpError() throws { - let expectation = XCTestExpectation(description: "getAccounts completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getAccountsWithHttpError() { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) authenticateDownloader() // Setup mock to return HTTP error - MockURLProtocol.accountsRequestHandler = { url, _ in - mockExpectation.fulfill() + mockHTTPClient.accountsRequestHandler = { url, _ in + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data()) } downloader.getAccounts { result in switch result { case .success: - XCTFail("Expected failure") + Issue.record("Expected failure") case .failure(let error): if case .httpError = error { // This is expected } else { - XCTFail("Expected httpError but got \(error)") + Issue.record("Expected httpError but got \(error)") } } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - getPositions Tests - func testGetPositionsWithoutToken() { - let expectation = XCTestExpectation(description: "getPositions completion") + @Test + func getPositionsWithoutToken() { + let expectation = DispatchSemaphore(value: 0) downloader = createDownloader { _ in - XCTFail("Auth callback should not be called without a call to authenticate") + Issue.record("Auth callback should not be called without a call to authenticate") } downloader.getPositions(in: mockAccount, date: nil) { result in switch result { case .success: - XCTFail("Expected failure due to no token") + Issue.record("Expected failure due to no token") case .failure(let error): if case .tokenError(let tokenError) = error { - XCTAssertEqual(tokenError, .noToken) + #expect(tokenError == .noToken) } else { - XCTFail("Expected tokenError(.noToken)") + Issue.record("Expected tokenError(.noToken)") } } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGetPositionsWithTokenSuccess() throws { - let expectation = XCTestExpectation(description: "getPositions completion"), mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getPositionsWithTokenSuccess() throws { + let expectation = DispatchSemaphore(value: 0), mockExpectation = DispatchSemaphore(value: 0) authenticateDownloader() // Setup mock for successful positions response - MockURLProtocol.positionsRequestHandler = { url, _ in - XCTAssert((url.query ?? "").contains("account_id=\(self.mockAccount.id)")) + mockHTTPClient.positionsRequestHandler = { url, _ in + #expect((url.query ?? "").contains("account_id=\(self.mockAccount.id)")) let jsonResponse = [ "object": "position", "results": [ @@ -320,59 +332,61 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab ] ] ] - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } downloader.getPositions(in: mockAccount, date: nil) { result in if case .success(let positions) = result { - XCTAssertEqual(positions.count, 1) + #expect(positions.count == 1) } else { - XCTFail("Expected success") + Issue.record("Expected success") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success && mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - getTransactions Tests - func testGetTransactionsWithoutToken() { - let expectation = XCTestExpectation(description: "getTransactions completion") + @Test + func getTransactionsWithoutToken() { + let expectation = DispatchSemaphore(value: 0) downloader = createDownloader { _ in - XCTFail("Auth callback should not be called without a call to authenticate") + Issue.record("Auth callback should not be called without a call to authenticate") } let defaultDate = Date(timeIntervalSince1970: 0) downloader.getTransactions(in: mockAccount, startDate: defaultDate) { result in switch result { case .success: - XCTFail("Expected failure due to no token") + Issue.record("Expected failure due to no token") case .failure(let error): if case .tokenError(let tokenError) = error { - XCTAssertEqual(tokenError, .noToken) + #expect(tokenError == .noToken) } else { - XCTFail("Expected tokenError(.noToken)") + Issue.record("Expected tokenError(.noToken)") } } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTransactionsWithTokenSuccess() throws { // swiftlint:disable:this function_body_length - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTransactionsWithTokenSuccess() throws { // swiftlint:disable:this function_body_length + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) authenticateDownloader() // Setup mock for successful transactions response - MockURLProtocol.transactionsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token") + mockHTTPClient.transactionsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token") let jsonResponse = [ "object": "transaction", @@ -394,7 +408,7 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab ] ] ] - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } @@ -402,25 +416,27 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab downloader.getTransactions(in: mockAccount, startDate: defaultDate) { result in switch result { case .success(let transactions): - XCTAssertEqual(transactions.count, 1) + #expect(transactions.count == 1) case .failure: - XCTFail("Expected success") + Issue.record("Expected success") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTransactionsWithNetworkError() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTransactionsWithNetworkError() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) authenticateDownloader() // Setup mock to throw network error - MockURLProtocol.transactionsRequestHandler = { _, _ in - mockExpectation.fulfill() + mockHTTPClient.transactionsRequestHandler = { _, _ in + mockExpectation.signal() throw URLError(.networkConnectionLost) } @@ -428,18 +444,19 @@ final class WealthsimpleDownloaderTests: DownloaderTestCase { // swiftlint:disab downloader.getTransactions(in: mockAccount, startDate: defaultDate) { result in switch result { case .success: - XCTFail("Expected failure") + Issue.record("Expected failure") case .failure(let error): if case .httpError = error { // This is expected } else { - XCTFail("Expected httpError but got \(error)") + Issue.record("Expected httpError but got \(error)") } } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } } diff --git a/Tests/WealthsimpleDownloaderTests/WealthsimplePositionTests.swift b/Tests/WealthsimpleDownloaderTests/WealthsimplePositionTests.swift index 27986752..bb07dd95 100644 --- a/Tests/WealthsimpleDownloaderTests/WealthsimplePositionTests.swift +++ b/Tests/WealthsimpleDownloaderTests/WealthsimplePositionTests.swift @@ -9,9 +9,10 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable:this type_body_length private static let mockAccount = MockAccount(id: "account-123", accountType: .tfsa, currency: "CAD", number: "12345-67890") @@ -53,10 +54,10 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable // MARK: - Helper Methods private func createValidToken() throws -> Token { - let expectation = XCTestExpectation(description: "createValidToken completion") + let expectation = DispatchSemaphore(value: 0) var resultToken: Token? - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -65,22 +66,22 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable mockCredentialStorage.storage["refreshToken"] = "valid_refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in resultToken = token - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) - return try XCTUnwrap(resultToken) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + return try #require(resultToken) } - private func setupMockForSuccess(positions: [[String: Any]], expectation: XCTestExpectation) { - MockURLProtocol.positionsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token3") - XCTAssertFalse((url.query ?? "").contains("date")) - XCTAssert((url.query ?? "").contains("account_id=\(Self.mockAccount.id)")) - XCTAssert((url.query ?? "").contains("limit=250")) - expectation.fulfill() + private func setupMockForSuccess(positions: [[String: Any]], expectation: DispatchSemaphore) { + mockHTTPClient.positionsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token3") + #expect(!((url.query ?? "").contains("date"))) + #expect((url.query ?? "").contains("account_id=\(Self.mockAccount.id)")) + #expect((url.query ?? "").contains("limit=250")) + expectation.signal() let responseJSON: [String: Any] = ["object": "position", "results": positions] return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: responseJSON, options: [])) } @@ -88,100 +89,96 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable private func testPositionsFailure( response: @escaping (URL, URLRequest) throws -> (URLResponse, Data), - expectedError: PositionError, - file: StaticString = #file, - line: UInt = #line + expectedError: PositionError ) throws { - let expectation = XCTestExpectation(description: "getPositions completion") + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.positionsRequestHandler = response + mockHTTPClient.positionsRequestHandler = response - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil, dependencies: dependencies) { result in switch result { case .success: - XCTFail("Expected failure", file: file, line: line) + Issue.record("Expected failure") case .failure(let error): - XCTAssertEqual(error, expectedError, file: file, line: line) + #expect(error == expectedError) } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } private func testJSONParsingFailure( jsonData: Data, - expectedError: PositionError, - file: StaticString = #file, - line: UInt = #line + expectedError: PositionError ) throws { try testPositionsFailure( response: { url, _ in (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, jsonData) }, - expectedError: expectedError, - file: file, - line: line + expectedError: expectedError ) } private func testJSONParsingFailure( jsonObject: [String: Any], - expectedError: PositionError, - file: StaticString = #file, - line: UInt = #line + expectedError: PositionError ) throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject, options: []) else { - XCTFail("Failed to create JSON data", file: file, line: line) + Issue.record("Failed to create JSON data") return } - try testJSONParsingFailure(jsonData: jsonData, expectedError: expectedError, file: file, line: line) + try testJSONParsingFailure(jsonData: jsonData, expectedError: expectedError) } // MARK: - Successful getPositions Tests - func testGetPositionsSuccess() throws { - let expectation = XCTestExpectation(description: "getPositions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getPositionsSuccess() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupMockForSuccess(positions: [Self.positionJSON], expectation: mockExpectation) - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil, dependencies: dependencies) { result in switch result { case .success(let positions): - XCTAssertEqual(positions.count, 1) + #expect(positions.count == 1) + let position = positions[0] - XCTAssertEqual(position.accountId, "account-123") - XCTAssertEqual(position.quantity, "100.0") - XCTAssertEqual(position.priceAmount, "150.25") - XCTAssertEqual(position.priceCurrency, "USD") - XCTAssertEqual(position.asset.symbol, "AAPL") - XCTAssertEqual(position.asset.name, "Apple Inc.") - XCTAssertEqual(position.asset.currency, "USD") - XCTAssertEqual(position.asset.type, .equity) + #expect(position.accountId == "account-123") + #expect(position.quantity == "100.0") + #expect(position.priceAmount == "150.25") + #expect(position.priceCurrency == "USD") + #expect(position.asset.symbol == "AAPL") + #expect(position.asset.name == "Apple Inc.") + #expect(position.asset.currency == "USD") + #expect(position.asset.type == .equity) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let expectedDate = dateFormatter.date(from: "2023-12-01")! - XCTAssertEqual(position.positionDate, expectedDate) + #expect(position.positionDate == expectedDate) - expectation.fulfill() + expectation.signal() case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetPositionsSuccessWithDate() throws { - let expectation = XCTestExpectation(description: "getPositions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getPositionsSuccessWithDate() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.positionsRequestHandler = { url, _ in + mockHTTPClient.positionsRequestHandler = { url, _ in // Verify that the date parameter is included in the request - XCTAssertEqual(url.query?.contains("date=2023-12-01"), true) - mockExpectation.fulfill() + #expect(url.query?.contains("date=2023-12-01") == true) + mockExpectation.signal() let json = ["object": "position", "results": [Self.positionJSON, Self.positionJSON2]] return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: json, options: [])) } @@ -190,52 +187,57 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable dateFormatter.dateFormat = "yyyy-MM-dd" let testDate = dateFormatter.date(from: "2023-12-01")! - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: testDate) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: testDate, dependencies: dependencies) { result in switch result { case .success(let positions): - XCTAssertEqual(positions.count, 2) - expectation.fulfill() + #expect(positions.count == 2) + expectation.signal() case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetPositionsSuccessMultiplePositions() throws { - let expectation = XCTestExpectation(description: "getPositions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getPositionsSuccessMultiplePositions() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupMockForSuccess(positions: [Self.positionJSON, Self.positionJSON2], expectation: mockExpectation) - WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil) { result in + WealthsimplePosition.getPositions(token: try createValidToken(), account: Self.mockAccount, date: nil, dependencies: dependencies) { result in switch result { case .success(let positions): - XCTAssertEqual(positions.count, 2) + #expect(positions.count == 2) let firstPosition = positions[0] - XCTAssertEqual(firstPosition.accountId, "account-123") - XCTAssertEqual(firstPosition.quantity, "100.0") - XCTAssertEqual(firstPosition.asset.symbol, "AAPL") + #expect(firstPosition.accountId == "account-123") + #expect(firstPosition.quantity == "100.0") + #expect(firstPosition.asset.symbol == "AAPL") let secondPosition = positions[1] - XCTAssertEqual(secondPosition.accountId, "account-123") - XCTAssertEqual(secondPosition.quantity, "50.0") - XCTAssertEqual(secondPosition.asset.symbol, "GOOGL") - expectation.fulfill() + #expect(secondPosition.accountId == "account-123") + #expect(secondPosition.quantity == "50.0") + #expect(secondPosition.asset.symbol == "GOOGL") + + expectation.signal() case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Network Failure Tests #if canImport(FoundationNetworking) - func testGetPositionsNetworkFailure() throws { + @Test + func getPositionsNetworkFailure() throws { try testPositionsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -243,7 +245,8 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable ) } #else - func testGetPositionsNetworkFailure() throws { + @Test + func getPositionsNetworkFailure() throws { try testPositionsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -252,19 +255,22 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable } #endif - func testGetPositionsEmptyData() throws { + @Test + func getPositionsEmptyData() throws { try testPositionsFailure(response: { url, _ in (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data()) }, expectedError: PositionError.invalidJson(json: Data())) } - func testGetPositionsWrongResponseType() throws { + @Test + func getPositionsWrongResponseType() throws { try testPositionsFailure(response: { url, _ in (URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data("test".utf8)) }, expectedError: PositionError.httpError(error: "No HTTPURLResponse")) } - func testGetPositionsHTTPError() throws { + @Test + func getPositionsHTTPError() throws { try testPositionsFailure(response: { url, _ in (HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data()) }, expectedError: PositionError.httpError(error: "Status code 401")) @@ -272,14 +278,16 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable // MARK: - JSON Parsing Error Tests - func testGetPositionsInvalidJSON() throws { + @Test + func getPositionsInvalidJSON() throws { let data = Data("NOT VALID JSON".utf8) try testJSONParsingFailure(jsonData: data, expectedError: PositionError.invalidJson(json: data)) } - func testGetPositionsInvalidJSONType() throws { + @Test + func getPositionsInvalidJSONType() throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: ["not", "a", "dictionary"], options: []) else { - XCTFail("Failed to create test JSON data") + Issue.record("Failed to create test JSON data") return } try testJSONParsingFailure( @@ -288,18 +296,21 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable ) } - func testGetPositionsMissingResults() throws { + @Test + func getPositionsMissingResults() throws { try testJSONParsingFailure(jsonObject: ["object": "position"], expectedError: PositionError.missingResultParamenter(json: "{\"object\":\"position\"}")) } - func testGetPositionsInvalidObject() throws { + @Test + func getPositionsInvalidObject() throws { try testJSONParsingFailure( jsonObject: ["object": "not_position", "results": []], expectedError: PositionError.invalidResultParamenter(json: "{\"object\":\"not_position\",\"results\":[]}") ) } - func testGetPositionsMissingQuantity() throws { + @Test + func getPositionsMissingQuantity() throws { var json = Self.positionJSON json.removeValue(forKey: "quantity") try testJSONParsingFailure( @@ -310,7 +321,8 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable ) } - func testGetPositionsWrongObject() throws { + @Test + func getPositionsWrongObject() throws { var json = Self.positionJSON json["object"] = "not_position" try testJSONParsingFailure( @@ -321,7 +333,8 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable ) } - func testGetPositionsInvalidDate() throws { + @Test + func getPositionsInvalidDate() throws { var json = Self.positionJSON json["position_date"] = "invalid-date" try testJSONParsingFailure( @@ -332,7 +345,8 @@ final class WealthsimplePositionTests: DownloaderTestCase { // swiftlint:disable ) } - func testGetPositionsInvalidAsset() throws { + @Test + func getPositionsInvalidAsset() throws { var json = Self.positionJSON json["asset"] = [ "security_id": "asset-123", diff --git a/Tests/WealthsimpleDownloaderTests/WealthsimpleTransactionTests.swift b/Tests/WealthsimpleDownloaderTests/WealthsimpleTransactionTests.swift index f6518b6c..2e3d1979 100644 --- a/Tests/WealthsimpleDownloaderTests/WealthsimpleTransactionTests.swift +++ b/Tests/WealthsimpleDownloaderTests/WealthsimpleTransactionTests.swift @@ -10,9 +10,10 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import Testing @testable import WealthsimpleDownloader -import XCTest +@Suite final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disable:this type_body_length private struct TestAccount: Account { @@ -68,10 +69,10 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa // MARK: - Helper Methods private func createValidToken() throws -> Token { - let expectation = XCTestExpectation(description: "createValidToken completion") + let expectation = DispatchSemaphore(value: 0) var resultToken: Token? - MockURLProtocol.tokenValidationRequestHandler = { url, _ in + mockHTTPClient.tokenValidationRequestHandler = { url, _ in let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, Data()) } @@ -80,14 +81,14 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa mockCredentialStorage.storage["refreshToken"] = "valid_refresh_token" mockCredentialStorage.storage["expiry"] = String(Date().addingTimeInterval(3_600).timeIntervalSince1970) - Token.getToken(from: mockCredentialStorage) { token in + Token.getToken(from: mockCredentialStorage, dependencies: dependencies) { token in resultToken = token - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) guard let resultToken else { - XCTFail("Did not get valid token") + Issue.record("Did not get valid token") throw TokenError.noToken } return resultToken @@ -101,126 +102,129 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa TestAccount(id: "credit-test-account-4321", accountType: .creditCard, currency: "CAD", number: "4321") } - private func setupRESTMockForSuccess(transactions: [[String: Any]], expectation: XCTestExpectation) { - MockURLProtocol.transactionsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token3") - XCTAssertTrue(url.query()?.contains("effective_date_start") ?? false) - XCTAssertTrue(url.query()?.contains("process_date_start") ?? false) + private func setupRESTMockForSuccess(transactions: [[String: Any]], expectation: DispatchSemaphore) { + mockHTTPClient.transactionsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token3") + #expect(url.query()?.contains("effective_date_start") ?? false) + #expect(url.query()?.contains("process_date_start") ?? false) let jsonResponse = [ "object": "transaction", "results": transactions ] - expectation.fulfill() + expectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } } private func setupGraphQLMockForSuccess( - activityResponses: [[String: Any]], fxResponses: [[String: Any]], expectation: XCTestExpectation, file: StaticString = #file, line: UInt = #line + activityResponses: [[String: Any]], fxResponses: [[String: Any]], expectation: DispatchSemaphore ) throws { var callCount = 0 - MockURLProtocol.graphQLRequestHandler = { _, request in + mockHTTPClient.graphQLRequestHandler = { _, request in callCount += 1 - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json", file: file, line: line) - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token3", file: file, line: line) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token3") guard let url = request.url else { - XCTFail("Request URL is empty", file: file, line: line) + Issue.record("Request URL is empty") throw TransactionError.httpError(error: "Request URL is empty") } guard let stream = request.httpBodyStream, let inputData = try? Data(reading: stream), let requestString = String(data: inputData, encoding: .utf8) else { - XCTFail("Request body is empty", file: file, line: line) + Issue.record("Request body is empty") throw TransactionError.noDataReceived } var response = [String: Any]() if requestString.contains("query FetchActivityFeedItems") { - XCTAssertEqual(callCount % 2, 1) + #expect(!callCount.isMultiple(of: 2)) response = activityResponses[(callCount - 1) / 2] } else if requestString.contains("query CreditCardActivity") { - XCTAssertEqual(callCount % 2, 0) + #expect(callCount.isMultiple(of: 2)) response = fxResponses[(callCount / 2) - 1] } else { - XCTFail("Unexpected GraphQL query", file: file, line: line) + Issue.record("Unexpected GraphQL query") } if callCount == activityResponses.count + fxResponses.count { - expectation.fulfill() + expectation.signal() } else if callCount > activityResponses.count + fxResponses.count { - XCTFail("Too many GraphQL calls", file: file, line: line) + Issue.record("Too many GraphQL calls") } return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: response, options: [])) } } - private func testRESTTransactionsFailure(response: (URLResponse, Data), expectedError: TransactionError, file: StaticString = #file, line: UInt = #line) throws { - let mockExpectation = XCTestExpectation(description: "mock server called") + private func testRESTTransactionsFailure(response: (URLResponse, Data), expectedError: TransactionError) throws { + let mockExpectation = DispatchSemaphore(value: 0) try testRESTTransactionsFailure( response: { _, _ in - mockExpectation.fulfill() + mockExpectation.signal() return response }, - expectedError: expectedError, - file: file, - line: line + expectedError: expectedError ) - wait(for: [mockExpectation], timeout: 10.0) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } private func testRESTTransactionsFailure( response: @escaping ((URL, URLRequest) throws -> (URLResponse, Data)), - expectedError: TransactionError, - file: StaticString = #file, - line: UInt = #line + expectedError: TransactionError ) throws { - let expectation = XCTestExpectation(description: "getTransactions completion") + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.transactionsRequestHandler = response + mockHTTPClient.transactionsRequestHandler = response - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate) { result in - switch result { + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate, dependencies: dependencies) { + switch $0 { case .success: - XCTFail("Expected failure", file: file, line: line) + Issue.record("Expected failure") case .failure(let error): - XCTAssertEqual(error, expectedError, file: file, line: line) + #expect(error == expectedError) } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - private func testRESTJSONParsingFailure(jsonData: Data, expectedError: TransactionError, file: StaticString = #file, line: UInt = #line) throws { + private func testRESTJSONParsingFailure(jsonData: Data, expectedError: TransactionError) throws { try testRESTTransactionsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, jsonData ), - expectedError: expectedError, - file: file, - line: line + expectedError: expectedError ) } - private func testRESTJSONParsingFailure(jsonObject: [String: Any], expectedError: TransactionError, file: StaticString = #file, line: UInt = #line) throws { + private func testRESTJSONParsingFailure(jsonObject: [String: Any], expectedError: TransactionError) throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject, options: []) else { - XCTFail("Failed to create JSON data", file: file, line: line) + Issue.record("Failed to create JSON data") return } - try testRESTJSONParsingFailure(jsonData: jsonData, expectedError: expectedError, file: file, line: line) + try testRESTJSONParsingFailure(jsonData: jsonData, expectedError: expectedError) } - private func testGraphQLFailure(expectation: XCTestExpectation, expectedError: TransactionError, file: StaticString = #file, line: UInt = #line) throws { - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createGraphQLAccount(), startDate: Self.startDate) { result in + private func testGraphQLFailure( + expectation: DispatchSemaphore, + expectedError: TransactionError, + dependencies: DownloaderDependencies? = nil + ) throws { + WealthsimpleTransaction.getTransactions( + token: try createValidToken(), + account: createGraphQLAccount(), + startDate: Self.startDate, + dependencies: dependencies ?? self.dependencies + ) { result in switch result { case .success(let transactions): - XCTFail("Expected failure but got success with transactions: \(transactions)", file: file, line: line) + Issue.record("Expected failure but got success with transactions: \(transactions)") case .failure(let error): - XCTAssertEqual(error, expectedError, file: file, line: line) + #expect(error == expectedError) if error != expectedError { // Helper to debug test failures switch error { case .missingResultParameter(let json), .invalidResultParameter(let json): @@ -238,21 +242,22 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa } } } - expectation.fulfill() + expectation.signal() } } private func testGraphQLJSONParsingFailure( // swiftlint:disable:next discouraged_optional_collection - activityResponse: [String: Any], fxResponse: [String: Any]?, expectedError: TransactionError, file: StaticString = #file, line: UInt = #line + activityResponse: [String: Any], fxResponse: [String: Any]?, expectedError: TransactionError ) throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) let fxResponses = fxResponse != nil ? [fxResponse!] : [] try setupGraphQLMockForSuccess(activityResponses: [activityResponse], fxResponses: fxResponses, expectation: mockExpectation) - try testGraphQLFailure(expectation: expectation, expectedError: expectedError, file: file, line: line) + try testGraphQLFailure(expectation: expectation, expectedError: expectedError) - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } private func graphQLResponse(for transaction: [String: Any]) -> [String: Any] { @@ -281,70 +286,74 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa // MARK: - Successful REST Tests - // swiftlint:disable:next function_body_length - func testGetTransactionsSuccess() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTransactionsSuccess() throws { // swiftlint:disable:this function_body_length + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) let transactionJSON = Self.transactionJSON setupRESTMockForSuccess(transactions: [transactionJSON], expectation: mockExpectation) - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate) { result in - switch result { + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate, dependencies: dependencies) { + switch $0 { case .success(let transactions): - XCTAssertEqual(transactions.count, 1) + #expect(transactions.count == 1) let transaction = transactions[0] - XCTAssertEqual(transaction.id, "transaction-123") - XCTAssertEqual(transaction.accountId, "account-456") - XCTAssertEqual(transaction.transactionType, .buy) - XCTAssertEqual(transaction.description, "Buy AAPL") - XCTAssertEqual(transaction.symbol, "AAPL") - XCTAssertEqual(transaction.quantity, "10.0") - XCTAssertEqual(transaction.marketPriceAmount, "150.00") - XCTAssertEqual(transaction.marketPriceCurrency, "USD") - XCTAssertEqual(transaction.marketValueAmount, "1500.00") - XCTAssertEqual(transaction.marketValueCurrency, "USD") - XCTAssertEqual(transaction.netCashAmount, "-1500.00") - XCTAssertEqual(transaction.netCashCurrency, "USD") - XCTAssertEqual(transaction.fxRate, "1.0") + #expect(transaction.id == "transaction-123") + #expect(transaction.accountId == "account-456") + #expect(transaction.transactionType == .buy) + #expect(transaction.description == "Buy AAPL") + #expect(transaction.symbol == "AAPL") + #expect(transaction.quantity == "10.0") + #expect(transaction.marketPriceAmount == "150.00") + #expect(transaction.marketPriceCurrency == "USD") + #expect(transaction.marketValueAmount == "1500.00") + #expect(transaction.marketValueCurrency == "USD") + #expect(transaction.netCashAmount == "-1500.00") + #expect(transaction.netCashCurrency == "USD") + #expect(transaction.fxRate == "1.0") let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" - XCTAssertEqual(transaction.processDate, dateFormatter.date(from: "2023-01-15")) - XCTAssertEqual(transaction.effectiveDate, dateFormatter.date(from: "2023-01-16")) + #expect(transaction.processDate == dateFormatter.date(from: "2023-01-15")) + #expect(transaction.effectiveDate == dateFormatter.date(from: "2023-01-16")) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTransactionsEmptyResults() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTransactionsEmptyResults() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) setupRESTMockForSuccess(transactions: [], expectation: mockExpectation) - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate) { result in - switch result { + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate, dependencies: dependencies) { + switch $0 { case .success(let transactions): - XCTAssertEqual(transactions.count, 0) + #expect(transactions.isEmpty) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTransactionsMultipleTransactionTypes() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func getTransactionsMultipleTransactionTypes() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) var buyTransaction = Self.transactionJSON buyTransaction["type"] = "buy" @@ -364,59 +373,61 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa setupRESTMockForSuccess(transactions: [buyTransaction, dividendTransaction, feeTransaction, paymentTransaction], expectation: mockExpectation) - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate) { result in - switch result { + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: Self.startDate, dependencies: dependencies) { + switch $0 { case .success(let transactions): - XCTAssertEqual(transactions.count, 4) - XCTAssertEqual(transactions[0].transactionType, .buy) - XCTAssertEqual(transactions[1].transactionType, .dividend) - XCTAssertEqual(transactions[2].transactionType, .custodianFee) - XCTAssertEqual(transactions[3].transactionType, .paymentTransferIn) + #expect(transactions.count == 4) + #expect(transactions[0].transactionType == .buy) + #expect(transactions[1].transactionType == .dividend) + #expect(transactions[2].transactionType == .custodianFee) + #expect(transactions[3].transactionType == .paymentTransferIn) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGetTransactionsAppendsStartDateQueryItems() throws { + @Test + func getTransactionsAppendsStartDateQueryItems() throws { let startDate = Date(timeIntervalSince1970: 1_700_000_000) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let expectedDateString = dateFormatter.string(from: startDate) - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) - MockURLProtocol.transactionsRequestHandler = { url, request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer valid_access_token3") - XCTAssert(url.query()?.contains("effective_date_start=\(expectedDateString)") ?? false) - XCTAssert(url.query()?.contains("process_date_start=\(expectedDateString)") ?? false) + mockHTTPClient.transactionsRequestHandler = { url, request in + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer valid_access_token3") + #expect(url.query()?.contains("effective_date_start=\(expectedDateString)") ?? false) + #expect(url.query()?.contains("process_date_start=\(expectedDateString)") ?? false) let jsonResponse = [ "object": "transaction", "results": [Self.transactionJSON] ] - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: jsonResponse, options: [])) } - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: startDate) { result in + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createValidAccount(), startDate: startDate, dependencies: dependencies) { result in switch result { case .success(let transactions): - XCTAssertEqual(transactions.count, 1) + #expect(transactions.count == 1) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) - + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - Successful GraphQL Tests @@ -424,57 +435,63 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa #if canImport(FoundationNetworking) // see https://github.com/swiftlang/swift-corelibs-foundation/issues/3199 #else - // swiftlint:disable:next function_body_length - func testGraphQLTransactionsSuccess() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func graphQLTransactionsSuccess() throws { // swiftlint:disable:this function_body_length + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) let response1 = graphQLResponse(for: Self.graphQLTransactionJSON) let response2 = graphQLFxResponse(for: Self.graphQLFxJSON) try setupGraphQLMockForSuccess(activityResponses: [response1], fxResponses: [response2], expectation: mockExpectation) - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createGraphQLAccount(), startDate: Self.startDate) { result in + WealthsimpleTransaction.getTransactions( + token: try createValidToken(), + account: createGraphQLAccount(), + startDate: Self.startDate, + dependencies: dependencies + ) { result in switch result { case .success(let transactions): - XCTAssertEqual(transactions.count, 1) + #expect(transactions.count == 1) let transaction = transactions[0] // Check basic fields - XCTAssertEqual(transaction.id, "cc-transaction-123") - XCTAssertEqual(transaction.accountId, "credit-test-account-4321") - XCTAssertEqual(transaction.description, "Foreign Merchant") - XCTAssertEqual(transaction.transactionType, .purchase) - XCTAssertEqual(transaction.symbol, "USD") - XCTAssertEqual(transaction.quantity, "75.00") - XCTAssertEqual(transaction.marketPriceAmount, "1.00") - XCTAssertEqual(transaction.marketPriceCurrency, "CAD") - XCTAssertEqual(transaction.marketValueAmount, "100.00") - XCTAssertEqual(transaction.marketValueCurrency, "CAD") - XCTAssertEqual(transaction.netCashAmount, "-100.00") - XCTAssertEqual(transaction.netCashCurrency, "CAD") - XCTAssertEqual(transaction.fxRate, "1.33333") + #expect(transaction.id == "cc-transaction-123") + #expect(transaction.accountId == "credit-test-account-4321") + #expect(transaction.description == "Foreign Merchant") + #expect(transaction.transactionType == .purchase) + #expect(transaction.symbol == "USD") + #expect(transaction.quantity == "75.00") + #expect(transaction.marketPriceAmount == "1.00") + #expect(transaction.marketPriceCurrency == "CAD") + #expect(transaction.marketValueAmount == "100.00") + #expect(transaction.marketValueCurrency == "CAD") + #expect(transaction.netCashAmount == "-100.00") + #expect(transaction.netCashCurrency == "CAD") + #expect(transaction.fxRate == "1.33333") // Check dates let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX" - XCTAssertEqual(transaction.processDate, dateFormatter.date(from: "2023-01-15T10:30:45.123456-05:00")) + #expect(transaction.processDate == dateFormatter.date(from: "2023-01-15T10:30:45.123456-05:00")) dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss z" - XCTAssertEqual(transaction.effectiveDate, dateFormatter.date(from: "2023-01-16 15:45:30 EST")) + #expect(transaction.effectiveDate == dateFormatter.date(from: "2023-01-16 15:45:30 EST")) case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGraphQLPaginationSuccess() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func graphQLPaginationSuccess() throws { + let expectation = DispatchSemaphore(value: 0), mockExpectation = DispatchSemaphore(value: 0) var transaction2 = Self.graphQLTransactionJSON transaction2["externalCanonicalId"] = "cc-transaction-page2" @@ -498,20 +515,21 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try setupGraphQLMockForSuccess(activityResponses: [responsePage1, responsePage2], fxResponses: [fxResponse, fxResponse], expectation: mockExpectation) - WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createGraphQLAccount(), startDate: Self.startDate) { result in - switch result { + WealthsimpleTransaction.getTransactions(token: try createValidToken(), account: createGraphQLAccount(), startDate: Self.startDate, dependencies: dependencies) { + switch $0 { case .success(let transactions): // Expect two transactions (one from each page) - XCTAssertEqual(transactions.count, 2) - XCTAssertEqual(transactions[0].id, "cc-transaction-123") - XCTAssertEqual(transactions[1].id, "cc-transaction-page2") + #expect(transactions.count == 2) + #expect(transactions[0].id == "cc-transaction-123") + #expect(transactions[1].id == "cc-transaction-page2") case .failure(let error): - XCTFail("Expected success but got error: \(error)") + Issue.record("Expected success but got error: \(error)") } - expectation.fulfill() + expectation.signal() } - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } #endif @@ -519,7 +537,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa // MARK: - Network Error Tests #if canImport(FoundationNetworking) - func testGetTransactionsNetworkFailure() throws { + @Test + func getTransactionsNetworkFailure() throws { try testRESTTransactionsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -527,7 +546,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } #else - func testGetTransactionsNetworkFailure() throws { + @Test + func getTransactionsNetworkFailure() throws { try testRESTTransactionsFailure( response: { _, _ in throw URLError(.networkConnectionLost) @@ -536,7 +556,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa } #endif - func testGetTransactionsEmptyData() throws { + @Test + func getTransactionsEmptyData() throws { try testRESTTransactionsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data() @@ -544,60 +565,51 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testGetTransactionsWrongResponseType() throws { + @Test + func getTransactionsWrongResponseType() throws { try testRESTTransactionsFailure(response: ( URLResponse(url: URL(string: "http://test.com")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil), Data() ), expectedError: TransactionError.httpError(error: "No HTTPURLResponse")) } - func testGetTransactionsHTTPError() throws { + @Test + func getTransactionsHTTPError() throws { try testRESTTransactionsFailure(response: ( HTTPURLResponse(url: URL(string: "http://test.com")!, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data() ), expectedError: TransactionError.httpError(error: "Status code 401")) } - func testInvalidGraphQLURL() throws { - URLConfiguration.shared.setGraphQLURL("Not a valid URL:::///") - let expectation = XCTestExpectation(description: "getTransactions completion") + @Test + func invalidGraphQLURL() throws { + let invalidDependencies = DownloaderDependencies( + httpClient: mockHTTPClient, + configuration: URLConfiguration( + baseURL: "http://localhost:8080/v1/", + graphQLURL: "Not a valid URL:::///" + ) + ) + let expectation = DispatchSemaphore(value: 0) let expectedError = TransactionError.httpError(error: "Invalid URL") - try testGraphQLFailure(expectation: expectation, expectedError: expectedError) - wait(for: [expectation], timeout: 10.0) + try testGraphQLFailure( + expectation: expectation, + expectedError: expectedError, + dependencies: invalidDependencies + ) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testInvalidGraphQLURLFx() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func graphQLRequestErrorFx() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) var callCount = 0 - MockURLProtocol.graphQLRequestHandler = { _, request in + mockHTTPClient.graphQLRequestHandler = { _, request in callCount += 1 guard let url = request.url else { - XCTFail("Request URL is empty") - throw TransactionError.httpError(error: "Request URL is empty") - } - if callCount == 1 { - let response = self.graphQLResponse(for: Self.graphQLTransactionJSON) - URLConfiguration.shared.setGraphQLURL("Not a valid URL:::///") - mockExpectation.fulfill() - return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: response, options: [])) - } - XCTFail("Too many GraphQL calls") - throw TransactionError.httpError(error: "Too many GraphQL calls") - } - let expectedError = TransactionError.httpError(error: "Invalid URL") - try testGraphQLFailure(expectation: expectation, expectedError: expectedError) - wait(for: [expectation, mockExpectation], timeout: 10.0) - } + Issue.record("Request URL is empty") - func testGraphQLRequestErrorFx() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") - var callCount = 0 - MockURLProtocol.graphQLRequestHandler = { _, request in - callCount += 1 - guard let url = request.url else { - XCTFail("Request URL is empty") throw TransactionError.httpError(error: "Request URL is empty") } if callCount == 1 { @@ -605,33 +617,37 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: response, options: [])) } if callCount == 2 { - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)!, Data()) } - XCTFail("Too many GraphQL calls") + Issue.record("Too many GraphQL calls") throw TransactionError.httpError(error: "Too many GraphQL calls") } let expectedError = TransactionError.httpError(error: "Status code 401") try testGraphQLFailure(expectation: expectation, expectedError: expectedError) - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } // MARK: - REST JSON Parsing Error Tests - func testGetTransactionsInvalidJSON() throws { + @Test + func getTransactionsInvalidJSON() throws { let data = Data("NOT VALID JSON".utf8) try testRESTJSONParsingFailure(jsonData: data, expectedError: TransactionError.invalidJson(json: data)) } - func testGetTransactionsInvalidJSONType() throws { + @Test + func getTransactionsInvalidJSONType() throws { guard let jsonData = try? JSONSerialization.data(withJSONObject: ["not", "a", "dictionary"], options: []) else { - XCTFail("Failed to create test JSON data") + Issue.record("Failed to create test JSON data") return } try testRESTJSONParsingFailure(jsonData: jsonData, expectedError: TransactionError.invalidJson(json: jsonData)) } - func testGetTransactionsMissingResults() throws { + @Test + func getTransactionsMissingResults() throws { let json = ["object": "transaction"] try testRESTJSONParsingFailure( jsonObject: json, @@ -639,7 +655,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testGetTransactionsInvalidObject() throws { + @Test + func getTransactionsInvalidObject() throws { let json: [String: Any] = ["object": "not_transaction", "results": []] try testRESTJSONParsingFailure( jsonObject: json, @@ -647,7 +664,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionMissingId() throws { + @Test + func transactionMissingId() throws { var transaction = Self.transactionJSON transaction.removeValue(forKey: "id") try testRESTJSONParsingFailure( @@ -656,7 +674,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionMissingProcessDate() throws { + @Test + func transactionMissingProcessDate() throws { var transaction = Self.transactionJSON transaction.removeValue(forKey: "process_date") try testRESTJSONParsingFailure( @@ -665,7 +684,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionMissingEffectiveDate() throws { + @Test + func transactionMissingEffectiveDate() throws { var transaction = Self.transactionJSON transaction.removeValue(forKey: "effective_date") try testRESTJSONParsingFailure( @@ -674,7 +694,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionInvalidProcessDate() throws { + @Test + func transactionInvalidProcessDate() throws { var transaction = Self.transactionJSON transaction["process_date"] = "invalid-date" try testRESTJSONParsingFailure( @@ -683,7 +704,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionInvalidEffectiveDate() throws { + @Test + func transactionInvalidEffectiveDate() throws { var transaction = Self.transactionJSON transaction["effective_date"] = "invalid-date" try testRESTJSONParsingFailure( @@ -692,7 +714,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionInvalidType() throws { + @Test + func transactionInvalidType() throws { var transaction = Self.transactionJSON transaction["type"] = "invalid_transaction_type" try testRESTJSONParsingFailure( @@ -701,7 +724,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa ) } - func testTransactionInvalidObject() throws { + @Test + func transactionInvalidObject() throws { var transaction = Self.transactionJSON transaction["object"] = "not_transaction" try testRESTJSONParsingFailure( @@ -712,34 +736,37 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa // MARK: - GraphQL JSON Parsing Error Tests - func testGraphQLInvalidJSON() throws { - let mockExpectation = XCTestExpectation(description: "mock server called") - let expectation = XCTestExpectation(description: "getTransactions completion") + @Test + func graphQLInvalidJSON() throws { + let mockExpectation = DispatchSemaphore(value: 0) + let expectation = DispatchSemaphore(value: 0) - MockURLProtocol.graphQLRequestHandler = { _, request in + mockHTTPClient.graphQLRequestHandler = { _, request in guard let url = request.url else { - XCTFail("Request URL is empty") + Issue.record("Request URL is empty") throw TransactionError.httpError(error: "Request URL is empty") } - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("NOT VALID JSON".utf8)) } let error = TransactionError.invalidJson(json: Data("NOT VALID JSON".utf8)) try testGraphQLFailure(expectation: expectation, expectedError: error) - wait(for: [mockExpectation, expectation], timeout: 10.0) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } - func testGraphQLInvalidJSONFx() throws { - let mockExpectation = XCTestExpectation(description: "mock server called") - let expectation = XCTestExpectation(description: "getTransactions completion") + @Test + func graphQLInvalidJSONFx() throws { + let mockExpectation = DispatchSemaphore(value: 0) + let expectation = DispatchSemaphore(value: 0) var callCount = 0 - MockURLProtocol.graphQLRequestHandler = { _, request in + mockHTTPClient.graphQLRequestHandler = { _, request in callCount += 1 guard let url = request.url else { - XCTFail("Request URL is empty") + Issue.record("Request URL is empty") throw TransactionError.httpError(error: "Request URL is empty") } if callCount == 1 { @@ -747,26 +774,28 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, try JSONSerialization.data(withJSONObject: response, options: [])) } if callCount == 2 { - mockExpectation.fulfill() + mockExpectation.signal() return (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("NOT VALID JSON".utf8)) } - XCTFail("Too many GraphQL calls") + Issue.record("Too many GraphQL calls") throw TransactionError.httpError(error: "Too many GraphQL calls") } let error = TransactionError.invalidJson(json: Data("NOT VALID JSON".utf8)) try testGraphQLFailure(expectation: expectation, expectedError: error) - wait(for: [mockExpectation, expectation], timeout: 10.0) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) } #if canImport(FoundationNetworking) // see https://github.com/swiftlang/swift-corelibs-foundation/issues/3199 #else - func testGraphQLErrorSecondPage() throws { - let expectation = XCTestExpectation(description: "getTransactions completion") - let mockExpectation = XCTestExpectation(description: "mock server called") + @Test + func graphQLErrorSecondPage() throws { + let expectation = DispatchSemaphore(value: 0) + let mockExpectation = DispatchSemaphore(value: 0) var transaction2 = Self.graphQLTransactionJSON transaction2["externalCanonicalId"] = "cc-transaction-page2" @@ -790,10 +819,12 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa let expectedError = TransactionError.missingResultParameter(json: transaction2) try testGraphQLFailure(expectation: expectation, expectedError: expectedError) - wait(for: [expectation, mockExpectation], timeout: 10.0) + #expect(expectation.wait(timeout: .now() + 10.0) == .success) + #expect(mockExpectation.wait(timeout: .now() + 10.0) == .success) } - func testGraphQLWrongInnerStructure() throws { + @Test + func graphQLWrongInnerStructure() throws { let response1 = [ "data": [ "activityFeedItems": [ @@ -811,7 +842,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: nil, expectedError: error) } - func testGraphQLMissingPageInfo() throws { + @Test + func graphQLMissingPageInfo() throws { let response1 = [ "data": [ "activityFeedItems": [ @@ -825,13 +857,15 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: nil, expectedError: error) } - func testGraphQLWrongStructure() throws { + @Test + func graphQLWrongStructure() throws { let response1 = Self.graphQLTransactionJSON let error = TransactionError.missingResultParameter(json: (Self.graphQLTransactionJSON)) try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: nil, expectedError: error) } - func testGraphQLWrongStructureFx() throws { + @Test + func graphQLWrongStructureFx() throws { let response1 = graphQLResponse(for: Self.graphQLTransactionJSON) let response2 = Self.graphQLFxJSON let error = TransactionError.missingResultParameter(json: Self.graphQLFxJSON) @@ -839,7 +873,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLWrongStructureFx2() throws { + @Test + func graphQLWrongStructureFx2() throws { let response1 = graphQLResponse(for: Self.graphQLTransactionJSON) let response2 = [ "data": [ @@ -851,7 +886,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLMissingRequiredField() throws { + @Test + func graphQLMissingRequiredField() throws { var transaction = Self.graphQLTransactionJSON transaction.removeValue(forKey: "amount") @@ -862,7 +898,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLMissingRequiredFieldForFx() throws { + @Test + func graphQLMissingRequiredFieldForFx() throws { var transaction = Self.graphQLTransactionJSON transaction.removeValue(forKey: "externalCanonicalId") @@ -872,7 +909,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: nil, expectedError: error) } - func testGraphQLInvalidType() throws { + @Test + func graphQLInvalidType() throws { var transaction = Self.graphQLTransactionJSON transaction["subType"] = "fun" @@ -883,7 +921,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLMissingSettlementDate() throws { + @Test + func graphQLMissingSettlementDate() throws { var transaction = Self.graphQLFxJSON transaction.removeValue(forKey: "settledAt") @@ -894,7 +933,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLInvalidDate() throws { + @Test + func graphQLInvalidDate() throws { var transaction = Self.graphQLFxJSON transaction["settledAt"] = "invalid-date" @@ -905,7 +945,8 @@ final class WealthsimpleTransactionTests: DownloaderTestCase { // swiftlint:disa try testGraphQLJSONParsingFailure(activityResponse: response1, fxResponse: response2, expectedError: error) } - func testGraphQLMissingFxRate() throws { + @Test + func graphQLMissingFxRate() throws { var transaction = Self.graphQLFxJSON transaction.removeValue(forKey: "foreignExchangeRate") From 99064510e29807b61b5c39b5a2cd653fe2ef8532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sat, 15 Aug 2026 23:45:38 -0700 Subject: [PATCH 2/6] Fix downloader networking sendability --- Sources/WealthsimpleDownloader/DownloaderDependencies.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/WealthsimpleDownloader/DownloaderDependencies.swift b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift index 88176ab0..a1a25716 100644 --- a/Sources/WealthsimpleDownloader/DownloaderDependencies.swift +++ b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift @@ -1,6 +1,6 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif protocol HTTPClient { From c8a88e36c9c00948aa40df0dd49cb6cfa9b7aaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sun, 16 Aug 2026 01:01:35 -0700 Subject: [PATCH 3/6] Synchronize paginated transaction result --- .../WealthsimpleTransaction.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift index 4bd90588..f474e4a9 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift @@ -267,18 +267,35 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return result } + private final class TransactionsResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result<[Transaction], TransactionError>? + + func set(_ result: Result<[Transaction], TransactionError>) { + lock.lock() + self.result = result + lock.unlock() + } + + func value() -> Result<[Transaction], TransactionError>? { + lock.lock() + defer { lock.unlock() } + return result + } + } + private static func loadNextPage(cursor: String, token: Token, account: Account, startDate: Date, dependencies: DownloaderDependencies) throws -> [Transaction] { - var nextResult: Result<[Transaction], TransactionError>! + let resultBox = TransactionsResultBox() let group = DispatchGroup() group.enter() DispatchQueue.global(qos: .userInitiated).async { getTransactions(token: token, account: account, startDate: startDate, dependencies: dependencies, cursor: cursor) { - nextResult = $0 + resultBox.set($0) group.leave() } } group.wait() - switch nextResult { + switch resultBox.value() { case .success(let nextTransactions): return nextTransactions case .failure(let error): From ea864945661b5f5f223c6586a93bf7a9742bad85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sun, 16 Aug 2026 10:00:00 -0700 Subject: [PATCH 4/6] Order paginated transaction result helper --- .../WealthsimpleTransaction.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift index f474e4a9..75ef3a10 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift @@ -267,23 +267,6 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return result } - private final class TransactionsResultBox: @unchecked Sendable { - private let lock = NSLock() - private var result: Result<[Transaction], TransactionError>? - - func set(_ result: Result<[Transaction], TransactionError>) { - lock.lock() - self.result = result - lock.unlock() - } - - func value() -> Result<[Transaction], TransactionError>? { - lock.lock() - defer { lock.unlock() } - return result - } - } - private static func loadNextPage(cursor: String, token: Token, account: Account, startDate: Date, dependencies: DownloaderDependencies) throws -> [Transaction] { let resultBox = TransactionsResultBox() let group = DispatchGroup() @@ -425,4 +408,21 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return results } + private final class TransactionsResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result<[Transaction], TransactionError>? + + func set(_ result: Result<[Transaction], TransactionError>) { + lock.lock() + self.result = result + lock.unlock() + } + + func value() -> Result<[Transaction], TransactionError>? { + lock.lock() + defer { lock.unlock() } + return result + } + } + } From 124c1dbcac63bcb2cc86cddcf4ef809fce3ec680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sun, 16 Aug 2026 10:16:02 -0700 Subject: [PATCH 5/6] Order transaction result helper --- .../WealthsimpleTransaction.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift index 75ef3a10..77ea6543 100644 --- a/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift +++ b/Sources/WealthsimpleDownloader/WealthsimpleTransaction.swift @@ -15,6 +15,23 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod public typealias TransactionsCompletion = (Result<[Transaction], TransactionError>) -> Void + private final class TransactionsResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result<[Transaction], TransactionError>? + + func set(_ result: Result<[Transaction], TransactionError>) { + lock.lock() + self.result = result + lock.unlock() + } + + func value() -> Result<[Transaction], TransactionError>? { + lock.lock() + defer { lock.unlock() } + return result + } + } + private static let path = "transactions" private static let graphQLQuery = """ @@ -408,21 +425,4 @@ struct WealthsimpleTransaction: Transaction { // swiftlint:disable:this type_bod return results } - private final class TransactionsResultBox: @unchecked Sendable { - private let lock = NSLock() - private var result: Result<[Transaction], TransactionError>? - - func set(_ result: Result<[Transaction], TransactionError>) { - lock.lock() - self.result = result - lock.unlock() - } - - func value() -> Result<[Transaction], TransactionError>? { - lock.lock() - defer { lock.unlock() } - return result - } - } - } From 1e346626db8bfa1ac9548460a035edce0e7a3549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sun, 16 Aug 2026 10:22:55 -0700 Subject: [PATCH 6/6] Bridge HTTP completion sendability --- .../DownloaderDependencies.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/WealthsimpleDownloader/DownloaderDependencies.swift b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift index a1a25716..bfa9ee2e 100644 --- a/Sources/WealthsimpleDownloader/DownloaderDependencies.swift +++ b/Sources/WealthsimpleDownloader/DownloaderDependencies.swift @@ -11,6 +11,14 @@ protocol HTTPClient { ) } +private final class HTTPCompletionBox: @unchecked Sendable { + let completion: (Data?, URLResponse?, Error?) -> Void + + init(_ completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + self.completion = completion + } +} + struct URLSessionHTTPClient: HTTPClient { let session: URLSession @@ -25,7 +33,11 @@ struct URLSessionHTTPClient: HTTPClient { ) { var request = request request.httpBody = body - session.dataTask(with: request, completionHandler: completion).resume() + let completionBox = HTTPCompletionBox(completion) + session.dataTask(with: request) { data, response, error in + completionBox.completion(data, response, error) + } + .resume() } }