diff --git a/Projects/Detail/Sources/Service/DetailService.swift b/Projects/Detail/Sources/Service/DetailService.swift index f4d5289..37c306c 100644 --- a/Projects/Detail/Sources/Service/DetailService.swift +++ b/Projects/Detail/Sources/Service/DetailService.swift @@ -16,7 +16,7 @@ enum DetailServiceError: Error, Equatable { } final class DetailService: Sendable { - private nonisolated(unsafe) let networkService: NetworkServiceProtocol + private let networkService: NetworkServiceProtocol init(networkService: NetworkServiceProtocol) { self.networkService = networkService diff --git a/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift b/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift index da4fd58..57c1249 100644 --- a/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift +++ b/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift @@ -9,15 +9,15 @@ import Foundation import NetworkingInterfaces -final class NetworkServiceProtocolSpy: NetworkServiceProtocol, @unchecked Sendable { - enum Method: Equatable { +actor NetworkServiceProtocolSpy: NetworkServiceProtocol, Sendable { + enum Method: Equatable, Sendable { case request } - var calledMethods: [Method] = [] - var requestResult: Result<(Data, HTTPURLResponse), Error>? + private(set) var calledMethods: [Method] = [] + private(set) var requestResult: Result<(Data, HTTPURLResponse), NetworkError>? - func request(endpoint: APIEndpointProtocol) async throws -> (Data, HTTPURLResponse) { + func request(endpoint: APIEndpointProtocol) async throws(NetworkError) -> (Data, HTTPURLResponse) { calledMethods.append(.request) guard let result = requestResult else { @@ -26,4 +26,8 @@ final class NetworkServiceProtocolSpy: NetworkServiceProtocol, @unchecked Sendab return try result.get() } + + func setResponse(requestResult: Result<(Data, HTTPURLResponse), NetworkError>) async { + self.requestResult = requestResult + } } diff --git a/Projects/Detail/Tests/Service/DetailServiceTests.swift b/Projects/Detail/Tests/Service/DetailServiceTests.swift index bd1fbfd..fc6ac06 100644 --- a/Projects/Detail/Tests/Service/DetailServiceTests.swift +++ b/Projects/Detail/Tests/Service/DetailServiceTests.swift @@ -58,11 +58,11 @@ struct DetailServiceTests { headerFields: nil )) - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) let result = try await sut.fetchExchangeAssets(id: 1) - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) #expect(result.count == 2) #expect(result[0] == Asset(id: 1, name: "Bitcoin", symbol: "BTC", priceUsd: 50000.0)) #expect(result[1] == Asset(id: 2, name: "Ethereum", symbol: "ETH", priceUsd: 3000.0)) @@ -91,7 +91,7 @@ struct DetailServiceTests { headerFields: nil )) - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) do { _ = try await sut.fetchExchangeAssets(id: 999) @@ -104,7 +104,7 @@ struct DetailServiceTests { elapsed: 10, creditCount: 1 ))) - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) } } @@ -123,13 +123,13 @@ struct DetailServiceTests { headerFields: nil )) - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) do { _ = try await sut.fetchExchangeAssets(id: 1) Issue.record("Expected error to be thrown") } catch { - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) } } } diff --git a/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift index d541887..fd315ec 100644 --- a/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift +++ b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift @@ -10,6 +10,8 @@ import HomeInterfaces import Foundation protocol HomeServiceProtocol: AnyObject, Sendable { - func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] - func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] + @concurrent + func fetchExchangesList(page: Int, limit: Int) async throws(ServiceError) -> [ExchangeSummary] + @concurrent + func fetchDetailsFor(ids: [String]) async throws(ServiceError) -> [ExchangeDetail] } diff --git a/Projects/Home/Sources/Service/HomeService.swift b/Projects/Home/Sources/Service/HomeService.swift index 041d67b..b7c3ce6 100644 --- a/Projects/Home/Sources/Service/HomeService.swift +++ b/Projects/Home/Sources/Service/HomeService.swift @@ -12,18 +12,26 @@ import NetworkingInterfaces enum ServiceError: Error, Equatable { case decodeFail(String?) + case requestError(String?) case network(Status) } final class HomeService: Sendable { - private nonisolated(unsafe) let networkService: NetworkServiceProtocol + private let networkService: NetworkServiceProtocol init(networkService: NetworkServiceProtocol) { self.networkService = networkService } - private func performRequest(endpoint: APIEndpointProtocol) async throws -> T { - let (data, httpUrlResponse) = try await networkService.request(endpoint: endpoint) + @concurrent + private func performRequest(endpoint: APIEndpointProtocol) async throws(ServiceError) -> T { + let (data, httpUrlResponse): (Data, HTTPURLResponse) + + do { + (data, httpUrlResponse) = try await networkService.request(endpoint: endpoint) + } catch { + throw ServiceError.requestError(error.localizedDescription) + } switch httpUrlResponse.statusCode { case 200...299: @@ -34,20 +42,27 @@ final class HomeService: Sendable { } } - func decodeResponse(_ data: Data) throws -> T { - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: data) + func decodeResponse(_ data: Data) throws(ServiceError) -> T { + do { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch { + throw ServiceError.decodeFail(error.localizedDescription) + } + } } extension HomeService: HomeServiceProtocol { - func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] { + @concurrent + func fetchExchangesList(page: Int, limit: Int) async throws(ServiceError) -> [ExchangeSummary] { let endpoint = HomeEndpoint.fetchItems(page: page, limit: limit) let response: ExchangeResponse = try await performRequest(endpoint: endpoint) return response.data } - func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] { + @concurrent + func fetchDetailsFor(ids: [String]) async throws(ServiceError) -> [ExchangeDetail] { let endpoint = HomeEndpoint.fetchDetail(ids: ids) let response: ExchangeDetailResponse = try await performRequest(endpoint: endpoint) return Array(response.data.values) diff --git a/Projects/Home/Sources/ViewModel/HomeViewModel.swift b/Projects/Home/Sources/ViewModel/HomeViewModel.swift index 79b5190..182c93a 100644 --- a/Projects/Home/Sources/ViewModel/HomeViewModel.swift +++ b/Projects/Home/Sources/ViewModel/HomeViewModel.swift @@ -15,6 +15,7 @@ final class HomeViewModel: HomeViewModelProtocol { weak var coordinatorDelegate: HomeViewModelCoordinatorDelegate? private let service: HomeServiceProtocol + private let defaultMessage = "Failed to load data. Press try again or check your connection." private var currentPage = 1 private var isFetching = false @@ -83,27 +84,21 @@ final class HomeViewModel: HomeViewModelProtocol { isFetching = false state = .loaded - } catch { - isFetching = false - if exchanges.isEmpty { - let serviceError = error as? ServiceError - let defaultMessage = "Failed to Load data. Press try again later or check your connection." - let message: String - var code: String? - switch serviceError { - case .decodeFail, .none: - message = defaultMessage - case .network(let status): - message = status.errorMessage ?? defaultMessage - if let statusCode = status.errorCode { - code = String(statusCode) - } - } - state = .error(message, code) - } else { - state = .loaded + } catch ServiceError.decodeFail { + state = .error(defaultMessage, nil) + } catch let ServiceError.network(status) { + let message = status.errorMessage ?? defaultMessage + var code: String? + if let statusCode = status.errorCode { + code = String(statusCode) } + state = .error(message, code) + } catch let ServiceError.requestError(error) { + let message = error ?? defaultMessage + state = .error(message, nil) } + + isFetching = false } } diff --git a/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift index a98bf23..23b3087 100644 --- a/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift +++ b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift @@ -13,33 +13,35 @@ import HomeInterfaces @MainActor final class HomeServiceProtocolSpy: HomeServiceProtocol, Sendable { - enum Method: Equatable { + enum Method: Equatable, Sendable { case fetchExchangesList case fetchDetailsFor } var calledMethods: [Method] = [] - var fetchExchangesListResult: Result<[ExchangeSummary], Error>? - var fetchDetailsForResult: Result<[ExchangeDetail], Error>? + var fetchExchangesListResult: Result<[ExchangeSummary], ServiceError>? + var fetchDetailsForResult: Result<[ExchangeDetail], ServiceError>? - func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] { + @concurrent + func fetchExchangesList(page: Int, limit: Int) async throws(ServiceError) -> [ExchangeSummary] { await MainActor.run { calledMethods.append(.fetchExchangesList) } - guard let result = fetchExchangesListResult else { + guard let result = await fetchExchangesListResult else { fatalError("fetchExchangesListResult not set") } return try result.get() } - func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] { + @concurrent + func fetchDetailsFor(ids: [String]) async throws(ServiceError) -> [ExchangeDetail] { await MainActor.run { calledMethods.append(.fetchDetailsFor) } - guard let result = fetchDetailsForResult else { + guard let result = await fetchDetailsForResult else { fatalError("fetchDetailsForResult not set") } diff --git a/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift b/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift index da4fd58..57c1249 100644 --- a/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift +++ b/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift @@ -9,15 +9,15 @@ import Foundation import NetworkingInterfaces -final class NetworkServiceProtocolSpy: NetworkServiceProtocol, @unchecked Sendable { - enum Method: Equatable { +actor NetworkServiceProtocolSpy: NetworkServiceProtocol, Sendable { + enum Method: Equatable, Sendable { case request } - var calledMethods: [Method] = [] - var requestResult: Result<(Data, HTTPURLResponse), Error>? + private(set) var calledMethods: [Method] = [] + private(set) var requestResult: Result<(Data, HTTPURLResponse), NetworkError>? - func request(endpoint: APIEndpointProtocol) async throws -> (Data, HTTPURLResponse) { + func request(endpoint: APIEndpointProtocol) async throws(NetworkError) -> (Data, HTTPURLResponse) { calledMethods.append(.request) guard let result = requestResult else { @@ -26,4 +26,8 @@ final class NetworkServiceProtocolSpy: NetworkServiceProtocol, @unchecked Sendab return try result.get() } + + func setResponse(requestResult: Result<(Data, HTTPURLResponse), NetworkError>) async { + self.requestResult = requestResult + } } diff --git a/Projects/Home/Tests/Service/HomeServiceTests.swift b/Projects/Home/Tests/Service/HomeServiceTests.swift index 81e6cd8..658f8d9 100644 --- a/Projects/Home/Tests/Service/HomeServiceTests.swift +++ b/Projects/Home/Tests/Service/HomeServiceTests.swift @@ -40,11 +40,11 @@ struct HomeServiceTests { headerFields: nil )! - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) let result = try await sut.fetchExchangesList(page: 1, limit: 20) - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) #expect(result.count == 2) #expect(result[0].id == 1) #expect(result[0].name == "Binance") @@ -81,12 +81,12 @@ struct HomeServiceTests { headerFields: nil )! - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) await #expect(throws: expectedError) { _ = try await sut.fetchDetailsFor(ids: ["1"]) } - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) } @Test("GIVEN successful response WHEN fetchDetailsFor is called THEN returns exchange details") @@ -127,11 +127,11 @@ struct HomeServiceTests { headerFields: nil )! - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) let result = try await sut.fetchDetailsFor(ids: ["1"]) - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) #expect(result.count == 1) #expect(result[0].id == 1) #expect(result[0].name == "Binance") @@ -168,12 +168,12 @@ struct HomeServiceTests { headerFields: nil )! - networkSpy.requestResult = .success((data, httpResponse)) + await networkSpy.setResponse(requestResult: .success((data, httpResponse))) await #expect(throws: expectedError) { _ = try await sut.fetchDetailsFor(ids: ["1"]) } - #expect(networkSpy.calledMethods == [.request]) + #expect(await networkSpy.calledMethods == [.request]) } } diff --git a/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift index 680a6a9..907ae14 100644 --- a/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift +++ b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift @@ -15,7 +15,6 @@ import Testing @MainActor @Suite struct HomeViewModelTests { - @Test("GIVEN empty state WHEN loadData is called THEN fetches exchanges and updates state to empty") func testLoadDataSuccess() async throws { let (sut, doubles) = makeSut() @@ -35,8 +34,58 @@ struct HomeViewModelTests { func testLoadDataError() async throws { let (sut, doubles) = makeSut() let messageError = "Failed to Load data. Press try again later or check your connection." - let error = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) - doubles.serviceSpy.fetchExchangesListResult = .failure(error) + let serviceError = ServiceError.network(.init(timestamp: "", + errorCode: 1, + errorMessage: messageError, + elapsed: 0, + creditCount: 0)) + doubles.serviceSpy.fetchExchangesListResult = .failure(serviceError) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods.contains(.fetchExchangesList)) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.error(messageError, "1"))]) + } + + @Test("GIVEN empty state WHEN loadData fails with decodeFail THEN updates state to error with default message") + func testLoadDataDecodeFailError() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangesListResult = .failure(.decodeFail(nil)) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods.contains(.fetchExchangesList)) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.error("Failed to load data. Press try again or check your connection.", nil))]) + } + + @Test("GIVEN empty state WHEN loadData fails with requestError THEN updates state to error with request error message") + func testLoadDataRequestError() async throws { + let (sut, doubles) = makeSut() + let errorMessage = "Request timed out." + doubles.serviceSpy.fetchExchangesListResult = .failure(.requestError(errorMessage)) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods.contains(.fetchExchangesList)) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.error(errorMessage, nil))]) + } + + @Test("GIVEN empty state WHEN loadData fails with requestError nil message THEN updates state to error with default message") + func testLoadDataRequestErrorNilMessage() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangesListResult = .failure(.requestError(nil)) sut.loadData() @@ -45,7 +94,7 @@ struct HomeViewModelTests { #expect(doubles.serviceSpy.calledMethods.contains(.fetchExchangesList)) #expect(sut.numberOfItems == 0) #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), - .didUpdateState(.error(messageError, nil))]) + .didUpdateState(.error("Failed to load data. Press try again or check your connection.", nil))]) } @Test("GIVEN empty state WHEN loadData returns empty array THEN updates state to empty") diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 3cbbe40..18e7aa1 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vitor-rc1/networking-package.git", "state" : { - "revision" : "b5498ee2ab408592d4db9a9c95faec3a08de9ea2", - "version" : "1.1.0" + "revision" : "2fe707c620272b40e2c5a4fe258480bebae6e483", + "version" : "1.2.0" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 9cc58a7..8a051c8 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,6 +18,6 @@ let package = Package( dependencies: [ .package(url: "https://github.com/Swinject/Swinject.git", from: "2.9.1"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.18.9"), - .package(url: "https://github.com/vitor-rc1/networking-package.git", from: "1.1.0") + .package(url: "https://github.com/vitor-rc1/networking-package.git", from: "1.2.0") ] )