From 92a1d7a3b0e25ef1cfbcaf1f0119e88e0c4ae4e8 Mon Sep 17 00:00:00 2001 From: kp-said-rehouni <109279805+kp-said-rehouni@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:48:57 +0100 Subject: [PATCH] PIA-884: Add proper error handling for Login tvOS (#47) * PIA-884: Add new class to handle errors instead of VM * PIA-884: Move Equatable extensions to tvOS target * PIA-884: Add proper error handling to login feature * PIA-884: Add missing files --- .../Login/CompositionRoot/LoginFactory.swift | 7 +- .../Login/Domain/Entities/AccountInfo.swift | 16 ++ .../Login/Domain/Entities/Credentials.swift | 6 + .../Login/Domain/Entities/LoginError.swift | 15 ++ .../Login/Domain/Entities/UserAccount.swift | 7 + .../LoginPresentableErrorMapper.swift | 13 +- .../Login/Presentation/LoginStatus.swift | 37 ++++ .../Login/Presentation/LoginViewModel.swift | 61 ++++--- .../LoginViewModelErrorHandler.swift | 37 ++++ PIA VPN-tvOS/Login/UI/LoginView.swift | 34 ++-- .../Login/Helpers/Equatable.swift | 39 ---- .../Login/Helpers/Stubs+PIALibrary.swift | 72 ++++++++ PIA VPN-tvOSTests/Login/Helpers/Stubs.swift | 29 --- .../Login/LoginIntegrationTests.swift | 56 ++++-- .../Login/LoginProviderTests.swift | 161 +++++++++++++++++ .../Login/LoginViewModelTests.swift | 168 +++++++++++++++--- PIA VPN.xcodeproj/project.pbxproj | 28 +-- 17 files changed, 635 insertions(+), 151 deletions(-) create mode 100644 PIA VPN-tvOS/Login/Presentation/LoginStatus.swift create mode 100644 PIA VPN-tvOS/Login/Presentation/LoginViewModelErrorHandler.swift delete mode 100644 PIA VPN-tvOSTests/Login/Helpers/Equatable.swift create mode 100644 PIA VPN-tvOSTests/Login/Helpers/Stubs+PIALibrary.swift create mode 100644 PIA VPN-tvOSTests/Login/LoginProviderTests.swift diff --git a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift index ce4b2bdb..3e35410e 100644 --- a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift +++ b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift @@ -18,7 +18,8 @@ class LoginFactory { LoginViewModel(loginWithCredentialsUseCase: makeLoginWithCredentialsUseCase(), checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: makeLoginViewModelErrorHandler()) + } private static func makeLoginWithCredentialsUseCase() -> LoginWithCredentialsUseCaseType { @@ -30,4 +31,8 @@ class LoginFactory { LoginProvider(accountProvider: Client.providers.accountProvider, userAccountMapper: UserAccountMapper()) } + + private static func makeLoginViewModelErrorHandler() -> LoginViewModelErrorHandlerType { + LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper()) + } } diff --git a/PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift b/PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift index bc035bda..2ae9dd35 100644 --- a/PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift +++ b/PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift @@ -44,3 +44,19 @@ struct AccountInfo { return dateFormatter.string(from: self.expirationDate) } } + +extension AccountInfo: Equatable { + public static func == (lhs: AccountInfo, rhs: AccountInfo) -> Bool { + lhs.email == rhs.email + && lhs.username == rhs.username + && lhs.plan == rhs.plan + && lhs.productId == rhs.productId + && lhs.isRenewable == rhs.isRenewable + && lhs.isRecurring == rhs.isRecurring + && lhs.expirationDate == rhs.expirationDate + && lhs.canInvite == rhs.canInvite + && lhs.isExpired == rhs.isExpired + && lhs.shouldPresentExpirationAlert == rhs.shouldPresentExpirationAlert + && lhs.renewUrl == rhs.renewUrl + } +} diff --git a/PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift b/PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift index b139ebe2..8144b133 100644 --- a/PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift +++ b/PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift @@ -17,3 +17,9 @@ struct Credentials { self.password = password } } + +extension Credentials: Equatable { + public static func == (lhs: Credentials, rhs: Credentials) -> Bool { + lhs.username == rhs.username && lhs.password == rhs.password + } +} diff --git a/PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift b/PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift index 2e07eaa7..0f5afd92 100644 --- a/PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift +++ b/PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift @@ -16,3 +16,18 @@ enum LoginError: Error { case passwordWrongFormat case generic(message: String?) } + +extension LoginError: Equatable { + public static func == (lhs: LoginError, rhs: LoginError) -> Bool { + switch (lhs, rhs) { + case (.unauthorized, .unauthorized), (.expired, .expired), (.usernameWrongFormat, .usernameWrongFormat), (.passwordWrongFormat, .passwordWrongFormat): + return true + case let (.throttled(lhsDelay), .throttled(rhsDelay)): + return lhsDelay == rhsDelay + case let (.generic(lhsMessage), .generic(rhsMessage)): + return lhsMessage == rhsMessage + default: + return false + } + } +} diff --git a/PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift b/PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift index 8bb93f6f..bbfd4ba8 100644 --- a/PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift +++ b/PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift @@ -21,3 +21,10 @@ struct UserAccount { self.info = info } } + +extension UserAccount: Equatable { + public static func == (lhs: UserAccount, rhs: UserAccount) -> Bool { + lhs.credentials == rhs.credentials + && lhs.info == rhs.info + } +} diff --git a/PIA VPN-tvOS/Login/Presentation/LoginPresentableErrorMapper.swift b/PIA VPN-tvOS/Login/Presentation/LoginPresentableErrorMapper.swift index bc1eb938..f4c8725b 100644 --- a/PIA VPN-tvOS/Login/Presentation/LoginPresentableErrorMapper.swift +++ b/PIA VPN-tvOS/Login/Presentation/LoginPresentableErrorMapper.swift @@ -10,6 +10,17 @@ import Foundation class LoginPresentableErrorMapper { func map(error: LoginError) -> String? { - return nil + switch error { + case .unauthorized: + return "Your username or password is incorrect." + case .throttled(retryAfter: let retryAfter): + return "Too many failed login attempts with this username. Please try again after \(retryAfter) second(s)." + case .generic(message: let message): + return message + case .usernameWrongFormat, .passwordWrongFormat: + return "You must enter a username and password." + default: + return nil + } } } diff --git a/PIA VPN-tvOS/Login/Presentation/LoginStatus.swift b/PIA VPN-tvOS/Login/Presentation/LoginStatus.swift new file mode 100644 index 00000000..70dc647e --- /dev/null +++ b/PIA VPN-tvOS/Login/Presentation/LoginStatus.swift @@ -0,0 +1,37 @@ +// +// LoginStatus.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 13/12/23. +// Copyright © 2023 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +enum LoginCredentialsError { + case none + case username + case password +} + +enum LoginStatus { + case none + case isLogging + case failed(errorMessage: String?, field: LoginCredentialsError) + case succeeded(userAccount: UserAccount) +} + +extension LoginStatus: Equatable { + public static func == (lhs: LoginStatus, rhs: LoginStatus) -> Bool { + switch (lhs, rhs) { + case (.none, .none), (.isLogging, .isLogging): + return true + case let (.failed(lhsErrorMessage, lhsField), .failed(rhsErrorMessage, rhsField)): + return lhsErrorMessage == rhsErrorMessage && lhsField == rhsField + case let (.succeeded(lhsAccount), .succeeded(rhsAccount)): + return lhsAccount == rhsAccount + default: + return false + } + } +} diff --git a/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift b/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift index e6ea6627..699a57fa 100644 --- a/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift +++ b/PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift @@ -8,68 +8,73 @@ import Foundation -enum LoginStatus { - case none - case isLogging - case failed(error: LoginError) - case succeeded(userAccount: UserAccount) -} - class LoginViewModel: ObservableObject { private let loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType private let checkLoginAvailability: CheckLoginAvailabilityType private let validateLoginCredentials: ValidateCredentialsFormatType - private let errorMapper: LoginPresentableErrorMapper + private let errorHandler: LoginViewModelErrorHandlerType - var loginStatus: LoginStatus = .none + @Published var isAccountExpired = false + @Published var didLoginSuccessfully = false + @Published var shouldShowErrorMessage = false + @Published var loginStatus: LoginStatus = .none - init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorMapper: LoginPresentableErrorMapper) { + init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorHandler: LoginViewModelErrorHandlerType) { self.loginWithCredentialsUseCase = loginWithCredentialsUseCase self.checkLoginAvailability = checkLoginAvailability self.validateLoginCredentials = validateLoginCredentials - self.errorMapper = errorMapper + self.errorHandler = errorHandler } - func login(username: String, password: String) async { + func login(username: String, password: String) { if case .isLogging = loginStatus { return } if case .failure(let error) = checkLoginAvailability() { - handleError(error: error) + handleError(error) return } if case .failure(let error) = validateLoginCredentials(username: username, password: password) { - // Handle credentials wrong format - loginStatus = .failed(error: error) + handleError(error) return } loginStatus = .isLogging - await withCheckedContinuation { continuation in - loginWithCredentialsUseCase.execute(username: username, password: password) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let userAccount): + loginWithCredentialsUseCase.execute(username: username, password: password) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let userAccount): + Task { @MainActor in self.loginStatus = .succeeded(userAccount: userAccount) + self.didLoginSuccessfully = true + } - case .failure(let error): - self.handleError(error: error) - } - continuation.resume() + case .failure(let error): + handleError(error) } } } - private func handleError(error: LoginError) { + private func handleError(_ error: LoginError) { + guard error != .expired else { + Task { @MainActor in + loginStatus = .failed(errorMessage: nil, field: .none) + isAccountExpired = true + } + return + } + if case .throttled(let delay) = error { checkLoginAvailability.disableLoginFor(delay) } - let errorMessage = errorMapper.map(error: error) - loginStatus = .failed(error: error) + Task { @MainActor in + loginStatus = errorHandler(error: error) + shouldShowErrorMessage = true + } } } diff --git a/PIA VPN-tvOS/Login/Presentation/LoginViewModelErrorHandler.swift b/PIA VPN-tvOS/Login/Presentation/LoginViewModelErrorHandler.swift new file mode 100644 index 00000000..e8b3eaf7 --- /dev/null +++ b/PIA VPN-tvOS/Login/Presentation/LoginViewModelErrorHandler.swift @@ -0,0 +1,37 @@ +// +// LoginViewModelErrorHandler.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 13/12/23. +// Copyright © 2023 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +protocol LoginViewModelErrorHandlerType { + func callAsFunction(error: LoginError) -> LoginStatus +} + +class LoginViewModelErrorHandler: LoginViewModelErrorHandlerType { + private let errorMapper: LoginPresentableErrorMapper + + init(errorMapper: LoginPresentableErrorMapper) { + self.errorMapper = errorMapper + } + + func callAsFunction(error: LoginError) -> LoginStatus { + guard error != .usernameWrongFormat && error != .passwordWrongFormat else { + return handleCredentialsError(error) + } + + let message = errorMapper.map(error: error) + return .failed(errorMessage: message, field: .none) + } + + private func handleCredentialsError(_ error: LoginError) -> LoginStatus { + let message = errorMapper.map(error: error) + let field: LoginCredentialsError = error == .usernameWrongFormat ? .username : .password + + return .failed(errorMessage: message, field: field) + } +} diff --git a/PIA VPN-tvOS/Login/UI/LoginView.swift b/PIA VPN-tvOS/Login/UI/LoginView.swift index d868c5b3..0ee07ddb 100644 --- a/PIA VPN-tvOS/Login/UI/LoginView.swift +++ b/PIA VPN-tvOS/Login/UI/LoginView.swift @@ -20,17 +20,29 @@ struct LoginView: View { var body: some View { VStack { - Text("Sign in to your account") - .font(.system(size: 36)) - TextField("Username(p1234567)", text: $userName) - TextField("Password", text: $password) - Button(action: { - Task { - await viewModel.login(username: userName, password: password) + ZStack { + if viewModel.loginStatus == .isLogging { + ProgressView().progressViewStyle(.circular) } - }, label: { - Text("LOGIN") - }) - } + + VStack { + Text("Sign in to your account") + .font(.system(size: 36)) + TextField("Username(p1234567)", text: $userName) + SecureField("Password", text: $password) + Button(action: { + viewModel.login(username: userName, password: password) + }, label: { + Text("LOGIN") + }) + } + } + }.alert("Error", isPresented: $viewModel.shouldShowErrorMessage, actions: { + Button("OK") {} + }, message: { + if case .failed(let errorMessage, _) = viewModel.loginStatus, let errorMessage = errorMessage { + Text(errorMessage) + } + }) } } diff --git a/PIA VPN-tvOSTests/Login/Helpers/Equatable.swift b/PIA VPN-tvOSTests/Login/Helpers/Equatable.swift deleted file mode 100644 index c74f65d8..00000000 --- a/PIA VPN-tvOSTests/Login/Helpers/Equatable.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Equatable.swift -// PIA VPN-tvOSTests -// -// Created by Said Rehouni on 29/11/23. -// Copyright © 2023 Private Internet Access Inc. All rights reserved. -// - -import Foundation -@testable import PIA_VPN_tvOS -import PIALibrary - -extension LoginStatus: Equatable { - public static func == (lhs: LoginStatus, rhs: LoginStatus) -> Bool { - switch (lhs, rhs) { - case (.none, .none), (.isLogging, .isLogging): - return true - case let (.failed(lhsError), .failed(rhsError)): - return lhsError == rhsError - case let (.succeeded(lhsAccount), .succeeded(rhsAccount)): - return lhsAccount == rhsAccount - default: - return false - } - } -} - -extension LoginError: Equatable { - public static func == (lhs: LoginError, rhs: LoginError) -> Bool { - switch (lhs, rhs) { - case (.unauthorized, .unauthorized), (.expired, .expired), (.usernameWrongFormat, .usernameWrongFormat), (.passwordWrongFormat, .passwordWrongFormat): - return true - case let (.throttled(lhsDelay), .throttled(rhsDelay)): - return lhsDelay == rhsDelay - default: - return false - } - } -} diff --git a/PIA VPN-tvOSTests/Login/Helpers/Stubs+PIALibrary.swift b/PIA VPN-tvOSTests/Login/Helpers/Stubs+PIALibrary.swift new file mode 100644 index 00000000..9c450cd5 --- /dev/null +++ b/PIA VPN-tvOSTests/Login/Helpers/Stubs+PIALibrary.swift @@ -0,0 +1,72 @@ +// +// Stubs.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 29/11/23. +// Copyright © 2023 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary +import account + +extension PIALibrary.UserAccount { + static func makeStub() -> PIALibrary.UserAccount { + let credentials = PIALibrary.Credentials(username: "username", + password: "password") + return PIALibrary.UserAccount(credentials: credentials, + info: AccountInfo.makeStub()) + } +} + +extension PIALibrary.AccountInfo { + static func makeStub() -> PIALibrary.AccountInfo { + + let account = AccountInformation(active: true, + canInvite: true, + canceled: true, + daysRemaining: 0, + email: "email", + expirationTime: 0, + expireAlert: true, + expired: true, + needsPayment: true, + plan: "monthly", + productId: "productId", + recurring: true, + renewUrl: "renewUrl", + renewable: true, + username: "username") + + return PIALibrary.AccountInfo(accountInformation: account) + } +} + +extension UserAccount: Equatable { + public static func == (lhs: PIALibrary.UserAccount, rhs: PIALibrary.UserAccount) -> Bool { + lhs.credentials == rhs.credentials + && lhs.info == rhs.info + } +} + +extension Credentials: Equatable { + public static func == (lhs: Credentials, rhs: Credentials) -> Bool { + lhs.username == rhs.username && lhs.password == rhs.password + } +} + +extension AccountInfo: Equatable { + public static func == (lhs: AccountInfo, rhs: AccountInfo) -> Bool { + lhs.email == rhs.email + && lhs.username == rhs.username + && lhs.plan == rhs.plan + && lhs.productId == rhs.productId + && lhs.isRenewable == rhs.isRenewable + && lhs.isRecurring == rhs.isRecurring + && lhs.expirationDate == rhs.expirationDate + && lhs.canInvite == rhs.canInvite + && lhs.isExpired == rhs.isExpired + && lhs.shouldPresentExpirationAlert == rhs.shouldPresentExpirationAlert + && lhs.renewUrl == rhs.renewUrl + } +} diff --git a/PIA VPN-tvOSTests/Login/Helpers/Stubs.swift b/PIA VPN-tvOSTests/Login/Helpers/Stubs.swift index 773f78d4..3710adda 100644 --- a/PIA VPN-tvOSTests/Login/Helpers/Stubs.swift +++ b/PIA VPN-tvOSTests/Login/Helpers/Stubs.swift @@ -32,32 +32,3 @@ extension AccountInfo { renewUrl: URL(string: "https://an-url.com")) } } - -extension UserAccount: Equatable { - public static func == (lhs: UserAccount, rhs: UserAccount) -> Bool { - lhs.credentials == rhs.credentials - && lhs.info == rhs.info - } -} - -extension Credentials: Equatable { - public static func == (lhs: Credentials, rhs: Credentials) -> Bool { - lhs.username == rhs.username && lhs.password == rhs.password - } -} - -extension AccountInfo: Equatable { - public static func == (lhs: AccountInfo, rhs: AccountInfo) -> Bool { - lhs.email == rhs.email - && lhs.username == rhs.username - && lhs.plan == rhs.plan - && lhs.productId == rhs.productId - && lhs.isRenewable == rhs.isRenewable - && lhs.isRecurring == rhs.isRecurring - && lhs.expirationDate == rhs.expirationDate - && lhs.canInvite == rhs.canInvite - && lhs.isExpired == rhs.isExpired - && lhs.shouldPresentExpirationAlert == rhs.shouldPresentExpirationAlert - && lhs.renewUrl == rhs.renewUrl - } -} diff --git a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift index 3b6ac3c8..2d859db1 100644 --- a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift @@ -8,11 +8,12 @@ import XCTest import PIALibrary +import Combine @testable import PIA_VPN_tvOS final class LoginIntegrationTests: XCTestCase { - func test_login_succeeds() async throws { + func test_login_succeeds() throws { // GIVEN let userAccount = PIALibrary.UserAccount.makeStub() let accountProviderMock = AccountProviderMock(userResult: userAccount, @@ -27,15 +28,34 @@ final class LoginIntegrationTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for didLoginSuccessfully property to be updated") XCTAssertEqual(sut.loginStatus, .none) + + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$didLoginSuccessfully.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) // WHEN - await sut.login(username: "username", password: "password") + sut.login(username: "username", password: "password") // THEN - guard case .succeeded(let capturedUserResult) = sut.loginStatus else { + wait(for: [expectation], timeout: 1) + XCTAssertFalse(sut.shouldShowErrorMessage) + XCTAssertFalse(sut.isAccountExpired) + XCTAssertEqual(capturedLoginStatuses.count, 2) + XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) + + guard case .succeeded(let capturedUserResult) = capturedLoginStatuses[1] else { XCTFail("Expected success, got failure") return } @@ -60,11 +80,9 @@ final class LoginIntegrationTests: XCTestCase { default: XCTFail("Expected the same plan, got \(capturedPlan) and \(userPlan)") } - - } - func test_login_fails() async throws { + func test_login_fails() { // GIVEN let accountProviderMock = AccountProviderMock(userResult: nil, errorResult: ClientError.expired) @@ -78,14 +96,32 @@ final class LoginIntegrationTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for isAccountExpired property to be updated") XCTAssertEqual(sut.loginStatus, .none) + + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$isAccountExpired.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) // WHEN - await sut.login(username: "username", password: "password") + sut.login(username: "username", password: "password") // THEN - XCTAssertEqual(sut.loginStatus, .failed(error: .expired)) + 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)) } } diff --git a/PIA VPN-tvOSTests/Login/LoginProviderTests.swift b/PIA VPN-tvOSTests/Login/LoginProviderTests.swift new file mode 100644 index 00000000..be800ce9 --- /dev/null +++ b/PIA VPN-tvOSTests/Login/LoginProviderTests.swift @@ -0,0 +1,161 @@ +// +// LoginProviderTests.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 12/12/23. +// Copyright © 2023 Private Internet Access Inc. All rights reserved. +// + +import XCTest +import PIALibrary +@testable import PIA_VPN_tvOS + +final class LoginProviderTests: XCTestCase { + + func test_login_succeeds_when_accountprovider_completes_with_user_and_no_error() throws { + // GIVEN + let user = PIALibrary.UserAccount.makeStub() + let accountProviderMock = AccountProviderMock(userResult: user, + errorResult: nil) + + let sut = LoginProvider(accountProvider: accountProviderMock, + userAccountMapper: UserAccountMapper()) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + + var capturedResult: Result? + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: credentials) { result in + expectation.fulfill() + capturedResult = result + } + + // THEN + wait(for: [expectation], timeout: 1.0) + guard case .success(let capturedUserResult) = capturedResult else { + XCTFail("Expected success, got failure") + return + } + + XCTAssertEqual(capturedUserResult.credentials.username, user.credentials.username) + XCTAssertEqual(capturedUserResult.credentials.password, user.credentials.password) + XCTAssertEqual(capturedUserResult.isRenewable, user.isRenewable) + XCTAssertEqual(capturedUserResult.info?.email, user.info?.email) + XCTAssertEqual(capturedUserResult.info?.username, user.info?.username) + XCTAssertEqual(capturedUserResult.info?.productId, user.info?.productId) + XCTAssertEqual(capturedUserResult.info?.isRenewable, user.info?.isRenewable) + XCTAssertEqual(capturedUserResult.info?.isRecurring, user.info?.isRecurring) + XCTAssertEqual(capturedUserResult.info?.expirationDate, user.info?.expirationDate) + XCTAssertEqual(capturedUserResult.info?.canInvite, user.info?.canInvite) + + let capturedPlan = try XCTUnwrap(capturedUserResult.info?.plan) + let userPlan = try XCTUnwrap(user.info?.plan) + + switch (capturedPlan, userPlan) { + case (PIA_VPN_tvOS.Plan.monthly, PIALibrary.Plan.monthly), (PIA_VPN_tvOS.Plan.yearly, PIALibrary.Plan.yearly), (PIA_VPN_tvOS.Plan.trial, PIALibrary.Plan.trial), (PIA_VPN_tvOS.Plan.other, PIALibrary.Plan.other): + XCTAssertTrue(true) + default: + XCTFail("Expected the same plan, got \(capturedPlan) and \(userPlan)") + } + + } + + func test_login_fails_when_accountprovider_completes_with_user_and_error() throws { + // GIVEN + let user = PIALibrary.UserAccount.makeStub() + let accountProviderMock = AccountProviderMock(userResult: user, + errorResult: ClientError.expired) + + let sut = LoginProvider(accountProvider: accountProviderMock, + userAccountMapper: UserAccountMapper()) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + + var capturedResult: Result? + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: credentials) { result in + expectation.fulfill() + capturedResult = result + } + + // THEN + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure, got success") + return + } + + guard case ClientError.expired = error else { + XCTFail("Expected expired error, got \(error)") + return + } + } + + func test_login_fails_when_accountprovider_completes_with_no_user_and_error() throws { + // GIVEN + let accountProviderMock = AccountProviderMock(userResult: nil, + errorResult: ClientError.expired) + + let sut = LoginProvider(accountProvider: accountProviderMock, + userAccountMapper: UserAccountMapper()) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + + var capturedResult: Result? + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: credentials) { result in + expectation.fulfill() + capturedResult = result + } + + // THEN + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure, got success") + return + } + + guard case ClientError.expired = error else { + XCTFail("Expected expired error, got \(error)") + return + } + } + + func test_login_fails_when_accountprovider_completes_with_no_user_and_no_error() throws { + // GIVEN + let accountProviderMock = AccountProviderMock(userResult: nil, + errorResult: nil) + + let sut = LoginProvider(accountProvider: accountProviderMock, + userAccountMapper: UserAccountMapper()) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + + var capturedResult: Result? + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: credentials) { result in + expectation.fulfill() + capturedResult = result + } + + // THEN + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure, got success") + return + } + + guard case ClientError.unexpectedReply = error else { + XCTFail("Expected unexpectedReply error, got \(error)") + return + } + } +} diff --git a/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift b/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift index e5af3050..ececaf0f 100644 --- a/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginViewModelTests.swift @@ -7,11 +7,12 @@ // import XCTest +import Combine @testable import PIA_VPN_tvOS final class LoginViewModelTests: XCTestCase { - func test_login_fails_when_checkAvailability_returns_failure() async throws { + func test_login_fails_when_checkAvailability_returns_failure() { // GIVEN let userAccount = UserAccount.makeStub() let resultLoginUseCase: Result = .success(userAccount) @@ -23,18 +24,35 @@ final class LoginViewModelTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") XCTAssertEqual(sut.loginStatus, LoginStatus.none) + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$shouldShowErrorMessage.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + // WHEN - await sut.login(username: "username", password: "password") + sut.login(username: "username", password: "password") // THEN - XCTAssertEqual(sut.loginStatus, LoginStatus.failed(error: .throttled(retryAfter: 20))) + wait(for: [expectation], timeout: 1) + XCTAssertFalse(sut.isAccountExpired) + 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)) } - func test_login_fails_when_username_is_invalid() async throws { + func test_login_fails_when_username_is_invalid() { // GIVEN let userAccount = UserAccount.makeStub() let resultLoginUseCase: Result = .success(userAccount) @@ -46,18 +64,35 @@ final class LoginViewModelTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") XCTAssertEqual(sut.loginStatus, LoginStatus.none) + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$shouldShowErrorMessage.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + // WHEN - await sut.login(username: "", password: "password") + sut.login(username: "", password: "password") // THEN - XCTAssertEqual(sut.loginStatus, LoginStatus.failed(error: .usernameWrongFormat)) + wait(for: [expectation], timeout: 1) + XCTAssertFalse(sut.isAccountExpired) + XCTAssertFalse(sut.didLoginSuccessfully) + XCTAssertEqual(capturedLoginStatuses.count, 1) + XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.failed(errorMessage: "You must enter a username and password.", field: .username)) } - func test_login_fails_when_password_is_invalid() async throws { + func test_login_fails_when_password_is_invalid() { // GIVEN let userAccount = UserAccount.makeStub() let resultLoginUseCase: Result = .success(userAccount) @@ -69,19 +104,36 @@ final class LoginViewModelTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") XCTAssertEqual(sut.loginStatus, LoginStatus.none) + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$shouldShowErrorMessage.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + // WHEN - await sut.login(username: "username", password: "") + sut.login(username: "username", password: "") // THEN - XCTAssertEqual(sut.loginStatus, LoginStatus.failed(error: .passwordWrongFormat)) + wait(for: [expectation], timeout: 1) + XCTAssertFalse(sut.isAccountExpired) + XCTAssertFalse(sut.didLoginSuccessfully) + XCTAssertEqual(capturedLoginStatuses.count, 1) + XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.failed(errorMessage: "You must enter a username and password.", field: .password)) } - func test_login_succeeds_when_loginUseCase_completes_with_success() async throws { + func test_login_succeeds_when_loginUseCase_completes_with_success() { // GIVEN let userAccount = UserAccount.makeStub() let resultLoginUseCase: Result = .success(userAccount) @@ -93,18 +145,76 @@ final class LoginViewModelTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for didLoginSuccessfully property to be updated") XCTAssertEqual(sut.loginStatus, LoginStatus.none) + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$didLoginSuccessfully.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + // WHEN - await sut.login(username: "username", password: "password") + sut.login(username: "username", password: "password") // THEN - XCTAssertEqual(sut.loginStatus, LoginStatus.succeeded(userAccount: userAccount)) + wait(for: [expectation], timeout: 1) + XCTAssertFalse(sut.shouldShowErrorMessage) + XCTAssertFalse(sut.isAccountExpired) + XCTAssertEqual(capturedLoginStatuses.count, 2) + XCTAssertEqual(capturedLoginStatuses[0], LoginStatus.isLogging) + XCTAssertEqual(capturedLoginStatuses[1], LoginStatus.succeeded(userAccount: userAccount)) } - func test_login_fails_when_loginUseCase_completes_with_failure() async throws { + func test_login_fails_when_loginUseCase_completes_with_expired_error() { + // GIVEN + let resultLoginUseCase: Result = .failure(.expired) + let loginWithCredentialsUseCaseMock = LoginWithCredentialsUseCaseMock(result: resultLoginUseCase) + + let resultCheckLoginAvailability: Result = .success(()) + let checkLoginAvailabilityMock = CheckLoginAvailabilityMock(result: resultCheckLoginAvailability) + + let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, + checkLoginAvailability: checkLoginAvailabilityMock, + validateLoginCredentials: ValidateCredentialsFormat(), + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + + var cancellables = Set() + let expectation = expectation(description: "Waiting for isAccountExpired property to be updated") + XCTAssertEqual(sut.loginStatus, LoginStatus.none) + + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$isAccountExpired.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + + // WHEN + sut.login(username: "username", password: "password") + + // 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)) + } + + func test_login_fails_when_loginUseCase_completes_with_unauthorized_error() { // GIVEN let resultLoginUseCase: Result = .failure(.unauthorized) let loginWithCredentialsUseCaseMock = LoginWithCredentialsUseCaseMock(result: resultLoginUseCase) @@ -115,14 +225,32 @@ final class LoginViewModelTests: XCTestCase { let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCaseMock, checkLoginAvailability: checkLoginAvailabilityMock, validateLoginCredentials: ValidateCredentialsFormat(), - errorMapper: LoginPresentableErrorMapper()) + errorHandler: LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())) + var cancellables = Set() + let expectation = expectation(description: "Waiting for shouldShowErrorMessage property to be updated") XCTAssertEqual(sut.loginStatus, LoginStatus.none) + var capturedLoginStatuses = [LoginStatus]() + + sut.$loginStatus.dropFirst().sink(receiveValue: { status in + capturedLoginStatuses.append(status) + }).store(in: &cancellables) + + sut.$shouldShowErrorMessage.dropFirst().sink(receiveValue: { status in + XCTAssertTrue(status) + expectation.fulfill() + }).store(in: &cancellables) + // WHEN - await sut.login(username: "username", password: "password") + sut.login(username: "username", password: "password") // THEN - XCTAssertEqual(sut.loginStatus, LoginStatus.failed(error: .unauthorized)) + 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)) } } diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index 4bdd5819..0f5468ca 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -173,8 +173,6 @@ 695BF81F2AC410E000D1139C /* (null) in Sources */ = {isa = PBXBuildFile; }; 698C3B492B2B33650012D527 /* VpnConnectionUseCaseTypeMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698C3B482B2B33650012D527 /* VpnConnectionUseCaseTypeMock.swift */; }; 698C3B4B2B2B34760012D527 /* PIAConnectionButtonViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698C3B4A2B2B34760012D527 /* PIAConnectionButtonViewModelTests.swift */; }; - 698C3B4C2B2B3CA10012D527 /* Stubs+PIALibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28822B2902E200744E5F /* Stubs+PIALibrary.swift */; }; - 698C3B4D2B2B3CA60012D527 /* LoginProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28842B2902E700744E5F /* LoginProviderTests.swift */; }; 698C3B4E2B2B3CBE0012D527 /* LoginIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28742B279BB600744E5F /* LoginIntegrationTests.swift */; }; 698F4F2B2AB8A2080010B2B0 /* PIAWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8269A6D5251CB5E0000B4DBF /* PIAWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 698F4F2D2AB978BF0010B2B0 /* PIACircleImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698F4F2C2AB978BF0010B2B0 /* PIACircleImageView.swift */; }; @@ -548,6 +546,10 @@ E5AB287F2B28F6EF00744E5F /* Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB287E2B28F6EF00744E5F /* Stubs.swift */; }; E5AB28872B2911C900744E5F /* AccountProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28862B2911C900744E5F /* AccountProviderMock.swift */; }; E5AB288A2B29BDED00744E5F /* Foundation+PIA.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28892B29BDED00744E5F /* Foundation+PIA.swift */; }; + E5AB288C2B2A487A00744E5F /* LoginViewModelErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB288B2B2A487A00744E5F /* LoginViewModelErrorHandler.swift */; }; + E5AB288E2B2A489500744E5F /* LoginStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB288D2B2A489500744E5F /* LoginStatus.swift */; }; + E5AB28972B2C782C00744E5F /* Stubs+PIALibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28962B2C782C00744E5F /* Stubs+PIALibrary.swift */; }; + E5AB28992B2C783600744E5F /* LoginProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AB28982B2C783600744E5F /* LoginProviderTests.swift */; }; E5C507682B0D609300200A6A /* PIALibrary in Frameworks */ = {isa = PBXBuildFile; productRef = E5C507672B0D609300200A6A /* PIALibrary */; }; E5C5076A2B0D60A500200A6A /* PIALibrary in Frameworks */ = {isa = PBXBuildFile; productRef = E5C507692B0D60A500200A6A /* PIALibrary */; }; E5C5076C2B0D60AD00200A6A /* PIALibrary in Frameworks */ = {isa = PBXBuildFile; productRef = E5C5076B2B0D60AD00200A6A /* PIALibrary */; }; @@ -572,7 +574,6 @@ E5C507B32B17E5AD00200A6A /* LoginError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507B22B17E5AD00200A6A /* LoginError.swift */; }; E5C507B72B17E75B00200A6A /* CheckLoginAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507B62B17E75B00200A6A /* CheckLoginAvailability.swift */; }; E5C507B92B17E7A400200A6A /* ValidateCredentialsFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507B82B17E7A400200A6A /* ValidateCredentialsFormat.swift */; }; - E5C507BE2B1F6FA600200A6A /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507BD2B1F6FA600200A6A /* Equatable.swift */; }; E5C507C02B1F700C00200A6A /* CheckLoginAvailabilityMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507BF2B1F700C00200A6A /* CheckLoginAvailabilityMock.swift */; }; E5C507C22B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507C12B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift */; }; E5C507C42B1F72E700200A6A /* CheckLoginAvailabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C507C32B1F72E700200A6A /* CheckLoginAvailabilityTests.swift */; }; @@ -1128,10 +1129,12 @@ E5AB287A2B28B4C400744E5F /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; E5AB287C2B28E4E700744E5F /* UserAccountMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccountMapper.swift; sourceTree = ""; }; E5AB287E2B28F6EF00744E5F /* Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stubs.swift; sourceTree = ""; }; - E5AB28822B2902E200744E5F /* Stubs+PIALibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Stubs+PIALibrary.swift"; path = "../../../../../../../.Trash/Stubs+PIALibrary.swift"; sourceTree = ""; }; - E5AB28842B2902E700744E5F /* LoginProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoginProviderTests.swift; path = ../../../../../../.Trash/LoginProviderTests.swift; sourceTree = ""; }; E5AB28862B2911C900744E5F /* AccountProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountProviderMock.swift; sourceTree = ""; }; E5AB28892B29BDED00744E5F /* Foundation+PIA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Foundation+PIA.swift"; sourceTree = ""; }; + E5AB288B2B2A487A00744E5F /* LoginViewModelErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelErrorHandler.swift; sourceTree = ""; }; + E5AB288D2B2A489500744E5F /* LoginStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStatus.swift; sourceTree = ""; }; + E5AB28962B2C782C00744E5F /* Stubs+PIALibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Stubs+PIALibrary.swift"; sourceTree = ""; }; + E5AB28982B2C783600744E5F /* LoginProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginProviderTests.swift; sourceTree = ""; }; E5C507772B0E144D00200A6A /* PIA VPN-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "PIA VPN-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; E5C507792B0E144E00200A6A /* PIA_VPN_tvOSApp.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIA_VPN_tvOSApp.swift; sourceTree = ""; tabWidth = 4; }; E5C5077B2B0E144E00200A6A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -1150,7 +1153,6 @@ E5C507B22B17E5AD00200A6A /* LoginError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginError.swift; sourceTree = ""; }; E5C507B62B17E75B00200A6A /* CheckLoginAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckLoginAvailability.swift; sourceTree = ""; }; E5C507B82B17E7A400200A6A /* ValidateCredentialsFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCredentialsFormat.swift; sourceTree = ""; }; - E5C507BD2B1F6FA600200A6A /* Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Equatable.swift; sourceTree = ""; }; E5C507BF2B1F700C00200A6A /* CheckLoginAvailabilityMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckLoginAvailabilityMock.swift; sourceTree = ""; }; E5C507C12B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginWithCredentialsUseCaseMock.swift; sourceTree = ""; }; E5C507C32B1F72E700200A6A /* CheckLoginAvailabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckLoginAvailabilityTests.swift; sourceTree = ""; }; @@ -2193,10 +2195,10 @@ children = ( E5C507BA2B1F6F3800200A6A /* Helpers */, E5C507A72B153E6B00200A6A /* LoginViewModelTests.swift */, + E5AB28982B2C783600744E5F /* LoginProviderTests.swift */, E5C507C32B1F72E700200A6A /* CheckLoginAvailabilityTests.swift */, E5C507CB2B1FACE000200A6A /* LoginWithCredentialsUseCaseTests.swift */, E5AB28742B279BB600744E5F /* LoginIntegrationTests.swift */, - E5AB28842B2902E700744E5F /* LoginProviderTests.swift */, ); path = Login; sourceTree = ""; @@ -2208,6 +2210,8 @@ E5C507A42B153A2F00200A6A /* LoginViewModel.swift */, E5C507AD2B17E45800200A6A /* LoginPresentableErrorMapper.swift */, E5C507B82B17E7A400200A6A /* ValidateCredentialsFormat.swift */, + E5AB288B2B2A487A00744E5F /* LoginViewModelErrorHandler.swift */, + E5AB288D2B2A489500744E5F /* LoginStatus.swift */, ); path = Presentation; sourceTree = ""; @@ -2252,12 +2256,11 @@ E5C507BA2B1F6F3800200A6A /* Helpers */ = { isa = PBXGroup; children = ( - E5C507BD2B1F6FA600200A6A /* Equatable.swift */, + E5AB28962B2C782C00744E5F /* Stubs+PIALibrary.swift */, E5C507BF2B1F700C00200A6A /* CheckLoginAvailabilityMock.swift */, E5C507C12B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift */, E5AB286B2B2796E700744E5F /* LoginProviderMock.swift */, E5AB287E2B28F6EF00744E5F /* Stubs.swift */, - E5AB28822B2902E200744E5F /* Stubs+PIALibrary.swift */, E5AB28862B2911C900744E5F /* AccountProviderMock.swift */, ); path = Helpers; @@ -3371,9 +3374,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E5AB288C2B2A487A00744E5F /* LoginViewModelErrorHandler.swift in Sources */, E5AB28732B279B7000744E5F /* LoginProvider.swift in Sources */, E5C507A52B153A2F00200A6A /* LoginViewModel.swift in Sources */, E5C507B72B17E75B00200A6A /* CheckLoginAvailability.swift in Sources */, + E5AB288E2B2A489500744E5F /* LoginStatus.swift in Sources */, E5AB286F2B27977000744E5F /* LoginFactory.swift in Sources */, 69F1C29C2B2B299300E924AE /* VpnConnectionUseCase.swift in Sources */, E5AB288A2B29BDED00744E5F /* Foundation+PIA.swift in Sources */, @@ -3408,14 +3413,13 @@ E5C507CC2B1FACE000200A6A /* LoginWithCredentialsUseCaseTests.swift in Sources */, E5C507A82B153E6B00200A6A /* LoginViewModelTests.swift in Sources */, E5AB286C2B2796E700744E5F /* LoginProviderMock.swift in Sources */, - E5C507BE2B1F6FA600200A6A /* Equatable.swift in Sources */, E5AB28872B2911C900744E5F /* AccountProviderMock.swift in Sources */, 698C3B4E2B2B3CBE0012D527 /* LoginIntegrationTests.swift in Sources */, E5C5078B2B0E145100200A6A /* PIA_VPN_tvOSTests.swift in Sources */, 698C3B4B2B2B34760012D527 /* PIAConnectionButtonViewModelTests.swift in Sources */, - 698C3B4C2B2B3CA10012D527 /* Stubs+PIALibrary.swift in Sources */, E5AB287F2B28F6EF00744E5F /* Stubs.swift in Sources */, - 698C3B4D2B2B3CA60012D527 /* LoginProviderTests.swift in Sources */, + E5AB28972B2C782C00744E5F /* Stubs+PIALibrary.swift in Sources */, + E5AB28992B2C783600744E5F /* LoginProviderTests.swift in Sources */, E5C507C22B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift in Sources */, E5C507C42B1F72E700200A6A /* CheckLoginAvailabilityTests.swift in Sources */, E5C507C02B1F700C00200A6A /* CheckLoginAvailabilityMock.swift in Sources */,