diff --git a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift index cfddfaaa..ca423787 100644 --- a/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift +++ b/PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift @@ -27,7 +27,7 @@ class LoginFactory { errorMapper: LoginDomainErrorMapper()) } - private static func makeLoginProvider() -> LoginProviderType { + static func makeLoginProvider() -> LoginProviderType { LoginProvider(accountProvider: Client.providers.accountProvider, userAccountMapper: UserAccountMapper()) } diff --git a/PIA VPN-tvOS/Login/Data/LoginProvider.swift b/PIA VPN-tvOS/Login/Data/LoginProvider.swift index b0542c23..5bfb738c 100644 --- a/PIA VPN-tvOS/Login/Data/LoginProvider.swift +++ b/PIA VPN-tvOS/Login/Data/LoginProvider.swift @@ -23,26 +23,36 @@ class LoginProvider: LoginProviderType { let request = LoginRequest(credentials: pialibraryCredentials) accountProvider.login(with: request) { [weak self] userAccount, error in - guard let self = self else { return } - - if let error = error { - completion(.failure(error)) - return - } - - guard let userAccount = userAccount else { - completion(.failure(ClientError.unexpectedReply)) - return - } - - let user = userAccountMapper.map(userAccount: userAccount) - - guard userAccount.info?.isExpired == true else { - completion(.success(user)) - return - } - - completion(.failure(ClientError.expired)) + self?.handleLoginResult(userAccount: userAccount, error: error, completion: completion) } } + + func login(with receipt: Data, completion: @escaping (Result) -> Void) { + let request = LoginReceiptRequest(receipt: receipt) + + accountProvider.login(with: request) { [weak self] userAccount, error in + self?.handleLoginResult(userAccount: userAccount, error: error, completion: completion) + } + } + + private func handleLoginResult(userAccount: PIALibrary.UserAccount?, error: Error?, completion: @escaping (Result) -> Void) { + if let error = error { + completion(.failure(error)) + return + } + + guard let userAccount = userAccount else { + completion(.failure(ClientError.unexpectedReply)) + return + } + + let user = userAccountMapper.map(userAccount: userAccount) + + guard userAccount.info?.isExpired == true else { + completion(.success(user)) + return + } + + completion(.failure(ClientError.expired)) + } } diff --git a/PIA VPN-tvOS/Login/Domain/Interfaces/LoginProviderType.swift b/PIA VPN-tvOS/Login/Domain/Interfaces/LoginProviderType.swift index 6c28bb4c..d1ff8cc2 100644 --- a/PIA VPN-tvOS/Login/Domain/Interfaces/LoginProviderType.swift +++ b/PIA VPN-tvOS/Login/Domain/Interfaces/LoginProviderType.swift @@ -10,4 +10,5 @@ import Foundation protocol LoginProviderType { func login(with credentials: Credentials, completion: @escaping (Result) -> Void) + func login(with receipt: Data, completion: @escaping (Result) -> Void) } diff --git a/PIA VPN-tvOS/LoginQR/CompositionRoot/LoginQRFactory.swift b/PIA VPN-tvOS/LoginQR/CompositionRoot/LoginQRFactory.swift index 60a2cf93..f9933c96 100644 --- a/PIA VPN-tvOS/LoginQR/CompositionRoot/LoginQRFactory.swift +++ b/PIA VPN-tvOS/LoginQR/CompositionRoot/LoginQRFactory.swift @@ -15,8 +15,9 @@ class LoginQRFactory { } private static func makeLoginQRViewModel() -> LoginQRViewModel { - LoginQRViewModel(generateLoginQRCode: generateLoginQRCodeUseCase(), - validateLoginQRCode: validateLoginQRCodeUseCase(), + LoginQRViewModel(generateLoginQRCode: makeGenerateLoginQRCodeUseCase(), + validateLoginQRCode: makeValidateLoginQRCodeUseCase(), + loginWithReceipt: makeloginWithReceiptUseCase(), onSuccessAction: { AppRouter.navigateToConnectionstatsDestinationAction() }, onNavigateAction: { @@ -25,7 +26,7 @@ class LoginQRFactory { }) } - private static func generateLoginQRCodeUseCase() -> GenerateLoginQRCodeUseCaseType { + private static func makeGenerateLoginQRCodeUseCase() -> GenerateLoginQRCodeUseCaseType { GenerateLoginQRCodeUseCase(generateLoginQRCodeProvider: makeLoginQRProvider()) } @@ -36,7 +37,7 @@ class LoginQRFactory { errorMapper: LoginQRErrorMapper()) } - private static func validateLoginQRCodeUseCase() -> ValidateLoginQRCodeUseCaseType { + private static func makeValidateLoginQRCodeUseCase() -> ValidateLoginQRCodeUseCaseType { guard let defaultAccountProvider = Client.providers.accountProvider as? DefaultAccountProvider else { fatalError("Incorrect account provider type") } @@ -44,4 +45,14 @@ class LoginQRFactory { return ValidateLoginQRCodeUseCase(accountProviderType: defaultAccountProvider, validateLoginQRCodeProvider: makeLoginQRProvider()) } + + private static func makeloginWithReceiptUseCase() -> LoginWithReceiptUseCaseType { + LoginWithReceiptUseCase(paymentProvider: makePaymentProvider(), + loginProvider: LoginFactory.makeLoginProvider(), + errorMapper: LoginDomainErrorMapper()) + } + + private static func makePaymentProvider() -> PaymentProviderType { + PaymentProvider(store: Client.store) + } } diff --git a/PIA VPN-tvOS/LoginQR/Data/PaymentProvider.swift b/PIA VPN-tvOS/LoginQR/Data/PaymentProvider.swift new file mode 100644 index 00000000..0601330c --- /dev/null +++ b/PIA VPN-tvOS/LoginQR/Data/PaymentProvider.swift @@ -0,0 +1,34 @@ +// +// PaymentProvider.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 20/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary + +class PaymentProvider: PaymentProviderType { + private let store: InAppProvider + + init(store: InAppProvider) { + self.store = store + } + + func refreshPaymentReceipt(_ completion: @escaping (Result) -> Void) { + store.refreshPaymentReceipt { [weak self] error in + if let error { + completion(.failure(error)) + return + } + + guard let receipt = self?.store.paymentReceipt else { + completion(.failure(ClientError.unexpectedReply)) + return + } + + completion(.success(receipt)) + } + } +} diff --git a/PIA VPN-tvOS/LoginQR/Domain/Interfaces/PaymentProviderType.swift b/PIA VPN-tvOS/LoginQR/Domain/Interfaces/PaymentProviderType.swift new file mode 100644 index 00000000..1f3e781d --- /dev/null +++ b/PIA VPN-tvOS/LoginQR/Domain/Interfaces/PaymentProviderType.swift @@ -0,0 +1,13 @@ +// +// PaymentProviderType.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 21/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +protocol PaymentProviderType { + func refreshPaymentReceipt(_ completion: @escaping (Result) -> Void) +} diff --git a/PIA VPN-tvOS/LoginQR/Domain/Use cases/LoginWithReceiptUseCase.swift b/PIA VPN-tvOS/LoginQR/Domain/Use cases/LoginWithReceiptUseCase.swift new file mode 100644 index 00000000..a7661680 --- /dev/null +++ b/PIA VPN-tvOS/LoginQR/Domain/Use cases/LoginWithReceiptUseCase.swift @@ -0,0 +1,54 @@ +// +// LoginWithReceiptUseCase.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 20/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +protocol LoginWithReceiptUseCaseType { + func callAsFunction() async throws -> UserAccount +} + +class LoginWithReceiptUseCase: LoginWithReceiptUseCaseType { + private let paymentProvider: PaymentProviderType + private let loginProvider: LoginProviderType + private let errorMapper: LoginDomainErrorMapperType + + init(paymentProvider: PaymentProviderType, loginProvider: LoginProviderType, errorMapper: LoginDomainErrorMapperType) { + self.paymentProvider = paymentProvider + self.loginProvider = loginProvider + self.errorMapper = errorMapper + } + + func callAsFunction() async throws -> UserAccount { + return try await withCheckedThrowingContinuation { continuation in + paymentProvider.refreshPaymentReceipt { [weak self] result in + guard let self else { return } + + switch result { + case .success(let receipt): + login(with: receipt, continuation: continuation) + + case .failure(let error): + continuation.resume(throwing: errorMapper.map(error: error)) + } + } + } + } + + private func login(with receipt: Data, continuation: CheckedContinuation) { + loginProvider.login(with: Data()) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let userAccount): + continuation.resume(returning: userAccount) + case .failure(let error): + continuation.resume(throwing: errorMapper.map(error: error)) + } + } + } +} diff --git a/PIA VPN-tvOS/LoginQR/Presentation/LoginQRViewModel.swift b/PIA VPN-tvOS/LoginQR/Presentation/LoginQRViewModel.swift index 933cf6ab..5bdc77ba 100644 --- a/PIA VPN-tvOS/LoginQR/Presentation/LoginQRViewModel.swift +++ b/PIA VPN-tvOS/LoginQR/Presentation/LoginQRViewModel.swift @@ -26,12 +26,14 @@ class LoginQRViewModel: ObservableObject { private let generateLoginQRCode: GenerateLoginQRCodeUseCaseType private let validateLoginQRCode: ValidateLoginQRCodeUseCaseType + private let loginWithReceipt: LoginWithReceiptUseCaseType private let onSuccessAction: () -> Void private let onNavigateAction: () -> Void - init(generateLoginQRCode: GenerateLoginQRCodeUseCaseType, validateLoginQRCode: ValidateLoginQRCodeUseCaseType, onSuccessAction: @escaping () -> Void, onNavigateAction: @escaping () -> Void) { + init(generateLoginQRCode: GenerateLoginQRCodeUseCaseType, validateLoginQRCode: ValidateLoginQRCodeUseCaseType, loginWithReceipt: LoginWithReceiptUseCaseType, onSuccessAction: @escaping () -> Void, onNavigateAction: @escaping () -> Void) { self.generateLoginQRCode = generateLoginQRCode self.validateLoginQRCode = validateLoginQRCode + self.loginWithReceipt = loginWithReceipt self.onSuccessAction = onSuccessAction self.onNavigateAction = onNavigateAction } @@ -61,6 +63,23 @@ class LoginQRViewModel: ObservableObject { } } + func recoverPurchases() { + state = .loading + Task { + do { + let userAccount = try await loginWithReceipt() + Task { @MainActor in + onSuccessAction() + } + } catch { + Task { @MainActor in + state = .validating + shouldShowErrorMessage = true + } + } + } + } + private func map(loginQRToken: LoginQRTokenDTO) -> LoginQRCode? { let dateString = loginQRToken.expiresAt let dateFormatter = ISO8601DateFormatter() diff --git a/PIA VPN-tvOS/LoginQR/UI/LoginQRContainerView.swift b/PIA VPN-tvOS/LoginQR/UI/LoginQRContainerView.swift index 7d892c35..c7c2b374 100644 --- a/PIA VPN-tvOS/LoginQR/UI/LoginQRContainerView.swift +++ b/PIA VPN-tvOS/LoginQR/UI/LoginQRContainerView.swift @@ -28,9 +28,11 @@ struct LoginQRContainerView: View { } } } else { - LoginQRView(expiresAt: $viewModel.expiresAt, qrCodeURL: viewModel.qrCodeURL) { + LoginQRView(expiresAt: $viewModel.expiresAt, qrCodeURL: viewModel.qrCodeURL, loginAction: { viewModel.navigateToRoute() - } + }, restorePurchasesAction: { + viewModel.recoverPurchases() + }) } }.onAppear { viewModel.generateQRCode() diff --git a/PIA VPN-tvOS/LoginQR/UI/LoginQRView.swift b/PIA VPN-tvOS/LoginQR/UI/LoginQRView.swift index bd1f9d24..c6dd53c4 100644 --- a/PIA VPN-tvOS/LoginQR/UI/LoginQRView.swift +++ b/PIA VPN-tvOS/LoginQR/UI/LoginQRView.swift @@ -12,6 +12,7 @@ struct LoginQRView: View { @Binding var expiresAt: String var qrCodeURL: URL? let loginAction: () -> Void + let restorePurchasesAction: () -> Void var body: some View { HStack { @@ -51,6 +52,11 @@ struct LoginQRView: View { action: { loginAction() } ) .frame(width: 480, height: 66) + ActionButton( + title: L10n.Localizable.Tvos.Login.Qr.Button.restore, + action: { restorePurchasesAction() } + ) + .frame(width: 480, height: 66) } } diff --git a/PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift b/PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift index e7ed8cfe..15488a44 100644 --- a/PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift +++ b/PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift @@ -52,7 +52,10 @@ class AccountProviderMock: AccountProvider { callback?(appStoreInformationResult, errorResult) } - func login(with receiptRequest: PIALibrary.LoginReceiptRequest, _ callback: PIALibrary.LibraryCallback?) {} + func login(with receiptRequest: PIALibrary.LoginReceiptRequest, _ callback: PIALibrary.LibraryCallback?) { + callback?(userResult, errorResult) + } + func refreshAccountInfo(_ callback: PIALibrary.LibraryCallback?) {} func accountInformation(_ callback: ((PIALibrary.AccountInfo?, Error?) -> Void)?) {} func update(with request: PIALibrary.UpdateAccountRequest, resetPassword reset: Bool, andPassword password: String, _ callback: PIALibrary.LibraryCallback?) {} diff --git a/PIA VPN-tvOSTests/Login/Helpers/LoginProviderMock.swift b/PIA VPN-tvOSTests/Login/Helpers/LoginProviderMock.swift index f0ed693b..51a3a203 100644 --- a/PIA VPN-tvOSTests/Login/Helpers/LoginProviderMock.swift +++ b/PIA VPN-tvOSTests/Login/Helpers/LoginProviderMock.swift @@ -19,4 +19,8 @@ class LoginProviderMock: LoginProviderType { func login(with credentials: Credentials, completion: @escaping (Result) -> Void) { completion(result) } + + func login(with receipt: Data, completion: @escaping (Result) -> Void) { + completion(result) + } } diff --git a/PIA VPN-tvOSTests/Login/LoginProviderTests.swift b/PIA VPN-tvOSTests/Login/LoginProviderTests.swift index 39f47458..3c383568 100644 --- a/PIA VPN-tvOSTests/Login/LoginProviderTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginProviderTests.swift @@ -11,24 +11,44 @@ import PIALibrary @testable import PIA_VPN_tvOS final class LoginProviderTests: XCTestCase { + class Fixture { + var accountProviderMock: AccountProviderMock! + var userAccountMapper = UserAccountMapper() + } + + var fixture: Fixture! + var sut: LoginProvider! + var capturedResult: Result? + + func instantiateSut(accountProviderResult: (PIALibrary.UserAccount?, Error?)) { + fixture.accountProviderMock = AccountProviderMock(userResult: accountProviderResult.0, errorResult: accountProviderResult.1) + sut = LoginProvider(accountProvider: fixture.accountProviderMock, + userAccountMapper: fixture.userAccountMapper) + } + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + sut = nil + capturedResult = nil + } 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 error: Error? = nil - let sut = LoginProvider(accountProvider: accountProviderMock, - userAccountMapper: UserAccountMapper()) + instantiateSut(accountProviderResult: (user, error)) 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 - capturedResult = result + sut.login(with: credentials) { [weak self] result in + self?.capturedResult = result expectation.fulfill() } @@ -65,20 +85,15 @@ final class LoginProviderTests: XCTestCase { 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()) + instantiateSut(accountProviderResult: (user, ClientError.expired)) 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 - capturedResult = result + sut.login(with: credentials) { [weak self] result in + self?.capturedResult = result expectation.fulfill() } @@ -98,20 +113,15 @@ final class LoginProviderTests: XCTestCase { func test_login_fails_when_accountprovider_completes_with_expired_user() throws { // GIVEN let user = PIALibrary.UserAccount.makeExpiredStub() - let accountProviderMock = AccountProviderMock(userResult: user, - errorResult: ClientError.expired) - let sut = LoginProvider(accountProvider: accountProviderMock, - userAccountMapper: UserAccountMapper()) + instantiateSut(accountProviderResult: (user, ClientError.expired)) 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 - capturedResult = result + sut.login(with: credentials) { [weak self] result in + self?.capturedResult = result expectation.fulfill() } @@ -130,20 +140,16 @@ final class LoginProviderTests: XCTestCase { func test_login_fails_when_accountprovider_completes_with_no_user_and_error() throws { // GIVEN - let accountProviderMock = AccountProviderMock(userResult: nil, - errorResult: ClientError.expired) + let user: PIALibrary.UserAccount? = nil - let sut = LoginProvider(accountProvider: accountProviderMock, - userAccountMapper: UserAccountMapper()) + instantiateSut(accountProviderResult: (user, ClientError.expired)) 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 - capturedResult = result + sut.login(with: credentials) { [weak self] result in + self?.capturedResult = result expectation.fulfill() } @@ -162,20 +168,176 @@ final class LoginProviderTests: XCTestCase { func test_login_fails_when_accountprovider_completes_with_no_user_and_no_error() throws { // GIVEN - let accountProviderMock = AccountProviderMock(userResult: nil, - errorResult: nil) + let user: PIALibrary.UserAccount? = nil + let error: Error? = nil + + instantiateSut(accountProviderResult: (user, error)) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: credentials) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // 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 + } + } + + func test_loginWithReceipt_succeeds_when_accountprovider_completes_with_user_and_no_error() throws { + // GIVEN + let user = PIALibrary.UserAccount.makeStub() + let error: Error? = nil + + instantiateSut(accountProviderResult: (user, error)) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: Data()) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // 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 (Plan.monthly, Plan.monthly), (Plan.yearly, Plan.yearly), (Plan.trial, PIALibrary.Plan.trial), (Plan.other, Plan.other): + XCTAssertTrue(true) + default: + XCTFail("Expected the same plan, got \(capturedPlan) and \(userPlan)") + } + + } + + func test_loginWithReceipt_fails_when_accountprovider_completes_with_user_and_error() throws { + // GIVEN + let user = PIALibrary.UserAccount.makeStub() - let sut = LoginProvider(accountProvider: accountProviderMock, - userAccountMapper: UserAccountMapper()) + instantiateSut(accountProviderResult: (user, ClientError.expired)) let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + let expectation = expectation(description: "Waiting for login to finish") - var capturedResult: Result? + // WHEN + sut.login(with: Data()) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // 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_loginWithReceipt_fails_when_accountprovider_completes_with_expired_user() throws { + // GIVEN + let user = PIALibrary.UserAccount.makeExpiredStub() + + instantiateSut(accountProviderResult: (user, ClientError.expired)) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: Data()) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // 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_loginWithReceipt_fails_when_accountprovider_completes_with_no_user_and_error() throws { + // GIVEN + let user: PIALibrary.UserAccount? = nil + + instantiateSut(accountProviderResult: (user, ClientError.expired)) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") + let expectation = expectation(description: "Waiting for login to finish") + + // WHEN + sut.login(with: Data()) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // 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_loginWithReceipt_fails_when_accountprovider_completes_with_no_user_and_no_error() throws { + // GIVEN + let user: PIALibrary.UserAccount? = nil + let error: Error? = nil + + instantiateSut(accountProviderResult: (user, error)) + + let credentials = PIA_VPN_tvOS.Credentials(username: "", password: "") let expectation = expectation(description: "Waiting for login to finish") // WHEN - sut.login(with: credentials) { result in - capturedResult = result + sut.login(with: Data()) { [weak self] result in + self?.capturedResult = result expectation.fulfill() } diff --git a/PIA VPN-tvOSTests/LoginQR/LoginQRViewModelTests.swift b/PIA VPN-tvOSTests/LoginQR/LoginQRViewModelTests.swift index 2c6f129b..1ee84d9a 100644 --- a/PIA VPN-tvOSTests/LoginQR/LoginQRViewModelTests.swift +++ b/PIA VPN-tvOSTests/LoginQR/LoginQRViewModelTests.swift @@ -15,6 +15,7 @@ final class LoginQRViewModelTests: XCTestCase { class Fixture { var generateLoginQRCodeMock: GenerateLoginQRCodeUseCaseMock! var validateLoginQRCodeMock: ValidateLoginQRCodeMock! + var loginWithReceiptMock: LoginWithReceiptUseCaseMock! } var fixture: Fixture! @@ -22,14 +23,15 @@ final class LoginQRViewModelTests: XCTestCase { var cancellables: Set! var capturedState: [LoginQRViewModel.State]! - func instantiateSut(generateLoginQRResult: Result, validateLoginQRError: ClientError?, onSuccessAction: @escaping () -> Void, onNavigateAction: @escaping () -> Void) { - + func instantiateSut(generateLoginQRResult: Result, validateLoginQRError: ClientError?, loginWithReceiptResult: Result = .failure(LoginError.unauthorized), onSuccessAction: @escaping () -> Void, onNavigateAction: @escaping () -> Void) { fixture.generateLoginQRCodeMock = GenerateLoginQRCodeUseCaseMock(result: generateLoginQRResult) fixture.validateLoginQRCodeMock = ValidateLoginQRCodeMock(error: validateLoginQRError) + fixture.loginWithReceiptMock = LoginWithReceiptUseCaseMock(result: loginWithReceiptResult) sut = LoginQRViewModel(generateLoginQRCode: fixture.generateLoginQRCodeMock, - validateLoginQRCode: fixture.validateLoginQRCodeMock, + validateLoginQRCode: fixture.validateLoginQRCodeMock, + loginWithReceipt: fixture.loginWithReceiptMock, onSuccessAction: onSuccessAction, onNavigateAction: onNavigateAction) } @@ -50,8 +52,8 @@ final class LoginQRViewModelTests: XCTestCase { func test_generateQRCode_succeeds_when_it_generates_a_token_and_its_validated() { // GIVEN let expectation = expectation(description: "Waiting for onSuccessAction") - let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(100)) - instantiateSut(generateLoginQRResult: .success(loginQRCode), + let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(1000)) + instantiateSut(generateLoginQRResult: .success(loginQRCode), validateLoginQRError: nil, onSuccessAction: { expectation.fulfill() @@ -77,7 +79,6 @@ final class LoginQRViewModelTests: XCTestCase { func test_generateQRCode_fails_when_it_generates_a_token() { // GIVEN let expectation = expectation(description: "Waiting for onSuccessAction") - let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(100)) instantiateSut(generateLoginQRResult: .failure(ClientError.malformedResponseData), validateLoginQRError: nil, onSuccessAction: { @@ -108,7 +109,7 @@ final class LoginQRViewModelTests: XCTestCase { func test_generateQRCode_fails_when_it_generates_a_token_and_its_not_validated() { // GIVEN let expectation = expectation(description: "Waiting for onSuccessAction") - let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(100)) + let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(1000)) instantiateSut(generateLoginQRResult: .success(loginQRCode), validateLoginQRError: ClientError.malformedResponseData, onSuccessAction: { @@ -139,7 +140,7 @@ final class LoginQRViewModelTests: XCTestCase { func test_navigateToRoute_succeeds() { // GIVEN let expectation = expectation(description: "Waiting for onSuccessAction") - let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(100)) + let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(1000)) instantiateSut(generateLoginQRResult: .success(loginQRCode), validateLoginQRError: ClientError.malformedResponseData, onSuccessAction: { @@ -155,4 +156,61 @@ final class LoginQRViewModelTests: XCTestCase { // THEN wait(for: [expectation], timeout: 1.0) } + + func test_recoverPurchases_succeeds_when_there_is_a_valid_purchase() { + // GIVEN + let expectation = expectation(description: "Waiting for onSuccessAction") + let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(1000)) + instantiateSut(generateLoginQRResult: .success(loginQRCode), + validateLoginQRError: nil, + loginWithReceiptResult: .success(UserAccount.makeStub()), + onSuccessAction: { + expectation.fulfill() + }, + onNavigateAction: { + XCTFail("onNavigateAction was not expected") + }) + + sut.$state.dropFirst().sink(receiveValue: { [weak self] status in + self?.capturedState.append(status) + }).store(in: &cancellables) + + // WHEN + sut.recoverPurchases() + + // THEN + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(capturedState, [.loading]) + XCTAssertFalse(sut.shouldShowErrorMessage) + } + + func test_recoverPurchases_fails_when_there_is_no_valid_purchase() { + // GIVEN + let expectation = expectation(description: "Waiting for onSuccessAction") + let loginQRCode = LoginQRCode(token: "token", expiresAt: Date().addingTimeInterval(100)) + instantiateSut(generateLoginQRResult: .failure(ClientError.malformedResponseData), + validateLoginQRError: nil, + onSuccessAction: { + XCTFail("onSuccessAction was not expected") + }, + onNavigateAction: { + XCTFail("onNavigateAction was not expected") + }) + + sut.$state.dropFirst().sink(receiveValue: { [weak self] status in + self?.capturedState.append(status) + }).store(in: &cancellables) + + sut.$shouldShowErrorMessage.dropFirst().sink(receiveValue: { value in + expectation.fulfill() + }).store(in: &cancellables) + + // WHEN + sut.recoverPurchases() + + // THEN + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(capturedState, [.loading, .validating]) + XCTAssertTrue(sut.shouldShowErrorMessage) + } } diff --git a/PIA VPN-tvOSTests/LoginQR/LoginWithReceiptUseCaseTests.swift b/PIA VPN-tvOSTests/LoginQR/LoginWithReceiptUseCaseTests.swift new file mode 100644 index 00000000..4ad52dff --- /dev/null +++ b/PIA VPN-tvOSTests/LoginQR/LoginWithReceiptUseCaseTests.swift @@ -0,0 +1,99 @@ +// +// LoginWithReceiptUseCaseTests.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 21/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import XCTest +@testable import PIA_VPN_tvOS +import PIALibrary + +final class LoginWithReceiptUseCaseTests: XCTestCase { + class Fixture { + var paymentProviderMock: PaymentProviderMock! + var loginProviderMock: LoginProviderMock! + var errorMapper = LoginDomainErrorMapper() + } + + var fixture: Fixture! + var sut: LoginWithReceiptUseCase! + + func instantiateSut(paymentProviderResult: Result, loginProviderResult: Result) { + fixture.paymentProviderMock = PaymentProviderMock(result: paymentProviderResult) + fixture.loginProviderMock = LoginProviderMock(result: loginProviderResult) + + sut = LoginWithReceiptUseCase(paymentProvider: fixture.paymentProviderMock, + loginProvider: fixture.loginProviderMock, + errorMapper: fixture.errorMapper) + } + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + sut = nil + } + + func test_login_succeeds_when_paymentProvider_completes_with_receipt_and_loginprovider_completes_with_success() async throws { + // GIVEN + let receipt = Data() + let user = PIA_VPN_tvOS.UserAccount.makeStub() + + instantiateSut(paymentProviderResult: .success(receipt), + loginProviderResult: .success(user)) + + // WHEN + let userAccount = try await sut() + + // THEN + XCTAssertEqual(userAccount, user) + } + + func test_login_fails_when_paymentProvider_completes_with_failure() async throws { + // GIVEN + let user = PIA_VPN_tvOS.UserAccount.makeStub() + + instantiateSut(paymentProviderResult: .failure(ClientError.expired), + loginProviderResult: .success(user)) + + var capturedError: Error? + + // WHEN + do { + _ = try await sut() + XCTFail("Expected error, got success") + } catch { + capturedError = error + } + + // THEN + let error = try XCTUnwrap(capturedError as? LoginError) + XCTAssertEqual(error, .expired) + } + + func test_login_fails_when_paymentProvider_completes_with_receipt_and_loginprovider_completes_with_failure() async throws { + // GIVEN + let receipt = Data() + instantiateSut(paymentProviderResult: .success(receipt), + loginProviderResult: .failure(ClientError.expired)) + + var capturedError: Error? + + // WHEN + do { + _ = try await sut() + XCTFail("Expected error, got success") + } catch { + capturedError = error + } + + // THEN + let error = try XCTUnwrap(capturedError as? LoginError) + XCTAssertEqual(error, .expired) + } + +} diff --git a/PIA VPN-tvOSTests/LoginQR/Mocks/LoginWithReceiptUseCaseMock.swift b/PIA VPN-tvOSTests/LoginQR/Mocks/LoginWithReceiptUseCaseMock.swift new file mode 100644 index 00000000..5634cbbc --- /dev/null +++ b/PIA VPN-tvOSTests/LoginQR/Mocks/LoginWithReceiptUseCaseMock.swift @@ -0,0 +1,27 @@ +// +// LoginWithReceiptUseCaseMock.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 21/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class LoginWithReceiptUseCaseMock: LoginWithReceiptUseCaseType { + private let result: Result + + init(result: Result) { + self.result = result + } + + func callAsFunction() async throws -> UserAccount { + switch result { + case .success(let userAccount): + return userAccount + case .failure(let error): + throw error + } + } +} diff --git a/PIA VPN-tvOSTests/LoginQR/Mocks/PaymentProviderMock.swift b/PIA VPN-tvOSTests/LoginQR/Mocks/PaymentProviderMock.swift new file mode 100644 index 00000000..a7c15cca --- /dev/null +++ b/PIA VPN-tvOSTests/LoginQR/Mocks/PaymentProviderMock.swift @@ -0,0 +1,22 @@ +// +// PaymentProviderMock.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 21/5/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class PaymentProviderMock: PaymentProviderType { + private let result: Result + + init(result: Result) { + self.result = result + } + + func refreshPaymentReceipt(_ completion: @escaping (Result) -> Void) { + completion(result) + } +} diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index ccd3fbdf..ac51d3b7 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -720,6 +720,12 @@ E55216412BF48862001A287F /* SignupCredentialsFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216402BF48862001A287F /* SignupCredentialsFieldView.swift */; }; E55216442BF48A9C001A287F /* SignupCredentialsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216432BF48A9C001A287F /* SignupCredentialsFactory.swift */; }; E55216462BF69D38001A287F /* SignupTermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216452BF69D38001A287F /* SignupTermsView.swift */; }; + E55216482BFD337D001A287F /* LoginWithReceiptUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216472BFD337D001A287F /* LoginWithReceiptUseCase.swift */; }; + E552164A2BFD3A71001A287F /* PaymentProviderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216492BFD3A71001A287F /* PaymentProviderType.swift */; }; + E552164C2BFD3CE5001A287F /* PaymentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E552164B2BFD3CE5001A287F /* PaymentProvider.swift */; }; + E552164E2BFDB7F4001A287F /* LoginWithReceiptUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E552164D2BFDB7F4001A287F /* LoginWithReceiptUseCaseMock.swift */; }; + E55216502BFDBB33001A287F /* LoginWithReceiptUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E552164F2BFDBB33001A287F /* LoginWithReceiptUseCaseTests.swift */; }; + E55216522BFDBD69001A287F /* PaymentProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55216512BFDBD69001A287F /* PaymentProviderMock.swift */; }; E55EDCD22B755DB1007010DB /* OnboardingComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55EDCD12B755DB1007010DB /* OnboardingComponentView.swift */; }; E55EDCD52B755E09007010DB /* OnboardingComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55EDCD42B755E09007010DB /* OnboardingComponentViewModel.swift */; }; E55EDCDA2B755E6D007010DB /* OnboardingFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55EDCD92B755E6C007010DB /* OnboardingFactory.swift */; }; @@ -1673,6 +1679,12 @@ E55216402BF48862001A287F /* SignupCredentialsFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupCredentialsFieldView.swift; sourceTree = ""; }; E55216432BF48A9C001A287F /* SignupCredentialsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupCredentialsFactory.swift; sourceTree = ""; }; E55216452BF69D38001A287F /* SignupTermsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupTermsView.swift; sourceTree = ""; }; + E55216472BFD337D001A287F /* LoginWithReceiptUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginWithReceiptUseCase.swift; sourceTree = ""; }; + E55216492BFD3A71001A287F /* PaymentProviderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderType.swift; sourceTree = ""; }; + E552164B2BFD3CE5001A287F /* PaymentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProvider.swift; sourceTree = ""; }; + E552164D2BFDB7F4001A287F /* LoginWithReceiptUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginWithReceiptUseCaseMock.swift; sourceTree = ""; }; + E552164F2BFDBB33001A287F /* LoginWithReceiptUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginWithReceiptUseCaseTests.swift; sourceTree = ""; }; + E55216512BFDBD69001A287F /* PaymentProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderMock.swift; sourceTree = ""; }; E55EDCD12B755DB1007010DB /* OnboardingComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingComponentView.swift; sourceTree = ""; }; E55EDCD42B755E09007010DB /* OnboardingComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingComponentViewModel.swift; sourceTree = ""; }; E55EDCD92B755E6C007010DB /* OnboardingFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFactory.swift; sourceTree = ""; }; @@ -3410,6 +3422,7 @@ children = ( E520FCF42B9A631E00D06C03 /* ValidateLoginQRCodeUseCase.swift */, E520FCF62B9A633600D06C03 /* GenerateLoginQRCodeUseCase.swift */, + E55216472BFD337D001A287F /* LoginWithReceiptUseCase.swift */, ); path = "Use cases"; sourceTree = ""; @@ -3432,6 +3445,7 @@ E520FD0D2BA3886100D06C03 /* LoginQRURLRequestMaker.swift */, E520FD0F2BA3889600D06C03 /* LoginQRCodeDomainMapper.swift */, E520FD1A2BA3A1A800D06C03 /* LoginQRErrorMapper.swift */, + E552164B2BFD3CE5001A287F /* PaymentProvider.swift */, ); path = Data; sourceTree = ""; @@ -3458,6 +3472,7 @@ children = ( E520FD122BA388BD00D06C03 /* GenerateLoginQRCodeProviderType.swift */, E520FD142BA388D100D06C03 /* ValidateLoginQRCodeProviderType.swift */, + E55216492BFD3A71001A287F /* PaymentProviderType.swift */, ); path = Interfaces; sourceTree = ""; @@ -3902,6 +3917,7 @@ E58A45442BA4A11A002A0704 /* LoginQRCodeDomainMapperTests.swift */, E58A45462BA4A2CD002A0704 /* LoginQRProviderTests.swift */, E58A45482BA4BE36002A0704 /* ValidateLoginQRCodeUseCaseTests.swift */, + E552164F2BFDBB33001A287F /* LoginWithReceiptUseCaseTests.swift */, ); path = LoginQR; sourceTree = ""; @@ -3913,6 +3929,8 @@ E58A453F2BA49C43002A0704 /* ValidateLoginQRCodeMock.swift */, E58A454A2BA4BE5C002A0704 /* HTTPClientMock.swift */, E58A454C2BA4C849002A0704 /* ValidateLoginQRCodeProviderMock.swift */, + E552164D2BFDB7F4001A287F /* LoginWithReceiptUseCaseMock.swift */, + E55216512BFDBD69001A287F /* PaymentProviderMock.swift */, ); path = Mocks; sourceTree = ""; @@ -5830,6 +5848,7 @@ E52E68FE2B55E47600471913 /* Routes+Onboarding.swift in Sources */, E5C5077C2B0E144E00200A6A /* ContentView.swift in Sources */, E520FCEE2B979FA900D06C03 /* LoginQRExpiredView.swift in Sources */, + E55216482BFD337D001A287F /* LoginWithReceiptUseCase.swift in Sources */, E574D9FE2B854A42000FADAF /* DedicatedIPProvider.swift in Sources */, 69A226BE2B307D5F0065EDDB /* RootContainerView.swift in Sources */, 69C5E7D02B72962700FE3126 /* TopNavigationFactory.swift in Sources */, @@ -5842,6 +5861,7 @@ E56E76132BD700C60018E279 /* SignupLoadingView.swift in Sources */, E5AB28B62B361E6C00744E5F /* VPNConfigurationInstallingErrorMapper.swift in Sources */, 69E1924E2B8E698000A97286 /* AcknowledgementsView.swift in Sources */, + E552164A2BFD3A71001A287F /* PaymentProviderType.swift in Sources */, E56E75EB2BD06F1F0018E279 /* GetAvailableProductsUseCase.swift in Sources */, E56E75E42BCEE8330018E279 /* SubscriptionOptionViewModel.swift in Sources */, E574DA152B8D41CB000FADAF /* QRImageView.swift in Sources */, @@ -5871,6 +5891,7 @@ E56E75DF2BCEE7D90018E279 /* SubscriptionOptionView.swift in Sources */, E55216382BF14A5B001A287F /* SignupDomainErrorMapper.swift in Sources */, E5C507B32B17E5AD00200A6A /* LoginError.swift in Sources */, + E552164C2BFD3CE5001A287F /* PaymentProvider.swift in Sources */, E520FCFF2B9A64C900D06C03 /* LoginQRContainerView.swift in Sources */, E5AB28BB2B48815300744E5F /* VPNConfigurationInstallingStatus.swift in Sources */, 6900D3752B972ACE00C87E2E /* Client+Protocols.swift in Sources */, @@ -5906,6 +5927,7 @@ E5AB28D02B4B39F000744E5F /* InstallVpnConfigurationProviderTests.swift in Sources */, E574DA002B854A52000FADAF /* RemoveDIPUseCaseMock.swift in Sources */, E56E760D2BD5566A0018E279 /* SignupViewModelTests.swift in Sources */, + E55216522BFDBD69001A287F /* PaymentProviderMock.swift in Sources */, 698C3B492B2B33650012D527 /* VpnConnectionUseCaseMock.swift in Sources */, 69CA26B22B59668700E78894 /* RegionsListUseCaseMock.swift in Sources */, 6963465D2B7109DA0051F8BC /* RegionsFilterUseCaseTests.swift in Sources */, @@ -5953,6 +5975,7 @@ 698C3B4B2B2B34760012D527 /* PIAConnectionButtonViewModelTests.swift in Sources */, 690FC4862B3C5F7300F6DCC8 /* SelectedServerUseCaseMock.swift in Sources */, E5AB28E02B4C107F00744E5F /* VPNConfigurationAvailabilityMock.swift in Sources */, + E55216502BFDBB33001A287F /* LoginWithReceiptUseCaseTests.swift in Sources */, 690FC4822B3C5A4300F6DCC8 /* ServerMock.swift in Sources */, 69B1C7AD2B96038D009A0629 /* RegionsContainerViewModelTests.swift in Sources */, E58A454D2BA4C849002A0704 /* ValidateLoginQRCodeProviderMock.swift in Sources */, @@ -5973,6 +5996,7 @@ E58A454B2BA4BE5C002A0704 /* HTTPClientMock.swift in Sources */, E5D7BB6E2BE1681F00585909 /* InAppTransactionMock.swift in Sources */, E58A453E2BA49C1D002A0704 /* GenerateLoginQRCodeUseCaseMock.swift in Sources */, + E552164E2BFDB7F4001A287F /* LoginWithReceiptUseCaseMock.swift in Sources */, 690FC4882B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift in Sources */, E55216362BF1393C001A287F /* SignupEmailIntegrationTests.swift in Sources */, E5AB28992B2C783600744E5F /* LoginProviderTests.swift in Sources */,