From 6742116fe38693752a51c5dfa26885d32ba7610a Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Tue, 28 May 2024 12:29:51 +0200 Subject: [PATCH 1/6] feat(Crypto list): Retrieve top list cryptos --- CryptoWidgetKitApp.xcodeproj/project.pbxproj | 40 ++++++++++++ CryptoWidgetKitApp/ContentView.swift | 19 ++++-- CryptoWidgetKitApp/CryptoWidgetKitApp.swift | 4 ++ CryptoWidgetKitApp/Models/CryptoModel.swift | 63 +++++++++++++++++++ .../Services/CryptoAPIService.swift | 34 ++++++++++ .../Services/CryptoAPIURLs.swift | 21 +++++++ .../ViewModels/CryptoViewModel.swift | 37 +++++++++++ 7 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 CryptoWidgetKitApp/Models/CryptoModel.swift create mode 100644 CryptoWidgetKitApp/Services/CryptoAPIService.swift create mode 100644 CryptoWidgetKitApp/Services/CryptoAPIURLs.swift create mode 100644 CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift diff --git a/CryptoWidgetKitApp.xcodeproj/project.pbxproj b/CryptoWidgetKitApp.xcodeproj/project.pbxproj index 2204176..531cad1 100644 --- a/CryptoWidgetKitApp.xcodeproj/project.pbxproj +++ b/CryptoWidgetKitApp.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ 4D72664E2C049D810035E348 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D72664D2C049D810035E348 /* Assets.xcassets */; }; 4D7266512C049D810035E348 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D7266502C049D810035E348 /* Preview Assets.xcassets */; }; 4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */; }; + 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */; }; + 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */; }; + 4D8251B82C05D2A200DD32A3 /* CryptoAPIURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */; }; + 4D8251BB2C05DA7500DD32A3 /* CryptoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251BA2C05DA7500DD32A3 /* CryptoViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,6 +36,10 @@ 4D7266502C049D810035E348 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 4D7266562C049D820035E348 /* CryptoWidgetKitAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CryptoWidgetKitAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoWidgetKitAppTests.swift; sourceTree = ""; }; + 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoModel.swift; sourceTree = ""; }; + 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIService.swift; sourceTree = ""; }; + 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIURLs.swift; sourceTree = ""; }; + 4D8251BA2C05DA7500DD32A3 /* CryptoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,6 +83,9 @@ children = ( 4D7266492C049D800035E348 /* CryptoWidgetKitApp.swift */, 4D72664B2C049D800035E348 /* ContentView.swift */, + 4D8251B12C05CDC400DD32A3 /* Models */, + 4D8251B92C05DA6000DD32A3 /* ViewModels */, + 4D8251B42C05D20B00DD32A3 /* Services */, 4D7266732C049D930035E348 /* Resources */, 4D72664F2C049D810035E348 /* Preview Content */, ); @@ -105,6 +116,31 @@ path = Resources; sourceTree = ""; }; + 4D8251B12C05CDC400DD32A3 /* Models */ = { + isa = PBXGroup; + children = ( + 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4D8251B42C05D20B00DD32A3 /* Services */ = { + isa = PBXGroup; + children = ( + 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */, + 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */, + ); + path = Services; + sourceTree = ""; + }; + 4D8251B92C05DA6000DD32A3 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 4D8251BA2C05DA7500DD32A3 /* CryptoViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -205,8 +241,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */, + 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */, 4D72664C2C049D800035E348 /* ContentView.swift in Sources */, + 4D8251B82C05D2A200DD32A3 /* CryptoAPIURLs.swift in Sources */, 4D72664A2C049D800035E348 /* CryptoWidgetKitApp.swift in Sources */, + 4D8251BB2C05DA7500DD32A3 /* CryptoViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CryptoWidgetKitApp/ContentView.swift b/CryptoWidgetKitApp/ContentView.swift index 1f5013f..fb33291 100644 --- a/CryptoWidgetKitApp/ContentView.swift +++ b/CryptoWidgetKitApp/ContentView.swift @@ -8,17 +8,24 @@ import SwiftUI struct ContentView: View { + + @Environment(CryptoViewModel.self) var cryptoViewModel + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationStack { + List(cryptoViewModel.cryptos, id: \.coinInfo.id) { crypto in + HStack { + Text(crypto.coinInfo.fullName) + Spacer() + Text(crypto.display?.usd.price ?? "0") + } + } + .task { await cryptoViewModel.fetch() } } - .padding() } } #Preview { ContentView() + .environment(CryptoViewModel()) } diff --git a/CryptoWidgetKitApp/CryptoWidgetKitApp.swift b/CryptoWidgetKitApp/CryptoWidgetKitApp.swift index 51428b5..6358b5f 100644 --- a/CryptoWidgetKitApp/CryptoWidgetKitApp.swift +++ b/CryptoWidgetKitApp/CryptoWidgetKitApp.swift @@ -9,9 +9,13 @@ import SwiftUI @main struct CryptoWidgetKitApp: App { + + @State var cryptoViewModel = CryptoViewModel() + var body: some Scene { WindowGroup { ContentView() + .environment(cryptoViewModel) } } } diff --git a/CryptoWidgetKitApp/Models/CryptoModel.swift b/CryptoWidgetKitApp/Models/CryptoModel.swift new file mode 100644 index 0000000..88c0c94 --- /dev/null +++ b/CryptoWidgetKitApp/Models/CryptoModel.swift @@ -0,0 +1,63 @@ +// +// CryptoModel.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - Cryptos +struct Cryptos: Codable { + let data: [Crypto] + + enum CodingKeys: String, CodingKey { + case data = "Data" + } +} + +// MARK: - Crypto +struct Crypto: Codable { + let coinInfo: CoinInfo + let display: Display? + + enum CodingKeys: String, CodingKey { + case coinInfo = "CoinInfo" + case display = "DISPLAY" + } +} + +// MARK: - CoinInfo +struct CoinInfo: Codable { + let id: String + let name: String + let fullName: String + let imageURL: String + + enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case fullName = "FullName" + case imageURL = "ImageUrl" + } +} + +// MARK: - Display +struct Display: Codable { + let usd: DisplayUsd + + enum CodingKeys: String, CodingKey { + case usd = "USD" + } +} + +// MARK: - DisplayUsd +struct DisplayUsd: Codable { + let price: String + let pct: String + + enum CodingKeys: String, CodingKey { + case price = "PRICE" + case pct = "CHANGEPCT24HOUR" + } +} diff --git a/CryptoWidgetKitApp/Services/CryptoAPIService.swift b/CryptoWidgetKitApp/Services/CryptoAPIService.swift new file mode 100644 index 0000000..65ef813 --- /dev/null +++ b/CryptoWidgetKitApp/Services/CryptoAPIService.swift @@ -0,0 +1,34 @@ +// +// CryptoAPIService.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - CryptoAPIService +class CryptoAPIService { + + // MARK: - Dependencies + private let urlSession: URLSession + + // MARK: - Init + init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + } +} + +// MARK: - Public Methods +extension CryptoAPIService { + + /// Fetches the list of cryptocurrencies + /// - Returns: An array of `Crypto` objects. + func fetchRetrieveFullList() async throws -> Cryptos { + var request = URLRequest(url: CryptoAPIURLs.fullList.url) + request.httpMethod = "GET" + request.addValue("application/json", forHTTPHeaderField: "Accept") + let (data, _) = try await urlSession.data(for: request) + return try JSONDecoder().decode(Cryptos.self, from: data) + } +} diff --git a/CryptoWidgetKitApp/Services/CryptoAPIURLs.swift b/CryptoWidgetKitApp/Services/CryptoAPIURLs.swift new file mode 100644 index 0000000..c9eb31f --- /dev/null +++ b/CryptoWidgetKitApp/Services/CryptoAPIURLs.swift @@ -0,0 +1,21 @@ +// +// CryptoAPIURLs.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - CryptoAPIURLs +enum CryptoAPIURLs { + case fullList + + var url: URL { + switch self { + case .fullList: + URL(string: "https://min-api.cryptocompare.com/data/top/totalvolfull?tsym=USD")! + } + } +} + diff --git a/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift new file mode 100644 index 0000000..55b73e1 --- /dev/null +++ b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift @@ -0,0 +1,37 @@ +// +// CryptoViewModel.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - CryptoViewModel +@Observable +final class CryptoViewModel { + + // MARK: - Properties + private(set) var cryptos: [Crypto] = [] + + // MARK: - Dependencies + private let apiService: CryptoAPIService + + // MARK: - Init + init(apiService: CryptoAPIService = CryptoAPIService()) { + self.apiService = apiService + } +} + +// MARK: - Public Methods +extension CryptoViewModel { + + /// Fetch the cryptos from API and update the cryptos array + func fetch() async { + do { + cryptos = try await apiService.fetchRetrieveFullList().data + } catch { + print(error) + } + } +} From 096cddcd9fa317181d5dc7ff138cc70461ad0275 Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Tue, 28 May 2024 13:25:39 +0200 Subject: [PATCH 2/6] Add tests --- CryptoWidgetKitApp.xcodeproj/project.pbxproj | 58 +++++++-- .../CryptoAPIService.swift | 16 +-- .../Utils/CryptoAPIEndpoint.swift} | 10 +- .../Network/Utils/HTTPMethod.swift | 16 +++ .../Network/Utils/URLRequest+.swift | 22 ++++ .../Network/Utils/URLSessionProtocol.swift | 20 +++ .../Mocks/MockURLSession.swift | 42 +++++++ .../Network/CryptoAPIServiceTests.swift | 114 ++++++++++++++++++ 8 files changed, 280 insertions(+), 18 deletions(-) rename CryptoWidgetKitApp/{Services => Network}/CryptoAPIService.swift (60%) rename CryptoWidgetKitApp/{Services/CryptoAPIURLs.swift => Network/Utils/CryptoAPIEndpoint.swift} (73%) create mode 100644 CryptoWidgetKitApp/Network/Utils/HTTPMethod.swift create mode 100644 CryptoWidgetKitApp/Network/Utils/URLRequest+.swift create mode 100644 CryptoWidgetKitApp/Network/Utils/URLSessionProtocol.swift create mode 100644 CryptoWidgetKitAppTests/Mocks/MockURLSession.swift create mode 100644 CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift diff --git a/CryptoWidgetKitApp.xcodeproj/project.pbxproj b/CryptoWidgetKitApp.xcodeproj/project.pbxproj index 531cad1..a273ccb 100644 --- a/CryptoWidgetKitApp.xcodeproj/project.pbxproj +++ b/CryptoWidgetKitApp.xcodeproj/project.pbxproj @@ -14,8 +14,13 @@ 4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */; }; 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */; }; 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */; }; - 4D8251B82C05D2A200DD32A3 /* CryptoAPIURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */; }; + 4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */; }; 4D8251BB2C05DA7500DD32A3 /* CryptoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251BA2C05DA7500DD32A3 /* CryptoViewModel.swift */; }; + 4D8251BF2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251BE2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift */; }; + 4D8251C32C05EAA800DD32A3 /* URLRequest+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C22C05EAA800DD32A3 /* URLRequest+.swift */; }; + 4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */; }; + 4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */; }; + 4D8251CA2C05EC4500DD32A3 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,8 +43,13 @@ 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoWidgetKitAppTests.swift; sourceTree = ""; }; 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoModel.swift; sourceTree = ""; }; 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIService.swift; sourceTree = ""; }; - 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIURLs.swift; sourceTree = ""; }; + 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIEndpoint.swift; sourceTree = ""; }; 4D8251BA2C05DA7500DD32A3 /* CryptoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViewModel.swift; sourceTree = ""; }; + 4D8251BE2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIServiceTests.swift; sourceTree = ""; }; + 4D8251C22C05EAA800DD32A3 /* URLRequest+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+.swift"; sourceTree = ""; }; + 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; + 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,7 +95,7 @@ 4D72664B2C049D800035E348 /* ContentView.swift */, 4D8251B12C05CDC400DD32A3 /* Models */, 4D8251B92C05DA6000DD32A3 /* ViewModels */, - 4D8251B42C05D20B00DD32A3 /* Services */, + 4D8251B42C05D20B00DD32A3 /* Network */, 4D7266732C049D930035E348 /* Resources */, 4D72664F2C049D810035E348 /* Preview Content */, ); @@ -103,6 +113,8 @@ 4D7266592C049D820035E348 /* CryptoWidgetKitAppTests */ = { isa = PBXGroup; children = ( + 4D8251C02C05E9F100DD32A3 /* Mocks */, + 4D8251C12C05E9FE00DD32A3 /* Network */, 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */, ); path = CryptoWidgetKitAppTests; @@ -124,13 +136,13 @@ path = Models; sourceTree = ""; }; - 4D8251B42C05D20B00DD32A3 /* Services */ = { + 4D8251B42C05D20B00DD32A3 /* Network */ = { isa = PBXGroup; children = ( 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */, - 4D8251B72C05D2A100DD32A3 /* CryptoAPIURLs.swift */, + 4D8251C42C05EAAB00DD32A3 /* Utils */, ); - path = Services; + path = Network; sourceTree = ""; }; 4D8251B92C05DA6000DD32A3 /* ViewModels */ = { @@ -141,6 +153,33 @@ path = ViewModels; sourceTree = ""; }; + 4D8251C02C05E9F100DD32A3 /* Mocks */ = { + isa = PBXGroup; + children = ( + 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 4D8251C12C05E9FE00DD32A3 /* Network */ = { + isa = PBXGroup; + children = ( + 4D8251BE2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift */, + ); + path = Network; + sourceTree = ""; + }; + 4D8251C42C05EAAB00DD32A3 /* Utils */ = { + isa = PBXGroup; + children = ( + 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */, + 4D8251C22C05EAA800DD32A3 /* URLRequest+.swift */, + 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */, + 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */, + ); + path = Utils; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -241,11 +280,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D8251CA2C05EC4500DD32A3 /* URLSessionProtocol.swift in Sources */, 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */, 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */, 4D72664C2C049D800035E348 /* ContentView.swift in Sources */, - 4D8251B82C05D2A200DD32A3 /* CryptoAPIURLs.swift in Sources */, + 4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */, + 4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */, 4D72664A2C049D800035E348 /* CryptoWidgetKitApp.swift in Sources */, + 4D8251C32C05EAA800DD32A3 /* URLRequest+.swift in Sources */, 4D8251BB2C05DA7500DD32A3 /* CryptoViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -254,7 +296,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */, 4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */, + 4D8251BF2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CryptoWidgetKitApp/Services/CryptoAPIService.swift b/CryptoWidgetKitApp/Network/CryptoAPIService.swift similarity index 60% rename from CryptoWidgetKitApp/Services/CryptoAPIService.swift rename to CryptoWidgetKitApp/Network/CryptoAPIService.swift index 65ef813..eada098 100644 --- a/CryptoWidgetKitApp/Services/CryptoAPIService.swift +++ b/CryptoWidgetKitApp/Network/CryptoAPIService.swift @@ -11,11 +11,15 @@ import Foundation class CryptoAPIService { // MARK: - Dependencies - private let urlSession: URLSession + private let session: URLSessionProtocol // MARK: - Init - init(urlSession: URLSession = .shared) { - self.urlSession = urlSession + init(session: URLSessionProtocol) { + self.session = session + } + + convenience init() { + self.init(session: URLSession.shared) } } @@ -25,10 +29,8 @@ extension CryptoAPIService { /// Fetches the list of cryptocurrencies /// - Returns: An array of `Crypto` objects. func fetchRetrieveFullList() async throws -> Cryptos { - var request = URLRequest(url: CryptoAPIURLs.fullList.url) - request.httpMethod = "GET" - request.addValue("application/json", forHTTPHeaderField: "Accept") - let (data, _) = try await urlSession.data(for: request) + let request: URLRequest = .init(endpoint: .fullList, method: .get) + let (data, _) = try await session.data(for: request) return try JSONDecoder().decode(Cryptos.self, from: data) } } diff --git a/CryptoWidgetKitApp/Services/CryptoAPIURLs.swift b/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift similarity index 73% rename from CryptoWidgetKitApp/Services/CryptoAPIURLs.swift rename to CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift index c9eb31f..d2a86ff 100644 --- a/CryptoWidgetKitApp/Services/CryptoAPIURLs.swift +++ b/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift @@ -1,5 +1,5 @@ // -// CryptoAPIURLs.swift +// CryptoAPIEndpoint.swift // CryptoWidgetKitApp // // Created by Jose Jesus Torronteras Hernandez on 28/5/24. @@ -7,9 +7,12 @@ import Foundation -// MARK: - CryptoAPIURLs -enum CryptoAPIURLs { +// MARK: - CryptoAPIEndpoint +enum CryptoAPIEndpoint { case fullList +} + +extension CryptoAPIEndpoint { var url: URL { switch self { @@ -18,4 +21,3 @@ enum CryptoAPIURLs { } } } - diff --git a/CryptoWidgetKitApp/Network/Utils/HTTPMethod.swift b/CryptoWidgetKitApp/Network/Utils/HTTPMethod.swift new file mode 100644 index 0000000..40f3450 --- /dev/null +++ b/CryptoWidgetKitApp/Network/Utils/HTTPMethod.swift @@ -0,0 +1,16 @@ +// +// HTTPMethod.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - HTTPMethod +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} diff --git a/CryptoWidgetKitApp/Network/Utils/URLRequest+.swift b/CryptoWidgetKitApp/Network/Utils/URLRequest+.swift new file mode 100644 index 0000000..8a8f2a5 --- /dev/null +++ b/CryptoWidgetKitApp/Network/Utils/URLRequest+.swift @@ -0,0 +1,22 @@ +// +// URLRequest+.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - URLRequest Extension +extension URLRequest { + + /// Custom URLRequest init + /// - Parameters: + /// - endpoint: request endpoint + /// - method: request method + init(endpoint: CryptoAPIEndpoint, method: HTTPMethod) { + self.init(url: endpoint.url) + httpMethod = method.rawValue + addValue("application/json", forHTTPHeaderField: "Accept") + } +} diff --git a/CryptoWidgetKitApp/Network/Utils/URLSessionProtocol.swift b/CryptoWidgetKitApp/Network/Utils/URLSessionProtocol.swift new file mode 100644 index 0000000..c5d8aed --- /dev/null +++ b/CryptoWidgetKitApp/Network/Utils/URLSessionProtocol.swift @@ -0,0 +1,20 @@ +// +// URLSessionProtocol.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation + +// MARK: - URLSessionProtocol +protocol URLSessionProtocol { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +// MARK: - URLSession +extension URLSession: URLSessionProtocol { + func protocolData(for request: URLRequest) async throws -> (Data, URLResponse) { + return try await self.data(for: request) + } +} diff --git a/CryptoWidgetKitAppTests/Mocks/MockURLSession.swift b/CryptoWidgetKitAppTests/Mocks/MockURLSession.swift new file mode 100644 index 0000000..2d4e3a9 --- /dev/null +++ b/CryptoWidgetKitAppTests/Mocks/MockURLSession.swift @@ -0,0 +1,42 @@ +// +// MockURLSession.swift +// CryptoWidgetKitAppTests +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import Foundation +@testable import CryptoWidgetKitApp + +// MARK: - MockURLSession +class MockURLSession: URLSessionProtocol { + + // MARK: - Properties + var data: Data? + var response: URLResponse? + var error: Error? +} + +// MARK: - Public Methods +extension MockURLSession { + + // Simulates a network data task and returns the result. + /// + /// - Parameter request: The URL request to be performed. + /// - Returns: A tuple containing the data and the URL response. + /// - Throws: An error if any of the following conditions occur: + /// - A simulated error is set (`error` property is not nil). + /// - The data is nil. + /// - The response is not an HTTPURLResponse or the status code is not in the 200-299 range. + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if let error = error { + throw error + } + guard let response = response as? HTTPURLResponse, + 200 ... 299 ~= response.statusCode else { + throw URLError(.badServerResponse) + } + guard let data = data else { throw NSError(domain: "", code: 0) } + return (data, response) + } +} diff --git a/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift b/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift new file mode 100644 index 0000000..899aff2 --- /dev/null +++ b/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift @@ -0,0 +1,114 @@ +// +// CryptoAPIServiceTests.swift +// CryptoWidgetKitAppTests +// +// Created by Jose Jesus Torronteras Hernandez on 28/5/24. +// + +import XCTest +@testable import CryptoWidgetKitApp + +// MARK: - CryptoAPIServiceTests +final class CryptoAPIServiceTests: XCTestCase { + + private var session: MockURLSession! + private var sut: CryptoAPIService! + + override func setUpWithError() throws { + try super.setUpWithError() + session = MockURLSession() + sut = CryptoAPIService(session: session) + } + + override func tearDownWithError() throws { + session = nil + sut = nil + try super.tearDownWithError() + } +} + +// MARK: - Tests +extension CryptoAPIServiceTests { + + func test_fetchRetrieveFullList_success() async throws { + // given + session.data = fullListData + session.response = successFullListResponse + let expectation = XCTestExpectation(description: "Fetch full list") + + // when + do { + let result = try await sut.fetchRetrieveFullList() + // then + XCTAssertEqual(result.data.count, 2) + XCTAssertEqual(result.data[0].coinInfo.fullName, "Bitcoin") + XCTAssertEqual(result.data[1].coinInfo.fullName, "Ethereum") + } catch { + XCTFail("Unexpected error: \(error)") + } + + expectation.fulfill() + await XCTWaiter().fulfillment(of: [expectation], timeout: 5.0) + } + + func test_fetchRetrieveFullList_failureHttpResponse() async throws { + // given + session.data = Data() + session.response = badFullListResponse + let expectation = XCTestExpectation(description: "Fetch full list") + + // when + do { + _ = try await sut.fetchRetrieveFullList() + XCTFail("Expected to throw error, but succeeded") + } catch { + // then + XCTAssertEqual(error as? URLError, URLError(.badServerResponse)) + } + + expectation.fulfill() + await XCTWaiter().fulfillment(of: [expectation], timeout: 5.0) + } + + func test_fetchRetrieveFullList_failureErrorConnection() async throws { + // given + session.error = URLError(.notConnectedToInternet) + let expectation = XCTestExpectation(description: "Fetch full list") + + // when + do { + _ = try await sut.fetchRetrieveFullList() + XCTFail("Expected to throw error, but succeeded") + } catch { + // then + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + + expectation.fulfill() + await XCTWaiter().fulfillment(of: [expectation], timeout: 5.0) + } +} + +// MARK: - Data +fileprivate extension CryptoAPIServiceTests { + + var fullListData: Data { + Data("{\"Data\":[{\"CoinInfo\":{\"Id\":\"1182\",\"Name\":\"BTC\",\"FullName\":\"Bitcoin\",\"Internal\":\"BTC\",\"ImageUrl\":\"/media/37746251/btc.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 68,512.1\",\"CHANGEPCT24HOUR\":\"-0.04\"}}},{\"CoinInfo\":{\"Id\":\"7605\",\"Name\":\"ETH\",\"FullName\":\"Ethereum\",\"Internal\":\"ETH\",\"ImageUrl\":\"/media/37746238/eth.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 3,901.48\",\"CHANGEPCT24HOUR\":\"-0.07\"}}}]}".utf8) + } + + var successFullListResponse: HTTPURLResponse { + HTTPURLResponse( + url: CryptoAPIEndpoint.fullList.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + } + + var badFullListResponse: HTTPURLResponse { + HTTPURLResponse( + url: CryptoAPIEndpoint.fullList.url, + statusCode: 500, + httpVersion: nil, + headerFields: nil)! + } +} From df619d18d80cc455cc813d56b64058a176168a9a Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Tue, 28 May 2024 14:03:50 +0200 Subject: [PATCH 3/6] feat(Crypto List): Add loading, error and refresh to view --- CryptoWidgetKitApp/ContentView.swift | 21 +++++++++++++++++++ .../ViewModels/CryptoViewModel.swift | 21 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CryptoWidgetKitApp/ContentView.swift b/CryptoWidgetKitApp/ContentView.swift index fb33291..4457347 100644 --- a/CryptoWidgetKitApp/ContentView.swift +++ b/CryptoWidgetKitApp/ContentView.swift @@ -12,6 +12,7 @@ struct ContentView: View { @Environment(CryptoViewModel.self) var cryptoViewModel var body: some View { + @Bindable var cryptoViewModel = cryptoViewModel NavigationStack { List(cryptoViewModel.cryptos, id: \.coinInfo.id) { crypto in HStack { @@ -20,7 +21,27 @@ struct ContentView: View { Text(crypto.display?.usd.price ?? "0") } } + .redacted(reason: cryptoViewModel.isLoading ? .placeholder : []) .task { await cryptoViewModel.fetch() } + .refreshable { await cryptoViewModel.fetch() } + .alert(isPresented: $cryptoViewModel.showError, content: { + Alert(title: Text("An error occurred, try again later"), + dismissButton: .default( + Text("Retry"), + action: { + Task { + await cryptoViewModel.fetch() + } + })) + }) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Text("API Cache 120 seconds") + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + } } } } diff --git a/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift index 55b73e1..faf940a 100644 --- a/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift +++ b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift @@ -13,13 +13,17 @@ final class CryptoViewModel { // MARK: - Properties private(set) var cryptos: [Crypto] = [] + private(set) var isLoading: Bool = true + var showError: Bool = false // MARK: - Dependencies private let apiService: CryptoAPIService // MARK: - Init - init(apiService: CryptoAPIService = CryptoAPIService()) { + init(apiService: CryptoAPIService = CryptoAPIService(), + initialState: [Crypto] = .mock) { self.apiService = apiService + self.cryptos = initialState } } @@ -28,10 +32,25 @@ extension CryptoViewModel { /// Fetch the cryptos from API and update the cryptos array func fetch() async { + isLoading = true do { + sleep(3) cryptos = try await apiService.fetchRetrieveFullList().data + isLoading = false } catch { print(error) + showError = true + } + } +} + +// MARK: - Crypto Extension +extension [Crypto] { + + /// Mock Crypto + static var mock: [Crypto] { + (0..<5).map { + Crypto(coinInfo: CoinInfo(id: $0.description, name: "name", fullName: "fullName", imageURL: "imageURL"), display: nil) } } } From 823f34a0109873a5baf72db5a5558c9f521afb4f Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Tue, 4 Jun 2024 12:24:22 +0200 Subject: [PATCH 4/6] feat(Crypto List): Load more data The commit adds the View+ and CryptoViewModelTests.swift files to the project. These files were missing and are now included to enhance the functionality and test coverage of the CryptoWidgetKitApp. Also change CryptoWidgetKitApp to not include views --- .../Views/Extensions/View+.swift | 8 +++++ .../CryptoViewModelTests.swift | 8 +++++ .../CryptoWidgetKitAppTests.swift | 36 ------------------- .../Extensions/XCTestCase+.swift | 8 +++++ 4 files changed, 24 insertions(+), 36 deletions(-) create mode 100644 CryptoWidgetKitApp/Views/Extensions/View+.swift create mode 100644 CryptoWidgetKitAppTests/CryptoViewModelTests.swift delete mode 100644 CryptoWidgetKitAppTests/CryptoWidgetKitAppTests.swift create mode 100644 CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift diff --git a/CryptoWidgetKitApp/Views/Extensions/View+.swift b/CryptoWidgetKitApp/Views/Extensions/View+.swift new file mode 100644 index 0000000..aa528c3 --- /dev/null +++ b/CryptoWidgetKitApp/Views/Extensions/View+.swift @@ -0,0 +1,8 @@ +// +// View+.swift +// CryptoWidgetKitApp +// +// Created by Jose Jesus Torronteras Hernandez on 30/5/24. +// + +import Foundation diff --git a/CryptoWidgetKitAppTests/CryptoViewModelTests.swift b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift new file mode 100644 index 0000000..567d642 --- /dev/null +++ b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift @@ -0,0 +1,8 @@ +// +// CryptoViewModelTests.swift +// CryptoWidgetKitAppTests +// +// Created by Jose Jesus Torronteras Hernandez on 3/6/24. +// + +import Foundation diff --git a/CryptoWidgetKitAppTests/CryptoWidgetKitAppTests.swift b/CryptoWidgetKitAppTests/CryptoWidgetKitAppTests.swift deleted file mode 100644 index 70ce1e3..0000000 --- a/CryptoWidgetKitAppTests/CryptoWidgetKitAppTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CryptoWidgetKitAppTests.swift -// CryptoWidgetKitAppTests -// -// Created by Jose Jesus Torronteras Hernandez on 27/1/24. -// - -import XCTest -@testable import CryptoWidgetKitApp - -final class CryptoWidgetKitAppTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift b/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift new file mode 100644 index 0000000..e267ac6 --- /dev/null +++ b/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift @@ -0,0 +1,8 @@ +// +// XCTestCase+.swift +// CryptoWidgetKitAppTests +// +// Created by Jose Jesus Torronteras Hernandez on 3/6/24. +// + +import Foundation From cab65b3c2945ebf370c04c82b0f32b477d003f77 Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Tue, 4 Jun 2024 12:24:52 +0200 Subject: [PATCH 5/6] feat(Crypto List): Load more data --- CryptoWidgetKitApp.xcodeproj/project.pbxproj | 40 +++++- CryptoWidgetKitApp/ContentView.swift | 46 ++++--- CryptoWidgetKitApp/CryptoWidgetKitApp.swift | 25 +++- .../Network/CryptoAPIService.swift | 13 +- .../Network/Utils/CryptoAPIEndpoint.swift | 12 +- .../ViewModels/CryptoViewModel.swift | 74 ++++++++-- .../Views/Extensions/View+.swift | 14 +- .../CryptoViewModelTests.swift | 130 +++++++++++++++++- .../Extensions/XCTestCase+.swift | 45 ++++++ .../Network/CryptoAPIServiceTests.swift | 10 +- 10 files changed, 367 insertions(+), 42 deletions(-) diff --git a/CryptoWidgetKitApp.xcodeproj/project.pbxproj b/CryptoWidgetKitApp.xcodeproj/project.pbxproj index a273ccb..718ed54 100644 --- a/CryptoWidgetKitApp.xcodeproj/project.pbxproj +++ b/CryptoWidgetKitApp.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 4D72664C2C049D800035E348 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72664B2C049D800035E348 /* ContentView.swift */; }; 4D72664E2C049D810035E348 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D72664D2C049D810035E348 /* Assets.xcassets */; }; 4D7266512C049D810035E348 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D7266502C049D810035E348 /* Preview Assets.xcassets */; }; - 4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */; }; 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */; }; 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */; }; 4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */; }; @@ -21,6 +20,9 @@ 4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */; }; 4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */; }; 4D8251CA2C05EC4500DD32A3 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */; }; + 4D8251D12C0879E800DD32A3 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D02C0879E800DD32A3 /* View+.swift */; }; + 4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */; }; + 4D8251D62C0DBAFC00DD32A3 /* XCTestCase+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,7 +42,6 @@ 4D72664D2C049D810035E348 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4D7266502C049D810035E348 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 4D7266562C049D820035E348 /* CryptoWidgetKitAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CryptoWidgetKitAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoWidgetKitAppTests.swift; sourceTree = ""; }; 4D8251B22C05CDDB00DD32A3 /* CryptoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoModel.swift; sourceTree = ""; }; 4D8251B52C05D21D00DD32A3 /* CryptoAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIService.swift; sourceTree = ""; }; 4D8251B72C05D2A100DD32A3 /* CryptoAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoAPIEndpoint.swift; sourceTree = ""; }; @@ -50,6 +51,9 @@ 4D8251C52C05EAD700DD32A3 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; 4D8251C72C05EBBB00DD32A3 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; + 4D8251D02C0879E800DD32A3 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; }; + 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViewModelTests.swift; sourceTree = ""; }; + 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,6 +99,7 @@ 4D72664B2C049D800035E348 /* ContentView.swift */, 4D8251B12C05CDC400DD32A3 /* Models */, 4D8251B92C05DA6000DD32A3 /* ViewModels */, + 4D8251CC2C0878B900DD32A3 /* Views */, 4D8251B42C05D20B00DD32A3 /* Network */, 4D7266732C049D930035E348 /* Resources */, 4D72664F2C049D810035E348 /* Preview Content */, @@ -113,9 +118,10 @@ 4D7266592C049D820035E348 /* CryptoWidgetKitAppTests */ = { isa = PBXGroup; children = ( + 4D8251D42C0DBAEC00DD32A3 /* Extensions */, 4D8251C02C05E9F100DD32A3 /* Mocks */, 4D8251C12C05E9FE00DD32A3 /* Network */, - 4D72665A2C049D820035E348 /* CryptoWidgetKitAppTests.swift */, + 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */, ); path = CryptoWidgetKitAppTests; sourceTree = ""; @@ -180,6 +186,30 @@ path = Utils; sourceTree = ""; }; + 4D8251CC2C0878B900DD32A3 /* Views */ = { + isa = PBXGroup; + children = ( + 4D8251CF2C0879D400DD32A3 /* Extensions */, + ); + path = Views; + sourceTree = ""; + }; + 4D8251CF2C0879D400DD32A3 /* Extensions */ = { + isa = PBXGroup; + children = ( + 4D8251D02C0879E800DD32A3 /* View+.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 4D8251D42C0DBAEC00DD32A3 /* Extensions */ = { + isa = PBXGroup; + children = ( + 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -284,6 +314,7 @@ 4D8251B32C05CDDB00DD32A3 /* CryptoModel.swift in Sources */, 4D8251B62C05D21D00DD32A3 /* CryptoAPIService.swift in Sources */, 4D72664C2C049D800035E348 /* ContentView.swift in Sources */, + 4D8251D12C0879E800DD32A3 /* View+.swift in Sources */, 4D8251B82C05D2A200DD32A3 /* CryptoAPIEndpoint.swift in Sources */, 4D8251C62C05EAD700DD32A3 /* HTTPMethod.swift in Sources */, 4D72664A2C049D800035E348 /* CryptoWidgetKitApp.swift in Sources */, @@ -297,7 +328,8 @@ buildActionMask = 2147483647; files = ( 4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */, - 4D72665B2C049D820035E348 /* CryptoWidgetKitAppTests.swift in Sources */, + 4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */, + 4D8251D62C0DBAFC00DD32A3 /* XCTestCase+.swift in Sources */, 4D8251BF2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CryptoWidgetKitApp/ContentView.swift b/CryptoWidgetKitApp/ContentView.swift index 4457347..d0242d2 100644 --- a/CryptoWidgetKitApp/ContentView.swift +++ b/CryptoWidgetKitApp/ContentView.swift @@ -14,26 +14,38 @@ struct ContentView: View { var body: some View { @Bindable var cryptoViewModel = cryptoViewModel NavigationStack { - List(cryptoViewModel.cryptos, id: \.coinInfo.id) { crypto in - HStack { - Text(crypto.coinInfo.fullName) - Spacer() - Text(crypto.display?.usd.price ?? "0") + List { + ForEach(cryptoViewModel.cryptos, id: \.coinInfo.id){ crypto in + HStack { + Text(crypto.coinInfo.fullName) + Spacer() + Text(crypto.display?.usd.price ?? "0") + } } - } - .redacted(reason: cryptoViewModel.isLoading ? .placeholder : []) - .task { await cryptoViewModel.fetch() } - .refreshable { await cryptoViewModel.fetch() } - .alert(isPresented: $cryptoViewModel.showError, content: { - Alert(title: Text("An error occurred, try again later"), - dismissButton: .default( - Text("Retry"), - action: { + if !cryptoViewModel.showSkeleton { + Section { + Button(action: { Task { - await cryptoViewModel.fetch() + await cryptoViewModel.loadMoreData() + } + }, label: { + if cryptoViewModel.isLoading { + ProgressView() + } else { + Text("Load more") } - })) - }) + }) + } + } + } + .redacted(reason: cryptoViewModel.showSkeleton ? .placeholder : []) + .task { await cryptoViewModel.fetchInitialData() } + .refreshable { await cryptoViewModel.refreshData() } + .errorAlert(isPresented: $cryptoViewModel.showError) { + Task { + await cryptoViewModel.refreshData() + } + } .toolbar { ToolbarItemGroup(placement: .bottomBar) { Text("API Cache 120 seconds") diff --git a/CryptoWidgetKitApp/CryptoWidgetKitApp.swift b/CryptoWidgetKitApp/CryptoWidgetKitApp.swift index 6358b5f..a5cced4 100644 --- a/CryptoWidgetKitApp/CryptoWidgetKitApp.swift +++ b/CryptoWidgetKitApp/CryptoWidgetKitApp.swift @@ -8,7 +8,23 @@ import SwiftUI @main -struct CryptoWidgetKitApp: App { +struct CryptoWidgetKitApp { + + static func main() { + guard isProduction() else { + TestApp.main() + return + } + ProductionApp.main() + } + + private static func isProduction() -> Bool { + return NSClassFromString("XCTestCase") == nil + } +} + +// MARK: - ProductionApp +struct ProductionApp: App { @State var cryptoViewModel = CryptoViewModel() @@ -19,3 +35,10 @@ struct CryptoWidgetKitApp: App { } } } + +// MARK: - TestApp +struct TestApp: App { + var body: some Scene { + WindowGroup {} + } +} diff --git a/CryptoWidgetKitApp/Network/CryptoAPIService.swift b/CryptoWidgetKitApp/Network/CryptoAPIService.swift index eada098..73ecc45 100644 --- a/CryptoWidgetKitApp/Network/CryptoAPIService.swift +++ b/CryptoWidgetKitApp/Network/CryptoAPIService.swift @@ -6,6 +6,7 @@ // import Foundation +import os // MARK: - CryptoAPIService class CryptoAPIService { @@ -13,6 +14,12 @@ class CryptoAPIService { // MARK: - Dependencies private let session: URLSessionProtocol + // MARK: - Properties + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: CryptoAPIService.self) + ) + // MARK: - Init init(session: URLSessionProtocol) { self.session = session @@ -28,9 +35,11 @@ extension CryptoAPIService { /// Fetches the list of cryptocurrencies /// - Returns: An array of `Crypto` objects. - func fetchRetrieveFullList() async throws -> Cryptos { - let request: URLRequest = .init(endpoint: .fullList, method: .get) + func fetchRetrieveTopList(page: Int? = nil) async throws -> Cryptos { + let request: URLRequest = .init(endpoint: .topList(page: page), method: .get) + Self.logger.debug("Fetching top list with request: \(request)") let (data, _) = try await session.data(for: request) + Self.logger.debug("Received response with data: \(data)") return try JSONDecoder().decode(Cryptos.self, from: data) } } diff --git a/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift b/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift index d2a86ff..a2b31d6 100644 --- a/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift +++ b/CryptoWidgetKitApp/Network/Utils/CryptoAPIEndpoint.swift @@ -9,15 +9,21 @@ import Foundation // MARK: - CryptoAPIEndpoint enum CryptoAPIEndpoint { - case fullList + case topList(page: Int?) } extension CryptoAPIEndpoint { var url: URL { switch self { - case .fullList: - URL(string: "https://min-api.cryptocompare.com/data/top/totalvolfull?tsym=USD")! + case .topList(let page): + var components = URLComponents(string: "https://min-api.cryptocompare.com/data/top/totalvolfull")! + var queryItems = [URLQueryItem(name: "tsym", value: "USD")] + if let page = page { + queryItems.append(URLQueryItem(name: "page", value: String(page))) + } + components.queryItems = queryItems + return components.url! } } } diff --git a/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift index faf940a..73f61f3 100644 --- a/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift +++ b/CryptoWidgetKitApp/ViewModels/CryptoViewModel.swift @@ -11,11 +11,21 @@ import Foundation @Observable final class CryptoViewModel { - // MARK: - Properties + // MARK: - Published Properties private(set) var cryptos: [Crypto] = [] - private(set) var isLoading: Bool = true + private(set) var showSkeleton: Bool = false + private(set) var isLoading: Bool = false var showError: Bool = false + // MARK: - Private Properties + private var page: Int = 0 { + didSet { + if page < 0 { + page = 0 + } + } + } + // MARK: - Dependencies private let apiService: CryptoAPIService @@ -31,15 +41,63 @@ final class CryptoViewModel { extension CryptoViewModel { /// Fetch the cryptos from API and update the cryptos array - func fetch() async { - isLoading = true + func fetchInitialData() async { + showSkeleton = true + defer { + if !showError { + showSkeleton = false + } + } + await fetchData(page: nil) + } + + /// Load more cryptos from API and append to the cryptos array + func loadMoreData() async { + page += 1 + await fetchData(page: page) + } + + /// Refresh all data from API and update the cryptos array + func refreshData() async { + showSkeleton = true + defer { + if !showError { + showSkeleton = false + } + } + var allData: [Crypto] = [] + for p in 0...page { + do { + let data = try await apiService.fetchRetrieveTopList(page: p).data + allData.append(contentsOf: data) + } catch { + showError = true + return + } + } + cryptos = allData + } +} + +// MARK: - Private Methods +private extension CryptoViewModel { + + /// Method to fetch data from API and update the cryptos array + func fetchData(page: Int?) async { + if page != nil { + isLoading = true + } + defer { isLoading = false } do { - sleep(3) - cryptos = try await apiService.fetchRetrieveFullList().data - isLoading = false + let data = try await apiService.fetchRetrieveTopList(page: page).data + if page != nil && page! > 0 { + cryptos.append(contentsOf: data) + } else { + cryptos = data + } } catch { - print(error) showError = true + self.page = (page ?? 0) - 1 } } } diff --git a/CryptoWidgetKitApp/Views/Extensions/View+.swift b/CryptoWidgetKitApp/Views/Extensions/View+.swift index aa528c3..8aae045 100644 --- a/CryptoWidgetKitApp/Views/Extensions/View+.swift +++ b/CryptoWidgetKitApp/Views/Extensions/View+.swift @@ -5,4 +5,16 @@ // Created by Jose Jesus Torronteras Hernandez on 30/5/24. // -import Foundation +import SwiftUI + +extension View { + + func errorAlert(isPresented: Binding, retryAction: @escaping () -> Void) -> some View { + alert(isPresented: isPresented) { + Alert( + title: Text("An error occurred, try again later"), + dismissButton: .default(Text("Retry"), action: retryAction) + ) + } + } +} diff --git a/CryptoWidgetKitAppTests/CryptoViewModelTests.swift b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift index 567d642..cbed2d7 100644 --- a/CryptoWidgetKitAppTests/CryptoViewModelTests.swift +++ b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift @@ -5,4 +5,132 @@ // Created by Jose Jesus Torronteras Hernandez on 3/6/24. // -import Foundation +import XCTest +@testable import CryptoWidgetKitApp + +// MARK: - CryptoViewModelTests +final class CryptoViewModelTests: XCTestCase { + + private var session: MockURLSession! + private var apiService: CryptoAPIService! + private var sut: CryptoViewModel! + + override func setUp() { + super.setUp() + session = MockURLSession() + apiService = CryptoAPIService(session: session) + sut = CryptoViewModel(apiService: apiService) + } + + override func tearDown() { + session = nil + apiService = nil + sut = nil + super.tearDown() + } +} + +// MARK: - Tests +extension CryptoViewModelTests { + + func test_fetchInitialData_success() async { + // Given + session.data = fullListData + session.response = successFullListResponse + + // When + Task { await sut.fetchInitialData() } + + // Then + await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + XCTAssertFalse(sut.showSkeleton) + XCTAssertFalse(sut.isLoading) + XCTAssertFalse(sut.showError) + await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) + XCTAssertEqual(sut.cryptos.count, 2) + } + + func test_loadMoreData_success() async { + // Given + session.data = fullListData + session.response = successFullListResponse + await sut.fetchInitialData() + + // When + session.data = loadMoreData + session.response = successLoadMoreDataResponse + await sut.loadMoreData() + + // Then + XCTAssertFalse(sut.showSkeleton) + await awaitChanges(to: \.isLoading, on: sut, timeout: 5.0) + XCTAssertFalse(sut.isLoading) + await awaitChanges(to: \.showError, on: sut, timeout: 5.0) + XCTAssertFalse(sut.showError) + await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) + XCTAssertEqual(sut.cryptos.count, 4) + } + + func test_refreshData_success() async { + // Given + session.data = fullListData + session.response = successFullListResponse + await sut.fetchInitialData() + + // When + await sut.refreshData() + + // Then + await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + XCTAssertFalse(sut.showSkeleton) + await awaitChanges(to: \.isLoading, on: sut, timeout: 5.0) + XCTAssertFalse(sut.isLoading) + XCTAssertFalse(sut.showError) + await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) + XCTAssertEqual(sut.cryptos.count, 2) + } + + func test_fetchData_failure() async { + // Given + session.error = URLError(.badServerResponse) + + // When + await sut.fetchInitialData() + + // Then + await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + XCTAssertTrue(sut.showSkeleton) + XCTAssertFalse(sut.isLoading) + await awaitChanges(to: \.showError, on: sut, timeout: 5.0) + XCTAssertTrue(sut.showError) + XCTAssertEqual(sut.cryptos.count, [Crypto].mock.count) + } +} + +// MARK: - Data +fileprivate extension CryptoViewModelTests { + + var fullListData: Data { + Data("{\"Data\":[{\"CoinInfo\":{\"Id\":\"1182\",\"Name\":\"BTC\",\"FullName\":\"Bitcoin\",\"Internal\":\"BTC\",\"ImageUrl\":\"/media/37746251/btc.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 68,512.1\",\"CHANGEPCT24HOUR\":\"-0.04\"}}},{\"CoinInfo\":{\"Id\":\"7605\",\"Name\":\"ETH\",\"FullName\":\"Ethereum\",\"Internal\":\"ETH\",\"ImageUrl\":\"/media/37746238/eth.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 3,901.48\",\"CHANGEPCT24HOUR\":\"-0.07\"}}}]}".utf8) + } + + var loadMoreData: Data { + Data("{\"Data\":[{\"CoinInfo\":{\"Id\":\"1042\",\"Name\":\"LTC\",\"FullName\":\"Litecoin\",\"Internal\":\"LTC\",\"ImageUrl\":\"/media/37746257/ltc.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 181.1\",\"CHANGEPCT24HOUR\":\"-0.04\"}}},{\"CoinInfo\":{\"Id\":\"02cf57e2-9b3c-4e9f-9f0e-2a1f2f7b7c9d\",\"Name\":\"DOGE\",\"FullName\":\"Dogecoin\",\"Internal\":\"DOGE\",\"ImageUrl\":\"/media/37746886/doge.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 0.002\",\"CHANGEPCT24HOUR\":\"-0.07\"}}}]}".utf8) + } + + var successFullListResponse: HTTPURLResponse { + HTTPURLResponse( + url: CryptoAPIEndpoint.topList(page: nil).url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + } + + var successLoadMoreDataResponse: HTTPURLResponse { + HTTPURLResponse( + url: CryptoAPIEndpoint.topList(page: 1).url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + } +} diff --git a/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift b/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift index e267ac6..85ee41b 100644 --- a/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift +++ b/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift @@ -6,3 +6,48 @@ // import Foundation +import Observation +import XCTest + +public extension XCTestCase { + + /// Waits for changes to a property at a given key path of an `@Observable` entity. + /// + /// Uses the Observation framework's global `withObservationTracking` function to track changes to a specific property. + /// By using wildcard assignment (`_ = ...`), we 'touch' the property without wasting CPU cycles. + /// + /// - Parameters: + /// - keyPath: The key path of the property to observe. + /// - parent: The observable view model that contains the property. + /// - timeout: The time (in seconds) to wait for changes before timing out. Defaults to `1.0`. + /// + func waitForChanges(to keyPath: KeyPath, on parent: T, timeout: Double = 1.0) { + let exp = expectation(description: #function) + withObservationTracking { + _ = parent[keyPath: keyPath] + } onChange: { + exp.fulfill() + } + waitForExpectations(timeout: timeout) + } + + /// Asynchronously awaits changes to a property at a given key path of an `@Observable` entity. + /// + /// Uses the Observation framework's global `withObservationTracking` function to track changes to a specific property. + /// By using wildcard assignment (`_ = ...`), we 'touch' the property without wasting CPU cycles. + /// + /// - Parameters: + /// - keyPath: The key path of the property to observe. + /// - parent: The observable view model that contains the property. + /// - timeout: The time (in seconds) to wait for changes before timing out. Defaults to `1.0`. + /// + func awaitChanges(to keyPath: KeyPath, on parent: T, timeout: Double = 1.0) async { + let exp = expectation(description: #function) + withObservationTracking { + _ = parent[keyPath: keyPath] + } onChange: { + exp.fulfill() + } + await fulfillment(of: [exp], timeout: timeout) + } +} diff --git a/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift b/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift index 899aff2..0a95828 100644 --- a/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift +++ b/CryptoWidgetKitAppTests/Network/CryptoAPIServiceTests.swift @@ -38,7 +38,7 @@ extension CryptoAPIServiceTests { // when do { - let result = try await sut.fetchRetrieveFullList() + let result = try await sut.fetchRetrieveTopList() // then XCTAssertEqual(result.data.count, 2) XCTAssertEqual(result.data[0].coinInfo.fullName, "Bitcoin") @@ -59,7 +59,7 @@ extension CryptoAPIServiceTests { // when do { - _ = try await sut.fetchRetrieveFullList() + _ = try await sut.fetchRetrieveTopList() XCTFail("Expected to throw error, but succeeded") } catch { // then @@ -77,7 +77,7 @@ extension CryptoAPIServiceTests { // when do { - _ = try await sut.fetchRetrieveFullList() + _ = try await sut.fetchRetrieveTopList() XCTFail("Expected to throw error, but succeeded") } catch { // then @@ -98,7 +98,7 @@ fileprivate extension CryptoAPIServiceTests { var successFullListResponse: HTTPURLResponse { HTTPURLResponse( - url: CryptoAPIEndpoint.fullList.url, + url: CryptoAPIEndpoint.topList(page: nil).url, statusCode: 200, httpVersion: nil, headerFields: nil)! @@ -106,7 +106,7 @@ fileprivate extension CryptoAPIServiceTests { var badFullListResponse: HTTPURLResponse { HTTPURLResponse( - url: CryptoAPIEndpoint.fullList.url, + url: CryptoAPIEndpoint.topList(page: nil).url, statusCode: 500, httpVersion: nil, headerFields: nil)! From 061ea2e169eeb59f93c75aa667e3551ce091638c Mon Sep 17 00:00:00 2001 From: Jose Torronteras <7280807+josetorronteras@users.noreply.github.com> Date: Sat, 8 Jun 2024 12:01:50 +0200 Subject: [PATCH 6/6] Update tests --- CryptoWidgetKitApp.xcodeproj/project.pbxproj | 12 -- .../CryptoViewModelTests.swift | 131 +++++++++++++++--- .../Extensions/XCTestCase+.swift | 53 ------- 3 files changed, 115 insertions(+), 81 deletions(-) delete mode 100644 CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift diff --git a/CryptoWidgetKitApp.xcodeproj/project.pbxproj b/CryptoWidgetKitApp.xcodeproj/project.pbxproj index 1b0c2a0..08b1436 100644 --- a/CryptoWidgetKitApp.xcodeproj/project.pbxproj +++ b/CryptoWidgetKitApp.xcodeproj/project.pbxproj @@ -22,7 +22,6 @@ 4D8251CA2C05EC4500DD32A3 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */; }; 4D8251D12C0879E800DD32A3 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D02C0879E800DD32A3 /* View+.swift */; }; 4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */; }; - 4D8251D62C0DBAFC00DD32A3 /* XCTestCase+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -53,7 +52,6 @@ 4D8251C92C05EC4500DD32A3 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; 4D8251D02C0879E800DD32A3 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; }; 4D8251D22C0DAD5000DD32A3 /* CryptoViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViewModelTests.swift; sourceTree = ""; }; - 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+.swift"; sourceTree = ""; }; 4D8251E42C0F7B0300DD32A3 /* CryptoWidgetKitApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CryptoWidgetKitApp.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ @@ -119,7 +117,6 @@ 4D7266592C049D820035E348 /* CryptoWidgetKitAppTests */ = { isa = PBXGroup; children = ( - 4D8251D42C0DBAEC00DD32A3 /* Extensions */, 4D8251E42C0F7B0300DD32A3 /* CryptoWidgetKitApp.xctestplan */, 4D8251C02C05E9F100DD32A3 /* Mocks */, 4D8251C12C05E9FE00DD32A3 /* Network */, @@ -204,14 +201,6 @@ path = Extensions; sourceTree = ""; }; - 4D8251D42C0DBAEC00DD32A3 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4D8251D52C0DBAFC00DD32A3 /* XCTestCase+.swift */, - ); - path = Extensions; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -331,7 +320,6 @@ files = ( 4D8251C82C05EBBB00DD32A3 /* MockURLSession.swift in Sources */, 4D8251D32C0DAD5000DD32A3 /* CryptoViewModelTests.swift in Sources */, - 4D8251D62C0DBAFC00DD32A3 /* XCTestCase+.swift in Sources */, 4D8251BF2C05E9BB00DD32A3 /* CryptoAPIServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CryptoWidgetKitAppTests/CryptoViewModelTests.swift b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift index cbed2d7..80de47a 100644 --- a/CryptoWidgetKitAppTests/CryptoViewModelTests.swift +++ b/CryptoWidgetKitAppTests/CryptoViewModelTests.swift @@ -37,16 +37,31 @@ extension CryptoViewModelTests { // Given session.data = fullListData session.response = successFullListResponse + let showSkeletonExpectation = expectation(description: "showSkeleton published property changed") + let cryptosExpectation = expectation(description: "cryptos published property changed") + + withObservationTracking { + _ = sut.showSkeleton + } onChange: { + showSkeletonExpectation.fulfill() + } + + withObservationTracking { + _ = sut.cryptos + } onChange: { + cryptosExpectation.fulfill() + } // When - Task { await sut.fetchInitialData() } + Task { + await sut.fetchInitialData() + } // Then - await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + await fulfillment(of: [showSkeletonExpectation, cryptosExpectation], timeout: 5.0) XCTAssertFalse(sut.showSkeleton) XCTAssertFalse(sut.isLoading) XCTAssertFalse(sut.showError) - await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) XCTAssertEqual(sut.cryptos.count, 2) } @@ -56,18 +71,33 @@ extension CryptoViewModelTests { session.response = successFullListResponse await sut.fetchInitialData() + // Observa los cambios + let isLoadingExpectation = expectation(description: "isLoading published property changed") + let cryptosExpectation = expectation(description: "cryptos published property changed") + + withObservationTracking { + _ = sut.isLoading + } onChange: { + isLoadingExpectation.fulfill() + } + + withObservationTracking { + _ = sut.cryptos + } onChange: { + cryptosExpectation.fulfill() + } + // When session.data = loadMoreData session.response = successLoadMoreDataResponse - await sut.loadMoreData() + Task { + await sut.loadMoreData() + } // Then - XCTAssertFalse(sut.showSkeleton) - await awaitChanges(to: \.isLoading, on: sut, timeout: 5.0) + await fulfillment(of: [isLoadingExpectation, cryptosExpectation], timeout: 5.0) XCTAssertFalse(sut.isLoading) - await awaitChanges(to: \.showError, on: sut, timeout: 5.0) XCTAssertFalse(sut.showError) - await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) XCTAssertEqual(sut.cryptos.count, 4) } @@ -77,16 +107,32 @@ extension CryptoViewModelTests { session.response = successFullListResponse await sut.fetchInitialData() + // Observa los cambios + let showSkeletonExpectation = expectation(description: "showSkeleton published property changed") + let cryptosExpectation = expectation(description: "cryptos published property changed") + + withObservationTracking { + _ = sut.showSkeleton + } onChange: { + showSkeletonExpectation.fulfill() + } + + withObservationTracking { + _ = sut.cryptos + } onChange: { + cryptosExpectation.fulfill() + } + // When - await sut.refreshData() + Task { + await sut.refreshData() + } // Then - await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + await fulfillment(of: [showSkeletonExpectation, cryptosExpectation], timeout: 5.0) XCTAssertFalse(sut.showSkeleton) - await awaitChanges(to: \.isLoading, on: sut, timeout: 5.0) XCTAssertFalse(sut.isLoading) XCTAssertFalse(sut.showError) - await awaitChanges(to: \.cryptos, on: sut, timeout: 5.0) XCTAssertEqual(sut.cryptos.count, 2) } @@ -94,17 +140,70 @@ extension CryptoViewModelTests { // Given session.error = URLError(.badServerResponse) + // Observa los cambios + let showSkeletonExpectation = expectation(description: "showSkeleton published property changed") + let showErrorExpectation = expectation(description: "showError published property changed") + + withObservationTracking { + _ = sut.showSkeleton + } onChange: { + showSkeletonExpectation.fulfill() + } + + withObservationTracking { + _ = sut.showError + } onChange: { + showErrorExpectation.fulfill() + } + // When - await sut.fetchInitialData() + Task { + await sut.fetchInitialData() + } // Then - await awaitChanges(to: \.showSkeleton, on: sut, timeout: 5.0) + await fulfillment(of: [showSkeletonExpectation, showErrorExpectation], timeout: 5.0) XCTAssertTrue(sut.showSkeleton) XCTAssertFalse(sut.isLoading) - await awaitChanges(to: \.showError, on: sut, timeout: 5.0) XCTAssertTrue(sut.showError) XCTAssertEqual(sut.cryptos.count, [Crypto].mock.count) } + + func test_refreshData_failure() async { + // Given + session.data = fullListData + session.response = successFullListResponse + await sut.fetchInitialData() + + // Observa los cambios + let showSkeletonExpectation = expectation(description: "showSkeleton published property changed") + let showErrorExpectation = expectation(description: "showError published property changed") + + withObservationTracking { + _ = sut.showSkeleton + } onChange: { + showSkeletonExpectation.fulfill() + } + + withObservationTracking { + _ = sut.showError + } onChange: { + showErrorExpectation.fulfill() + } + + // When + session.error = URLError(.badServerResponse) + Task { + await sut.refreshData() + } + + // Then + await fulfillment(of: [showSkeletonExpectation, showErrorExpectation], timeout: 5.0) + XCTAssertTrue(sut.showSkeleton) + XCTAssertFalse(sut.isLoading) + XCTAssertTrue(sut.showError) + XCTAssertEqual(sut.cryptos.count, 2) + } } // MARK: - Data @@ -113,7 +212,7 @@ fileprivate extension CryptoViewModelTests { var fullListData: Data { Data("{\"Data\":[{\"CoinInfo\":{\"Id\":\"1182\",\"Name\":\"BTC\",\"FullName\":\"Bitcoin\",\"Internal\":\"BTC\",\"ImageUrl\":\"/media/37746251/btc.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 68,512.1\",\"CHANGEPCT24HOUR\":\"-0.04\"}}},{\"CoinInfo\":{\"Id\":\"7605\",\"Name\":\"ETH\",\"FullName\":\"Ethereum\",\"Internal\":\"ETH\",\"ImageUrl\":\"/media/37746238/eth.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 3,901.48\",\"CHANGEPCT24HOUR\":\"-0.07\"}}}]}".utf8) } - + var loadMoreData: Data { Data("{\"Data\":[{\"CoinInfo\":{\"Id\":\"1042\",\"Name\":\"LTC\",\"FullName\":\"Litecoin\",\"Internal\":\"LTC\",\"ImageUrl\":\"/media/37746257/ltc.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 181.1\",\"CHANGEPCT24HOUR\":\"-0.04\"}}},{\"CoinInfo\":{\"Id\":\"02cf57e2-9b3c-4e9f-9f0e-2a1f2f7b7c9d\",\"Name\":\"DOGE\",\"FullName\":\"Dogecoin\",\"Internal\":\"DOGE\",\"ImageUrl\":\"/media/37746886/doge.png\"},\"DISPLAY\":{\"USD\":{\"PRICE\":\"$ 0.002\",\"CHANGEPCT24HOUR\":\"-0.07\"}}}]}".utf8) } diff --git a/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift b/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift deleted file mode 100644 index 85ee41b..0000000 --- a/CryptoWidgetKitAppTests/Extensions/XCTestCase+.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// XCTestCase+.swift -// CryptoWidgetKitAppTests -// -// Created by Jose Jesus Torronteras Hernandez on 3/6/24. -// - -import Foundation -import Observation -import XCTest - -public extension XCTestCase { - - /// Waits for changes to a property at a given key path of an `@Observable` entity. - /// - /// Uses the Observation framework's global `withObservationTracking` function to track changes to a specific property. - /// By using wildcard assignment (`_ = ...`), we 'touch' the property without wasting CPU cycles. - /// - /// - Parameters: - /// - keyPath: The key path of the property to observe. - /// - parent: The observable view model that contains the property. - /// - timeout: The time (in seconds) to wait for changes before timing out. Defaults to `1.0`. - /// - func waitForChanges(to keyPath: KeyPath, on parent: T, timeout: Double = 1.0) { - let exp = expectation(description: #function) - withObservationTracking { - _ = parent[keyPath: keyPath] - } onChange: { - exp.fulfill() - } - waitForExpectations(timeout: timeout) - } - - /// Asynchronously awaits changes to a property at a given key path of an `@Observable` entity. - /// - /// Uses the Observation framework's global `withObservationTracking` function to track changes to a specific property. - /// By using wildcard assignment (`_ = ...`), we 'touch' the property without wasting CPU cycles. - /// - /// - Parameters: - /// - keyPath: The key path of the property to observe. - /// - parent: The observable view model that contains the property. - /// - timeout: The time (in seconds) to wait for changes before timing out. Defaults to `1.0`. - /// - func awaitChanges(to keyPath: KeyPath, on parent: T, timeout: Double = 1.0) async { - let exp = expectation(description: #function) - withObservationTracking { - _ = parent[keyPath: keyPath] - } onChange: { - exp.fulfill() - } - await fulfillment(of: [exp], timeout: timeout) - } -}