From 63525f2f706f2db5bfb10fffabca8755c3cf8ac7 Mon Sep 17 00:00:00 2001 From: kp-said-rehouni <109279805+kp-said-rehouni@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:19:15 +0100 Subject: [PATCH] PIA-1177: Integrate VPN profile installing in App flow (#61) --- .../CompositionRoot/DashboardFactory.swift | 2 +- .../Presentation/DashboardViewModel.swift | 7 ++- .../Login/CompositionRoot/LoginFactory.swift | 4 +- .../Login/Presentation/LoginViewModel.swift | 10 +++- PIA VPN-tvOS/Navigation/AppRouter.swift | 10 +++- PIA VPN-tvOS/Navigation/Routes.swift | 41 +++++++++++++ .../RootContainerFactory.swift | 5 +- .../Presentation/RootContainerViewModel.swift | 14 +++-- .../RootContainer/UI/RootContainerView.swift | 38 +++++++----- .../UI/UserActivatedContainerView.swift | 22 +++---- .../VPNConfigurationInstallingFactory.swift | 4 +- .../VPNConfigurationInstallingViewModel.swift | 11 +++- .../Common/Mocks/AppRouterSpy.swift | 58 +++++++++++++++++++ .../Dashboard/DashboardViewModelTests.swift | 2 +- .../Login/LoginIntegrationTests.swift | 20 +++++-- .../Login/LoginViewModelTests.swift | 55 +++++++++++++----- .../RootContainerViewModelTests.swift | 8 ++- ...onfigurationInstallingViewModelTests.swift | 21 ++++--- PIA VPN.xcodeproj/project.pbxproj | 8 +++ 19 files changed, 260 insertions(+), 80 deletions(-) create mode 100644 PIA VPN-tvOS/Navigation/Routes.swift create mode 100644 PIA VPN-tvOSTests/Common/Mocks/AppRouterSpy.swift diff --git a/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift b/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift index 03f8761c..2482b55b 100644 --- a/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift +++ b/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift @@ -12,7 +12,7 @@ class DashboardFactory { guard let defaultAccountProvider = Client.providers.accountProvider as? DefaultAccountProvider else { fatalError("Incorrect account provider type") } - return DashboardViewModel(accountProvider: defaultAccountProvider, appRouter: AppRouterFactory.makeAppRouter()) + return DashboardViewModel(accountProvider: defaultAccountProvider, appRouter: AppRouterFactory.makeAppRouter(), navigationDestination: RegionsDestinations.serversList) } static func makePIAConnectionButton(size: CGFloat = 160, lineWidth: CGFloat = 6) -> PIAConnectionButton { diff --git a/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift index 9c1ae307..eb2ee2ac 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift @@ -6,15 +6,16 @@ class DashboardViewModel: ObservableObject { private let accountProvider: AccountProviderType private let appRouter: AppRouter + private let navigationDestination: any Destinations - - init(accountProvider: AccountProviderType, appRouter: AppRouter) { + init(accountProvider: AccountProviderType, appRouter: AppRouter, navigationDestination: any Destinations) { self.accountProvider = accountProvider self.appRouter = appRouter + self.navigationDestination = navigationDestination } func regionSelectionSectionWasTapped() { - appRouter.navigate(to: RegionsDestinations.serversList) + appRouter.navigate(to: navigationDestination) } diff --git a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift index 3e35410e..c8bd8fa8 100644 --- a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift +++ b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift @@ -18,7 +18,9 @@ class LoginFactory { LoginViewModel(loginWithCredentialsUseCase: makeLoginWithCredentialsUseCase(), checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: makeLoginViewModelErrorHandler()) + errorHandler: makeLoginViewModelErrorHandler(), + appRouter: AppRouter.shared, + successDestination: OnboardingDestinations.installVPNProfile) } diff --git a/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift b/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift index 699a57fa..d6397d8e 100644 --- a/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift +++ b/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift @@ -13,17 +13,21 @@ class LoginViewModel: ObservableObject { private let checkLoginAvailability: CheckLoginAvailabilityType private let validateLoginCredentials: ValidateCredentialsFormatType private let errorHandler: LoginViewModelErrorHandlerType + private let appRouter: AppRouterType + + private let successDestination: any Destinations @Published var isAccountExpired = false - @Published var didLoginSuccessfully = false @Published var shouldShowErrorMessage = false @Published var loginStatus: LoginStatus = .none - init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorHandler: LoginViewModelErrorHandlerType) { + init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorHandler: LoginViewModelErrorHandlerType, appRouter: AppRouterType, successDestination: any Destinations) { self.loginWithCredentialsUseCase = loginWithCredentialsUseCase self.checkLoginAvailability = checkLoginAvailability self.validateLoginCredentials = validateLoginCredentials self.errorHandler = errorHandler + self.appRouter = appRouter + self.successDestination = successDestination } func login(username: String, password: String) { @@ -50,7 +54,7 @@ class LoginViewModel: ObservableObject { case .success(let userAccount): Task { @MainActor in self.loginStatus = .succeeded(userAccount: userAccount) - self.didLoginSuccessfully = true + self.appRouter.navigate(to: self.successDestination) } case .failure(let error): diff --git a/PIA VPN-tvOS/Navigation/AppRouter.swift b/PIA VPN-tvOS/Navigation/AppRouter.swift index 86cd9b06..598ec57c 100644 --- a/PIA VPN-tvOS/Navigation/AppRouter.swift +++ b/PIA VPN-tvOS/Navigation/AppRouter.swift @@ -4,8 +4,16 @@ import Foundation import SwiftUI +protocol AppRouterType { + var stackCount: Int { get } + + func navigate(to destination: any Destinations) + func pop() + func goBackToRoot() +} + // AppRouter enables any component to navigate the user to any screen defined within Destinations -class AppRouter: ObservableObject { +class AppRouter: ObservableObject, AppRouterType { static let shared: AppRouter = AppRouter(with: NavigationPath()) diff --git a/PIA VPN-tvOS/Navigation/Routes.swift b/PIA VPN-tvOS/Navigation/Routes.swift new file mode 100644 index 00000000..b4890ed8 --- /dev/null +++ b/PIA VPN-tvOS/Navigation/Routes.swift @@ -0,0 +1,41 @@ +// +// Routes.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 12/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +enum AuthenticationDestinations: Destinations { + case loginCredentials +} + +enum OnboardingDestinations: Destinations { + case installVPNProfile + case dashboard +} + +public extension View { + func withAuthenticationRoutes() -> some View { + self.navigationDestination(for: AuthenticationDestinations.self) { destination in + switch destination { + case .loginCredentials: + LoginFactory.makeLoginView() + } + } + } + + func withOnboardingRoutes() -> some View { + self.navigationDestination(for: OnboardingDestinations.self) { destination in + switch destination { + case .installVPNProfile: + VPNConfigurationInstallingFactory.makeVPNConfigurationInstallingView() + case .dashboard: + UserActivatedContainerFactory.makeUSerActivatedContainerView() + } + } + } +} diff --git a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift index 3d0ad3ed..cdd2a9e4 100644 --- a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift +++ b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift @@ -4,13 +4,14 @@ import PIALibrary class RootContainerFactory { static func makeRootContainerView() -> RootContainerView { - RootContainerView(viewModel: makeRootContainerViewModel()) + RootContainerView(viewModel: makeRootContainerViewModel(), appRouter: AppRouter.shared) } private static func makeRootContainerViewModel() -> RootContainerViewModel { guard let defaultAccountProvider = Client.providers.accountProvider as? DefaultAccountProvider else { fatalError("Incorrect account provider type") } - return RootContainerViewModel(accountProvider: defaultAccountProvider) + return RootContainerViewModel(accountProvider: defaultAccountProvider, + vpnConfigurationAvailability: VPNConfigurationAvailability()) } } diff --git a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift index 8538cd69..8e1bffe7 100644 --- a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift +++ b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift @@ -14,16 +14,15 @@ class RootContainerViewModel: ObservableObject { @Published var state: State = .splash @Published internal var isBootstrapped: Bool = false - // TODO: Update this value from the Vpn OnBoarding installation profile screen - @AppStorage(.kOnboardingVpnProfileInstalled) var onBoardingVpnProfileInstalled = false + private let accountProvider: AccountProviderType + private let notificationCenter: NotificationCenterType + private let vpnConfigurationAvailability: VPNConfigurationAvailabilityType - let accountProvider: AccountProviderType - let notificationCenter: NotificationCenterType - - init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default) { + init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default, vpnConfigurationAvailability: VPNConfigurationAvailabilityType) { self.accountProvider = accountProvider self.notificationCenter = notificationCenter + self.vpnConfigurationAvailability = vpnConfigurationAvailability updateState() subscribeToAccountUpdates() } @@ -44,6 +43,9 @@ class RootContainerViewModel: ObservableObject { guard isBootstrapped else { return } + + let onBoardingVpnProfileInstalled = vpnConfigurationAvailability.get() + switch (accountProvider.isLoggedIn, onBoardingVpnProfileInstalled) { // logged in, vpn profile installed case (true, true): diff --git a/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift b/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift index 5acf5d14..64946d5f 100644 --- a/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift +++ b/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift @@ -6,22 +6,32 @@ struct RootContainerView: View { @ObservedObject var viewModel: RootContainerViewModel @Environment(\.scenePhase) var scenePhase + @ObservedObject private var appRouter: AppRouter + + init(viewModel: RootContainerViewModel, appRouter: AppRouter) { + self.viewModel = viewModel + self.appRouter = appRouter + } + var body: some View { - VStack { - switch viewModel.state { - case .splash: - VStack { - // TODO: Add Splash screen here + NavigationStack(path: $appRouter.path) { + // Add a root view here. + switch viewModel.state { + case .splash: + VStack { + // TODO: Add Splash screen here + } + case .notActivated: + LoginFactory.makeLoginView() + .withAuthenticationRoutes() + .withOnboardingRoutes() + case .activatedNotOnboarded: + VPNConfigurationInstallingFactory.makeVPNConfigurationInstallingView() + .withOnboardingRoutes() + case .activated: + UserActivatedContainerFactory.makeUSerActivatedContainerView() } - case .notActivated: - LoginFactory.makeLoginView() - case .activatedNotOnboarded: - VPNConfigurationInstallingFactory.makeVPNConfigurationInstallingView() - case .activated: - UserActivatedContainerFactory.makeUSerActivatedContainerView() - } - }.onChange(of: scenePhase) { newPhase in - + }.onChange(of: scenePhase) { _, newPhase in if newPhase == .active { NSLog(">>> Active") viewModel.phaseDidBecomeActive() diff --git a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift index e0bb1b2a..dae6ef97 100644 --- a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift +++ b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift @@ -14,21 +14,17 @@ struct UserActivatedContainerView: View { @ObservedObject var router: AppRouter var body: some View { - NavigationStack(path: $router.path) { - DashboardFactory.makeDashboardView() - .navigationDestination(for: RegionsDestinations.self) { destination in - switch destination { - case .serversList: - RegionsSelectionFactory.makeRegionsListView() - case .selectServer(let selectedServer): - VStack { - Text("Selected server: \(selectedServer.name)") - } + DashboardFactory.makeDashboardView() + .navigationDestination(for: RegionsDestinations.self) { destination in + switch destination { + case .serversList: + RegionsSelectionFactory.makeRegionsListView() + case .selectServer(let selectedServer): + VStack { + Text("Selected server: \(selectedServer.name)") } } - - } - + } } } diff --git a/PIA VPN-tvOS/VPNConfigurationInstalling/CompositionRoot/VPNConfigurationInstallingFactory.swift b/PIA VPN-tvOS/VPNConfigurationInstalling/CompositionRoot/VPNConfigurationInstallingFactory.swift index 0458fcfc..082a0afe 100644 --- a/PIA VPN-tvOS/VPNConfigurationInstalling/CompositionRoot/VPNConfigurationInstallingFactory.swift +++ b/PIA VPN-tvOS/VPNConfigurationInstalling/CompositionRoot/VPNConfigurationInstallingFactory.swift @@ -17,7 +17,9 @@ class VPNConfigurationInstallingFactory { private static func makeVPNConfigurationInstallingViewModel() -> VPNConfigurationInstallingViewModel { VPNConfigurationInstallingViewModel(installVPNConfiguration: makeInstallVPNConfigurationUseCase(), - errorMapper: VPNConfigurationInstallingErrorMapper()) + errorMapper: VPNConfigurationInstallingErrorMapper(), + appRouter: AppRouter.shared, + successDestination: OnboardingDestinations.dashboard) } private static func makeInstallVPNConfigurationUseCase() -> InstallVPNConfigurationUseCaseType { diff --git a/PIA VPN-tvOS/VPNConfigurationInstalling/Presentation/VPNConfigurationInstallingViewModel.swift b/PIA VPN-tvOS/VPNConfigurationInstalling/Presentation/VPNConfigurationInstallingViewModel.swift index 41ed4c98..340e588b 100644 --- a/PIA VPN-tvOS/VPNConfigurationInstalling/Presentation/VPNConfigurationInstallingViewModel.swift +++ b/PIA VPN-tvOS/VPNConfigurationInstalling/Presentation/VPNConfigurationInstallingViewModel.swift @@ -11,14 +11,19 @@ import Foundation class VPNConfigurationInstallingViewModel: ObservableObject { private let installVPNConfiguration: InstallVPNConfigurationUseCaseType private let errorMapper: VPNConfigurationInstallingErrorMapper + private let appRouter: AppRouterType + @Published var shouldShowErrorMessage = false - @Published var didInstallVPNProfile = false @Published var installingStatus: VPNConfigurationInstallingStatus = .none var errorMessage: String? - init(installVPNConfiguration: InstallVPNConfigurationUseCaseType, errorMapper: VPNConfigurationInstallingErrorMapper) { + private let successDestination: any Destinations + + init(installVPNConfiguration: InstallVPNConfigurationUseCaseType, errorMapper: VPNConfigurationInstallingErrorMapper, appRouter: AppRouterType, successDestination: any Destinations) { self.installVPNConfiguration = installVPNConfiguration self.errorMapper = errorMapper + self.appRouter = appRouter + self.successDestination = successDestination } func install() { @@ -32,8 +37,8 @@ class VPNConfigurationInstallingViewModel: ObservableObject { do { try await installVPNConfiguration() Task { @MainActor in - didInstallVPNProfile = true installingStatus = .succeeded + appRouter.navigate(to: successDestination) } } catch { errorMessage = errorMapper.map(error: error) diff --git a/PIA VPN-tvOSTests/Common/Mocks/AppRouterSpy.swift b/PIA VPN-tvOSTests/Common/Mocks/AppRouterSpy.swift new file mode 100644 index 00000000..194006b3 --- /dev/null +++ b/PIA VPN-tvOSTests/Common/Mocks/AppRouterSpy.swift @@ -0,0 +1,58 @@ +// +// AppRouterSpy.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 15/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class AppRouterSpy: AppRouterType { + enum Request: Equatable { + static func == (lhs: AppRouterSpy.Request, rhs: AppRouterSpy.Request) -> Bool { + switch (lhs, rhs) { + case let (.navigate(lhsRoute), .navigate(rhsRoute)): + + if let lhsOnboarding = lhsRoute as? OnboardingDestinations, let rhsOnboarding = rhsRoute as? OnboardingDestinations { + return lhsOnboarding == rhsOnboarding + } + + if let lhsAuth = lhsRoute as? AuthenticationDestinations, let rhsAuth = rhsRoute as? AuthenticationDestinations { + return lhsAuth == rhsAuth + } + + return false + case (.pop, .pop), (.goBackToRoot, .goBackToRoot): + return true + default: + return false + } + } + + case navigate(any Destinations) + case pop + case goBackToRoot + } + + var stackCount: Int = 0 + var requests = [AppRouterSpy.Request]() + + var didGetARequest: (() -> Void)? + + func navigate(to destination: any Destinations) { + requests.append(.navigate(destination)) + didGetARequest?() + } + + func pop() { + requests.append(.pop) + didGetARequest?() + } + + func goBackToRoot() { + requests.append(.goBackToRoot) + didGetARequest?() + } +} diff --git a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift index c5786ce5..ee5a7684 100644 --- a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift +++ b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift @@ -29,7 +29,7 @@ class DashboardViewModelTests: XCTestCase { } private func initializeSut() { - sut = DashboardViewModel(accountProvider: fixture.accountProviderMock, appRouter: fixture.appRouter) + sut = DashboardViewModel(accountProvider: fixture.accountProviderMock, appRouter: fixture.appRouter, navigationDestination: RegionsDestinations.serversList) } func test_navigateToRegionsList() { diff --git a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift index 2d859db1..7834527a 100644 --- a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift @@ -9,6 +9,7 @@ import XCTest import PIALibrary import Combine +import SwiftUI @testable import PIA_VPN_tvOS final class LoginIntegrationTests: XCTestCase { @@ -25,10 +26,14 @@ final class LoginIntegrationTests: XCTestCase { let loginWithCredentialsUseCase = LoginWithCredentialsUseCase(loginProvider: loginProvider, errorMapper: LoginDomainErrorMapper()) + let appRouter = AppRouter(with: NavigationPath()) + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouter, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for didLoginSuccessfully property to be updated") @@ -40,10 +45,10 @@ final class LoginIntegrationTests: XCTestCase { capturedLoginStatuses.append(status) }).store(in: &cancellables) - sut.$didLoginSuccessfully.dropFirst().sink(receiveValue: { status in - XCTAssertTrue(status) + appRouter.$path.dropFirst().sink(receiveValue: { path in expectation.fulfill() }).store(in: &cancellables) + // WHEN sut.login(username: "username", password: "password") @@ -54,6 +59,7 @@ final class LoginIntegrationTests: XCTestCase { XCTAssertFalse(sut.isAccountExpired) XCTAssertEqual(capturedLoginStatuses.count, 2) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) + XCTAssertEqual(appRouter.path, NavigationPath([OnboardingDestinations.installVPNProfile])) guard case .succeeded(let capturedUserResult) = capturedLoginStatuses[1] else { XCTFail("Expected success, got failure") @@ -93,10 +99,14 @@ final class LoginIntegrationTests: XCTestCase { let loginWithCredentialsUseCase = LoginWithCredentialsUseCase(loginProvider: loginProvider, errorMapper: LoginDomainErrorMapper()) + let appRouter = AppRouter(with: NavigationPath()) + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouter, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for isAccountExpired property to be updated") @@ -119,7 +129,7 @@ final class LoginIntegrationTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.shouldShowErrorMessage) - XCTAssertFalse(sut.didLoginSuccessfully) + XCTAssertEqual(appRouter.path, NavigationPath()) XCTAssertEqual(capturedLoginStatuses.count, 2) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) XCTAssertEqual(capturedLoginStatuses[1], LoginStatus.failed(errorMessage: nil, field: .none)) diff --git a/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift b/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift index ececaf0f..64f9021b 100644 --- a/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift @@ -21,10 +21,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .failure(.throttled(retryAfter: 20)) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") @@ -47,9 +51,10 @@ final class LoginViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.isAccountExpired) - XCTAssertFalse(sut.didLoginSuccessfully) + //XCTAssertFalse(sut.didLoginSuccessfully) XCTAssertEqual(capturedLoginStatuses.count, 1) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.failed(errorMessage: "Too many failed login attempts with this username. Please try again after 20.0 second(s).", field: .none)) + XCTAssertEqual(appRouterSpy.requests, []) } func test_login_fails_when_username_is_invalid() { @@ -61,10 +66,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .success(()) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") @@ -87,9 +96,10 @@ final class LoginViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.isAccountExpired) - XCTAssertFalse(sut.didLoginSuccessfully) + //XCTAssertFalse(sut.didLoginSuccessfully) XCTAssertEqual(capturedLoginStatuses.count, 1) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.failed(errorMessage: "You must enter a username and password.", field: .username)) + XCTAssertEqual(appRouterSpy.requests, []) } func test_login_fails_when_password_is_invalid() { @@ -101,10 +111,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .success(()) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") @@ -127,9 +141,10 @@ final class LoginViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.isAccountExpired) - XCTAssertFalse(sut.didLoginSuccessfully) + //XCTAssertFalse(sut.didLoginSuccessfully) XCTAssertEqual(capturedLoginStatuses.count, 1) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.failed(errorMessage: "You must enter a username and password.", field: .password)) + XCTAssertEqual(appRouterSpy.requests, []) } @@ -142,10 +157,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .success(()) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for didLoginSuccessfully property to be updated") @@ -157,10 +176,7 @@ final class LoginViewModelTests: XCTestCase { capturedLoginStatuses.append(status) }).store(in: &cancellables) - sut.$didLoginSuccessfully.dropFirst().sink(receiveValue: { status in - XCTAssertTrue(status) - expectation.fulfill() - }).store(in: &cancellables) + appRouterSpy.didGetARequest = { expectation.fulfill() } // WHEN sut.login(username: "username", password: "password") @@ -172,6 +188,7 @@ final class LoginViewModelTests: XCTestCase { XCTAssertEqual(capturedLoginStatuses.count, 2) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) XCTAssertEqual(capturedLoginStatuses[1], LoginStatus.succeeded(userAccount: userAccount)) + XCTAssertEqual(appRouterSpy.requests, [.navigate(OnboardingDestinations.installVPNProfile)]) } func test_login_fails_when_loginUseCase_completes_with_expired_error() { @@ -182,10 +199,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .success(()) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for isAccountExpired property to be updated") @@ -208,10 +229,10 @@ final class LoginViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.shouldShowErrorMessage) - XCTAssertFalse(sut.didLoginSuccessfully) XCTAssertEqual(capturedLoginStatuses.count, 2) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) XCTAssertEqual(capturedLoginStatuses[1], LoginStatus.failed(errorMessage: nil, field: .none)) + XCTAssertEqual(appRouterSpy.requests, []) } func test_login_fails_when_loginUseCase_completes_with_unauthorized_error() { @@ -222,10 +243,14 @@ final class LoginViewModelTests: XCTestCase { let resultCheckLoginAvailability: Result = .success(()) let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + let appRouterSpy = AppRouterSpy() + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()), + appRouter: appRouterSpy, + successDestination: OnboardingDestinations.installVPNProfile) var cancellables = Set() let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") @@ -248,9 +273,9 @@ final class LoginViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertFalse(sut.isAccountExpired) - XCTAssertFalse(sut.didLoginSuccessfully) XCTAssertEqual(capturedLoginStatuses.count, 2) XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) XCTAssertEqual(capturedLoginStatuses[1], LoginStatus.failed(errorMessage: "Your username or password is incorrect.", field: .none)) + XCTAssertEqual(appRouterSpy.requests, []) } } diff --git a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift index 48f59891..f6340e0a 100644 --- a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift +++ b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift @@ -14,6 +14,7 @@ final class RootContainerViewModelTests: XCTestCase { final class Fixture { let accountProvierMock = AccountProviderTypeMock() let notificationCenterMock = NotificationCenterMock() + var vpnConfigurationAvailabilityMock = VPNConfigurationAvailabilityMock(value: false) } var fixture: Fixture! @@ -30,7 +31,9 @@ final class RootContainerViewModelTests: XCTestCase { } private func initializeSut(bootStrapped: Bool = true) { - sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, notificationCenter: fixture.notificationCenterMock) + sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, + notificationCenter: fixture.notificationCenterMock, + vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock) sut.isBootstrapped = bootStrapped } @@ -81,7 +84,6 @@ final class RootContainerViewModelTests: XCTestCase { extension RootContainerViewModelTests { private func stubOnboardingVpnInstallation(finished: Bool) { - UserDefaults.standard.setValue(finished, forKey: .kOnboardingVpnProfileInstalled) - UserDefaults.standard.synchronize() + fixture.vpnConfigurationAvailabilityMock = VPNConfigurationAvailabilityMock(value: finished) } } diff --git a/PIA VPN-tvOSTests/VPNConfigurationInstalling/VPNConfigurationInstallingViewModelTests.swift b/PIA VPN-tvOSTests/VPNConfigurationInstalling/VPNConfigurationInstallingViewModelTests.swift index 876a98f0..3008a90b 100644 --- a/PIA VPN-tvOSTests/VPNConfigurationInstalling/VPNConfigurationInstallingViewModelTests.swift +++ b/PIA VPN-tvOSTests/VPNConfigurationInstalling/VPNConfigurationInstallingViewModelTests.swift @@ -14,22 +14,25 @@ final class VPNConfigurationInstallingViewModelTests: XCTestCase { final class Fixture { let errorMapper = VPNConfigurationInstallingErrorMapper() + let appRouterSpy = AppRouterSpy() func makeInstallVPNConfiguration(error: InstallVPNConfigurationError?) -> InstallVPNConfigurationUseCaseType { return InstallVPNConfigurationUseCaseMock(error: error) } } - var fixture: Fixture = Fixture() + var fixture: Fixture! var sut: VPNConfigurationInstallingViewModel! var cancellables: Set! override func setUp() { + fixture = Fixture() cancellables = Set() } override func tearDown() { sut = nil + fixture = nil cancellables = nil } @@ -37,7 +40,9 @@ final class VPNConfigurationInstallingViewModelTests: XCTestCase { // GIVEN sut = VPNConfigurationInstallingViewModel( installVPNConfiguration: fixture.makeInstallVPNConfiguration(error: .userCanceled), - errorMapper: fixture.errorMapper) + errorMapper: fixture.errorMapper, + appRouter: fixture.appRouterSpy, + successDestination: OnboardingDestinations.dashboard) let expectation = expectation(description: "Waiting for installing to finish with error message") let expectedErrorMessage = "We need this permission for the application to function." @@ -58,16 +63,18 @@ final class VPNConfigurationInstallingViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertEqual(capturedInstallingStatuses, [.isInstalling, .failed(errorMessage: expectedErrorMessage)]) - XCTAssertFalse(sut.didInstallVPNProfile) XCTAssert(sut.shouldShowErrorMessage) XCTAssertEqual(sut.errorMessage, expectedErrorMessage) + XCTAssertEqual(fixture.appRouterSpy.requests, []) } func test_install_succeeds_when_installVPNConfiguration_succeeds() { // GIVEN sut = VPNConfigurationInstallingViewModel( installVPNConfiguration: fixture.makeInstallVPNConfiguration(error: nil), - errorMapper: fixture.errorMapper) + errorMapper: fixture.errorMapper, + appRouter: fixture.appRouterSpy, + successDestination: OnboardingDestinations.dashboard) let expectation = expectation(description: "Waiting for installing to finish successfully") var capturedInstallingStatuses = [VPNConfigurationInstallingStatus]() @@ -76,9 +83,7 @@ final class VPNConfigurationInstallingViewModelTests: XCTestCase { capturedInstallingStatuses.append(status) }).store(in: &cancellables) - sut.$didInstallVPNProfile.dropFirst().sink(receiveValue: { value in - expectation.fulfill() - }).store(in: &cancellables) + fixture.appRouterSpy.didGetARequest = { expectation.fulfill() } // WHEN sut.install() @@ -86,8 +91,8 @@ final class VPNConfigurationInstallingViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1) XCTAssertEqual(capturedInstallingStatuses, [.isInstalling, .succeeded]) - XCTAssert(sut.didInstallVPNProfile) XCTAssertFalse(sut.shouldShowErrorMessage) XCTAssertNil(sut.errorMessage) + XCTAssertEqual(fixture.appRouterSpy.requests, [.navigate(OnboardingDestinations.dashboard)]) } } diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index f02bdccb..4c683739 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -551,6 +551,8 @@ E5217F702AEB042800123442 /* ClientError+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5217F6E2AEB042800123442 /* ClientError+Localization.swift */; }; E5217F722AEB055D00123442 /* Client+Storyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5217F712AEB055D00123442 /* Client+Storyboard.swift */; }; E5217F732AEB055D00123442 /* Client+Storyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5217F712AEB055D00123442 /* Client+Storyboard.swift */; }; + E52E68FE2B55E47600471913 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E68FD2B55E47500471913 /* Routes.swift */; }; + E52E69002B56ABE400471913 /* AppRouterSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E68FF2B56ABE400471913 /* AppRouterSpy.swift */; }; E59E8F942AEA7A29009278F5 /* ActivityButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E59E8F932AEA7A29009278F5 /* ActivityButton.swift */; }; E59E8F952AEA7A29009278F5 /* ActivityButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E59E8F932AEA7A29009278F5 /* ActivityButton.swift */; }; E59E8F972AEA7A5A009278F5 /* AutolayoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E59E8F962AEA7A5A009278F5 /* AutolayoutViewController.swift */; }; @@ -1228,6 +1230,8 @@ E5217F652AEAF94C00123442 /* SwiftGen+Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftGen+Assets.swift"; sourceTree = ""; }; E5217F6E2AEB042800123442 /* ClientError+Localization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ClientError+Localization.swift"; sourceTree = ""; }; E5217F712AEB055D00123442 /* Client+Storyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Client+Storyboard.swift"; sourceTree = ""; }; + E52E68FD2B55E47500471913 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routes.swift; sourceTree = ""; }; + E52E68FF2B56ABE400471913 /* AppRouterSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouterSpy.swift; sourceTree = ""; }; E59E8F932AEA7A29009278F5 /* ActivityButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityButton.swift; sourceTree = ""; }; E59E8F962AEA7A5A009278F5 /* AutolayoutViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutolayoutViewController.swift; sourceTree = ""; }; E59E8F992AEA7A7F009278F5 /* SignupInternetUnreachableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignupInternetUnreachableViewController.swift; sourceTree = ""; }; @@ -1910,6 +1914,7 @@ 691317A02B32ED76009B4E85 /* AccountProviderTypeMock.swift */, 690FC4812B3C5A4300F6DCC8 /* ServerMock.swift */, 690FC4852B3C5F7300F6DCC8 /* SelectedServerUseCaseMock.swift */, + E52E68FF2B56ABE400471913 /* AppRouterSpy.swift */, ); path = Mocks; sourceTree = ""; @@ -1988,6 +1993,7 @@ 697A5F432B514B3D00661977 /* CompositionRoot */, 69A226B22B3074D30065EDDB /* AppRouter.swift */, 69816C3C2B4EBB3C00E3C86B /* Destinations.swift */, + E52E68FD2B55E47500471913 /* Routes.swift */, ); path = Navigation; sourceTree = ""; @@ -3908,6 +3914,7 @@ E5AB28712B279B4000744E5F /* LoginProviderType.swift in Sources */, 693CA5B02B3EDB4900D38378 /* RegionFilter.swift in Sources */, E5AB287B2B28B4C400744E5F /* Credentials.swift in Sources */, + E52E68FE2B55E47600471913 /* Routes.swift in Sources */, E5C5077C2B0E144E00200A6A /* ContentView.swift in Sources */, 69A226BE2B307D5F0065EDDB /* RootContainerView.swift in Sources */, 693CA5AA2B3ED5F600D38378 /* Bootstrapper.swift in Sources */, @@ -3962,6 +3969,7 @@ 690FC4882B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift in Sources */, E5AB28992B2C783600744E5F /* LoginProviderTests.swift in Sources */, E5C507C22B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift in Sources */, + E52E69002B56ABE400471913 /* AppRouterSpy.swift in Sources */, E5AB28E12B4C107F00744E5F /* VpnConfigurationProviderTypeMock.swift in Sources */, E5C507C42B1F72E700200A6A /* CheckLoginAvailabilityTests.swift in Sources */, E5C507C02B1F700C00200A6A /* CheckLoginAvailabilityMock.swift in Sources */,