diff --git a/PIA VPN-tvOS/Bootstraper/BootstraperFactory.swift b/PIA VPN-tvOS/Bootstraper/BootstraperFactory.swift new file mode 100644 index 00000000..31d9d986 --- /dev/null +++ b/PIA VPN-tvOS/Bootstraper/BootstraperFactory.swift @@ -0,0 +1,105 @@ +// +// BootstraperFactory.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 18/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary +import SwiftyBeaver + +class BootstraperFactory { + static func makeBootstrapper() -> BootstraperType { + Bootstrapper(setupDebugginConsole: setupDebugginConsole, + loadDataBase: loadDataBase, + cleanCurrentAccount: cleanCurrentAccount, + migrateNMT: migrateNMT, + setupLatestRegionList: setupLatestRegionList, + setupConfiguration: setupConfiguration, + setupPreferences: setupPreferences, + acceptDataSharing: acceptDataSharing, + dependencyBootstrap: Client.bootstrap, + renewalDIPToken: renewalDIPToken, + setupExceptionHandler: setupExceptionHandler) + } + + private static func setupDebugginConsole() { + let console = ConsoleDestination() + #if PIA_DEV + console.minLevel = .debug + #else + console.minLevel = .info + #endif + SwiftyBeaver.addDestination(console) + } + + private static func migrateNMT() { + AppPreferences.shared.migrateNMT() + } + + private static func loadDataBase() { + Client.database = Client.Database(group: AppConstants.appGroup) + } + + private static func setupPreferences() { + let defaults = Client.preferences.defaults + defaults.isPersistentConnection = true + defaults.mace = false + defaults.vpnType = IKEv2Profile.vpnType + } + + private static func cleanCurrentAccount() { + // Check if should clean the account after delete the app and install again + if Client.providers.accountProvider.shouldCleanAccount { + //If first install, we need to ensure we don't have data from previous sessions in the Secure Keychain + Client.providers.accountProvider.cleanDatabase() + } + } + + private static func setupLatestRegionList() { + // TODO: Fix Build Phase script to download the latest Regions list on building + /* + guard let bundledRegionsURL = AppConstants.RegionsGEN4.bundleURL else { + fatalError("Could not find bundled regions file") + } + + do { + let bundledServersJSON = try Data(contentsOf: bundledRegionsURL) + Client.configuration.bundledServersJSON = bundledServersJSON + } catch let e { + fatalError("Could not parse bundled regions file: \(e)") + } + */ + } + + private static func renewalDIPToken() { + // Check the DIP token for renewal + if AppPreferences.shared.checksDipExpirationRequest, let dipToken = Client.providers.serverProvider.dipTokens?.first { + Client.providers.serverProvider.handleDIPTokenExpiration(dipToken: dipToken, nil) + } + } + + private static func setupConfiguration() { + Client.configuration.enablesConnectivityUpdates = true + Client.configuration.enablesServerUpdates = true + Client.configuration.enablesServerPings = true + Client.configuration.webTimeout = AppConfiguration.ClientConfiguration.webTimeout + Client.configuration.vpnProfileName = AppConfiguration.VPN.profileName + } + + private static func acceptDataSharing() { + if Client.preferences.shareServiceQualityData { + ServiceQualityManager.shared.start() + } else { + ServiceQualityManager.shared.stop() + } + } + + private static func setupExceptionHandler() { + NSSetUncaughtExceptionHandler { exception in + Client.preferences.lastKnownException = "$exception,\n\(exception.callStackSymbols.joined(separator: "\n"))" + } + } +} diff --git a/PIA VPN-tvOS/Bootstrapper.swift b/PIA VPN-tvOS/Bootstrapper.swift new file mode 100644 index 00000000..c4f19ae9 --- /dev/null +++ b/PIA VPN-tvOS/Bootstrapper.swift @@ -0,0 +1,55 @@ +// +// Bootstrapper.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 17/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +protocol BootstraperType { + func callAsFunction() +} + +class Bootstrapper: BootstraperType { + var setupDebugginConsole: (() -> Void) + var loadDataBase: (() -> Void) + var cleanCurrentAccount: (() -> Void) + var migrateNMT: (() -> Void) + var setupLatestRegionList: (() -> Void) + var setupConfiguration: (() -> Void) + var setupPreferences: (() -> Void) + var acceptDataSharing: (() -> Void) + var dependencyBootstrap: (() -> Void) + var renewalDIPToken: (() -> Void) + var setupExceptionHandler: (() -> Void) + + init(setupDebugginConsole: @escaping () -> Void, loadDataBase: @escaping () -> Void, cleanCurrentAccount: @escaping () -> Void, migrateNMT: @escaping () -> Void, setupLatestRegionList: @escaping () -> Void, setupConfiguration: @escaping () -> Void, setupPreferences: @escaping () -> Void, acceptDataSharing: @escaping () -> Void, dependencyBootstrap: @escaping () -> Void, renewalDIPToken: @escaping () -> Void, setupExceptionHandler: @escaping () -> Void) { + self.setupDebugginConsole = setupDebugginConsole + self.loadDataBase = loadDataBase + self.cleanCurrentAccount = cleanCurrentAccount + self.migrateNMT = migrateNMT + self.setupLatestRegionList = setupLatestRegionList + self.setupConfiguration = setupConfiguration + self.setupPreferences = setupPreferences + self.acceptDataSharing = acceptDataSharing + self.dependencyBootstrap = dependencyBootstrap + self.renewalDIPToken = renewalDIPToken + self.setupExceptionHandler = setupExceptionHandler + } + + func callAsFunction() { + setupDebugginConsole() + loadDataBase() + cleanCurrentAccount() + migrateNMT() + setupLatestRegionList() + setupConfiguration() + setupPreferences() + acceptDataSharing() + dependencyBootstrap() + renewalDIPToken() + setupExceptionHandler() + } +} diff --git a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift index cdd2a9e4..dc56712e 100644 --- a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift +++ b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift @@ -12,6 +12,8 @@ class RootContainerFactory { fatalError("Incorrect account provider type") } return RootContainerViewModel(accountProvider: defaultAccountProvider, - vpnConfigurationAvailability: VPNConfigurationAvailability()) + vpnConfigurationAvailability: VPNConfigurationAvailability(), + bootstrap: BootstraperFactory.makeBootstrapper(), + userAuthenticationStatusMonitor: StateMonitorsFactory.makeUserAuthenticationStatusMonitor()) } } diff --git a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift index 8e1bffe7..06a17590 100644 --- a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift +++ b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import PIALibrary +import Combine class RootContainerViewModel: ObservableObject { enum State { @@ -17,12 +18,17 @@ class RootContainerViewModel: ObservableObject { private let accountProvider: AccountProviderType private let notificationCenter: NotificationCenterType private let vpnConfigurationAvailability: VPNConfigurationAvailabilityType + private let bootstrap: BootstraperType + private let userAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType + private var cancellables = Set() - init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default, vpnConfigurationAvailability: VPNConfigurationAvailabilityType) { + init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default, vpnConfigurationAvailability: VPNConfigurationAvailabilityType, bootstrap: BootstraperType, userAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType) { self.accountProvider = accountProvider self.notificationCenter = notificationCenter self.vpnConfigurationAvailability = vpnConfigurationAvailability + self.bootstrap = bootstrap + self.userAuthenticationStatusMonitor = userAuthenticationStatusMonitor updateState() subscribeToAccountUpdates() } @@ -32,9 +38,7 @@ class RootContainerViewModel: ObservableObject { } func phaseDidBecomeActive() { - // Bootstrap PIALibrary preferences and settings - // TODO: DI this object - Bootstrapper.shared.bootstrap() + bootstrap() isBootstrapped = true updateState() } @@ -61,20 +65,12 @@ class RootContainerViewModel: ObservableObject { } -// NotificationCenter subscriptions +// Combine subscriptions extension RootContainerViewModel { private func subscribeToAccountUpdates() { - notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogin), name: .PIAAccountDidLogin, object: nil) - - notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogout), name: .PIAAccountDidLogout, object: nil) - } - - @objc func handleAccountDidLogin() { - updateState() - } - - @objc func handleAccountDidLogout() { - updateState() + userAuthenticationStatusMonitor.getStatus().sink { status in + self.updateState() + }.store(in: &cancellables) } } diff --git a/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift b/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift new file mode 100644 index 00000000..8bfb7978 --- /dev/null +++ b/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift @@ -0,0 +1,26 @@ +// +// StateMonitorsFactory.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 19/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary + +class StateMonitorsFactory { + static func makeUserAuthenticationStatusMonitor() -> UserAuthenticationStatusMonitorType { + UserAuthenticationStatusMonitor(currentStatus: Client.providers.accountProvider.isLoggedIn ? .loggedIn : .loggedOut, + notificationCenter: NotificationCenter.default) + } + + static func makeVPNStatusMonitor() -> VPNStatusMonitorType { + guard let defaultVPNProvider = Client.providers.vpnProvider as? DefaultVPNProvider else { + fatalError("Incorrect vpn provider type") + } + + return VPNStatusMonitor(vpnStatusProvider: defaultVPNProvider, + notificationCenter: NotificationCenter.default) + } +} diff --git a/PIA VPN-tvOS/Shared/StateMonitors/UserAuthenticationStatusMonitor.swift b/PIA VPN-tvOS/Shared/StateMonitors/UserAuthenticationStatusMonitor.swift new file mode 100644 index 00000000..1591de47 --- /dev/null +++ b/PIA VPN-tvOS/Shared/StateMonitors/UserAuthenticationStatusMonitor.swift @@ -0,0 +1,57 @@ +// +// UserAuthenticationStatusMonitor.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 18/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import Combine + +protocol UserAuthenticationStatusMonitorType { + func getStatus() -> AnyPublisher +} + +enum UserAuthenticationStatus { + case loggedIn + case loggedOut +} + +class UserAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType { + private var status: CurrentValueSubject + private let notificationCenter: NotificationCenterType + + init(currentStatus: UserAuthenticationStatus, notificationCenter: NotificationCenterType) { + self.notificationCenter = notificationCenter + self.status = CurrentValueSubject(currentStatus) + + addObservers() + } + + private func addObservers() { + notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogin), name: .PIAAccountDidLogin, object: nil) + + notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogout), name: .PIAAccountDidLogout, object: nil) + } + + @objc func handleAccountDidLogin() { + if status.value != .loggedIn { + status.send(.loggedIn) + } + } + + @objc func handleAccountDidLogout() { + if status.value != .loggedOut { + status.send(.loggedOut) + } + } + + func getStatus() -> AnyPublisher { + return status.eraseToAnyPublisher() + } + + deinit { + notificationCenter.removeObserver(self) + } +} diff --git a/PIA VPN-tvOS/Shared/StateMonitors/VPNStatusMonitor.swift b/PIA VPN-tvOS/Shared/StateMonitors/VPNStatusMonitor.swift new file mode 100644 index 00000000..ee69aef5 --- /dev/null +++ b/PIA VPN-tvOS/Shared/StateMonitors/VPNStatusMonitor.swift @@ -0,0 +1,48 @@ +// +// VPNStatusMonitor.swift +// PIA VPN-tvOS +// +// Created by Said Rehouni on 18/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import Combine +import PIALibrary + +protocol VPNStatusMonitorType { + func getStatus() -> AnyPublisher +} + +class VPNStatusMonitor: VPNStatusMonitorType { + private var status: CurrentValueSubject + private let vpnStatusProvider: VPNStatusProviderType + private let notificationCenter: NotificationCenterType + + init(vpnStatusProvider: VPNStatusProviderType, notificationCenter: NotificationCenterType) { + self.vpnStatusProvider = vpnStatusProvider + self.notificationCenter = notificationCenter + self.status = CurrentValueSubject(vpnStatusProvider.vpnStatus) + } + + private func addObservers() { + notificationCenter.addObserver(self, + selector: #selector(vpnStatusDidChange(notification:)), + name: .PIADaemonsDidUpdateVPNStatus, + object: nil) + } + + @objc func vpnStatusDidChange(notification: Notification) { + if vpnStatusProvider.vpnStatus != status.value { + status.send(vpnStatusProvider.vpnStatus) + } + } + + func getStatus() -> AnyPublisher { + return status.eraseToAnyPublisher() + } + + deinit { + notificationCenter.removeObserver(self) + } +} diff --git a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols.swift b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols.swift index 87c39e4d..67fac72a 100644 --- a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols.swift +++ b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols.swift @@ -30,3 +30,9 @@ protocol ServerProviderType { extension DefaultServerProvider: ServerProviderType { } + +protocol VPNStatusProviderType { + var vpnStatus: VPNStatus { get } +} + +extension DefaultVPNProvider: VPNStatusProviderType {} diff --git a/PIA VPN-tvOSTests/RootContainer/Mocks/BootstraperMock.swift b/PIA VPN-tvOSTests/RootContainer/Mocks/BootstraperMock.swift new file mode 100644 index 00000000..bcaace17 --- /dev/null +++ b/PIA VPN-tvOSTests/RootContainer/Mocks/BootstraperMock.swift @@ -0,0 +1,18 @@ +// +// BootstraperMock.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 20/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class BootstraperMock: BootstraperType { + var callAsFunctionTimesCalled = 0 + + func callAsFunction() { + callAsFunctionTimesCalled += 1 + } +} diff --git a/PIA VPN-tvOSTests/RootContainer/Mocks/UserAuthenticationStatusMonitorMock.swift b/PIA VPN-tvOSTests/RootContainer/Mocks/UserAuthenticationStatusMonitorMock.swift new file mode 100644 index 00000000..82964e31 --- /dev/null +++ b/PIA VPN-tvOSTests/RootContainer/Mocks/UserAuthenticationStatusMonitorMock.swift @@ -0,0 +1,23 @@ +// +// UserAuthenticationStatusMonitorMock.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 21/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import Combine +@testable import PIA_VPN_tvOS + +class UserAuthenticationStatusMonitorMock: UserAuthenticationStatusMonitorType { + var status: CurrentValueSubject + + init(status: UserAuthenticationStatus) { + self.status = CurrentValueSubject(status) + } + + func getStatus() -> AnyPublisher { + return status.eraseToAnyPublisher() + } +} diff --git a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift index f6340e0a..f17ce4aa 100644 --- a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift +++ b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift @@ -7,6 +7,7 @@ // import XCTest +import Combine @testable import PIA_VPN_tvOS final class RootContainerViewModelTests: XCTestCase { @@ -15,6 +16,12 @@ final class RootContainerViewModelTests: XCTestCase { let accountProvierMock = AccountProviderTypeMock() let notificationCenterMock = NotificationCenterMock() var vpnConfigurationAvailabilityMock = VPNConfigurationAvailabilityMock(value: false) + let bootstrapMock = BootstraperMock() + + func makeUserAuthenticationStatusMonitorMock(status: UserAuthenticationStatus) -> UserAuthenticationStatusMonitorMock { + return UserAuthenticationStatusMonitorMock(status: status) + } + } var fixture: Fixture! @@ -33,7 +40,9 @@ final class RootContainerViewModelTests: XCTestCase { private func initializeSut(bootStrapped: Bool = true) { sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, notificationCenter: fixture.notificationCenterMock, - vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock) + vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock, + bootstrap: fixture.bootstrapMock, + userAuthenticationStatusMonitor: fixture.makeUserAuthenticationStatusMonitorMock(status: .loggedOut)) sut.isBootstrapped = bootStrapped } @@ -79,6 +88,69 @@ final class RootContainerViewModelTests: XCTestCase { // THEN the state becomes 'activated' XCTAssertEqual(sut.state, .activated) } + + func testBoostrapperIsCalled_WhenAppIsLaunched() { + // GIVEN sut is initialized + initializeSut() + + // WHEN the app is launched + sut.phaseDidBecomeActive() + + // THEN Boostrapper is called + XCTAssertEqual(fixture.bootstrapMock.callAsFunctionTimesCalled, 1) + } + + func testState_WhenUserIsNotAuthenticatedAndAuthenticates() { + // GIVEN that the user is logged out + fixture.accountProvierMock.isLoggedIn = false + // AND GIVEN that the Onboarding Vpn Profile not installed + stubOnboardingVpnInstallation(finished: true) + + let userAuthenticationStatusMonitor = fixture.makeUserAuthenticationStatusMonitorMock(status: .loggedOut) + + sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, + notificationCenter: fixture.notificationCenterMock, + vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock, + bootstrap: fixture.bootstrapMock, + userAuthenticationStatusMonitor: userAuthenticationStatusMonitor) + + // AND the app is launched + sut.phaseDidBecomeActive() + XCTAssertEqual(sut.state, .notActivated) + fixture.accountProvierMock.isLoggedIn = true + + // WHEN user authenticates + userAuthenticationStatusMonitor.status.send(.loggedIn) + + // THEN the state becomes 'activated' + XCTAssertEqual(sut.state, .activated) + } + + func testState_WhenUserIsAuthenticatedAndLogsOut() { + // GIVEN that the user is authenticated + fixture.accountProvierMock.isLoggedIn = true + // AND GIVEN that the Onboarding Vpn Profile not installed + stubOnboardingVpnInstallation(finished: true) + + let userAuthenticationStatusMonitor = fixture.makeUserAuthenticationStatusMonitorMock(status: .loggedOut) + + sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, + notificationCenter: fixture.notificationCenterMock, + vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock, + bootstrap: fixture.bootstrapMock, + userAuthenticationStatusMonitor: userAuthenticationStatusMonitor) + + // AND the app is launched + sut.phaseDidBecomeActive() + XCTAssertEqual(sut.state, .activated) + fixture.accountProvierMock.isLoggedIn = false + + // WHEN user logs out + userAuthenticationStatusMonitor.status.send(.loggedOut) + + // THEN the state becomes 'activated' + XCTAssertEqual(sut.state, .notActivated) + } } diff --git a/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift b/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift new file mode 100644 index 00000000..c295403a --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift @@ -0,0 +1,23 @@ +// +// VPNStatusProviderMock.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 21/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary +@testable import PIA_VPN_tvOS + +class VPNStatusProviderMock: VPNStatusProviderType { + var vpnStatus: VPNStatus + + init(vpnStatus: VPNStatus) { + self.vpnStatus = vpnStatus + } + + func changeStatus(vpnStatus: VPNStatus) { + self.vpnStatus = vpnStatus + } +} diff --git a/PIA VPN-tvOSTests/Shared/UserAuthenticationStatusMonitorTests.swift b/PIA VPN-tvOSTests/Shared/UserAuthenticationStatusMonitorTests.swift new file mode 100644 index 00000000..3dc2d0c0 --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/UserAuthenticationStatusMonitorTests.swift @@ -0,0 +1,70 @@ +// +// UserAuthenticationStatusMonitorTests.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 18/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import XCTest +import Combine +@testable import PIA_VPN_tvOS + +final class UserAuthenticationStatusMonitorTests: XCTestCase { + class Fixture { + let notificationCenterMock = NotificationCenterMock() + } + + var fixture: Fixture! + var sut: UserAuthenticationStatusMonitor! + var cancellables: Set! + var capturedUserAuthenticationStatus: [UserAuthenticationStatus]! + + override func setUp() { + fixture = Fixture() + cancellables = Set() + capturedUserAuthenticationStatus = [UserAuthenticationStatus]() + } + + override func tearDown() { + fixture = nil + sut = nil + cancellables = nil + capturedUserAuthenticationStatus = nil + } + + func test_observer_authStatus_change_when_newState_and_oldState_are_different() throws { + // GIVEN + let oldStatus = UserAuthenticationStatus.loggedOut + let newState = UserAuthenticationStatus.loggedIn + sut = UserAuthenticationStatusMonitor(currentStatus: oldStatus, + notificationCenter: fixture.notificationCenterMock) + + sut.getStatus().sink { status in + self.capturedUserAuthenticationStatus.append(status) + }.store(in: &cancellables) + + // WHEN + sut.handleAccountDidLogin() + + // THEN + XCTAssertEqual(capturedUserAuthenticationStatus, [oldStatus, newState]) + } + + func test_observer_authStatus_does_not_change_state_when_newState_and_oldState_are_the_same() throws { + // GIVEN + let status = UserAuthenticationStatus.loggedIn + sut = UserAuthenticationStatusMonitor(currentStatus: status, + notificationCenter: fixture.notificationCenterMock) + + sut.getStatus().sink { status in + self.capturedUserAuthenticationStatus.append(status) + }.store(in: &cancellables) + + // WHEN + sut.handleAccountDidLogin() + + // THEN + XCTAssertEqual(capturedUserAuthenticationStatus, [status]) + } +} diff --git a/PIA VPN-tvOSTests/Shared/VPNStatusMonitorTests.swift b/PIA VPN-tvOSTests/Shared/VPNStatusMonitorTests.swift new file mode 100644 index 00000000..eb322479 --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/VPNStatusMonitorTests.swift @@ -0,0 +1,77 @@ +// +// VPNStatusMonitorTests.swift +// PIA VPN-tvOSTests +// +// Created by Said Rehouni on 18/1/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import XCTest +import Combine +import PIALibrary +@testable import PIA_VPN_tvOS + +final class VPNStatusMonitorTests: XCTestCase { + class Fixture { + let notificationCenterMock = NotificationCenterMock() + + func makeVPNStatusProvider(result: VPNStatus) -> VPNStatusProviderMock { + return VPNStatusProviderMock(vpnStatus: result) + } + } + + var fixture: Fixture! + var sut: VPNStatusMonitor! + var cancellables: Set! + var capturedVPNStatus: [VPNStatus]! + + override func setUp() { + fixture = Fixture() + cancellables = Set() + capturedVPNStatus = [VPNStatus]() + } + + override func tearDown() { + fixture = nil + sut = nil + cancellables = nil + capturedVPNStatus = nil + } + + func test_observer_vpnStatus_change_when_newState_and_oldState_are_different() { + // GIVEN + let oldStatus = VPNStatus.connected + let newState = VPNStatus.disconnected + let vpnStatusProvider = fixture.makeVPNStatusProvider(result: oldStatus) + sut = VPNStatusMonitor(vpnStatusProvider: vpnStatusProvider, + notificationCenter: fixture.notificationCenterMock) + + sut.getStatus().sink { status in + self.capturedVPNStatus.append(status) + }.store(in: &cancellables) + + // WHEN + vpnStatusProvider.changeStatus(vpnStatus: newState) + sut.vpnStatusDidChange(notification: Notification(name: Notification.Name(""))) + + // THEN + XCTAssertEqual(capturedVPNStatus, [oldStatus, newState]) + } + + func test_observer_vpnStatus_does_not_change_state_when_newState_and_oldState_are_the_same() throws { + // GIVEN + let status = VPNStatus.connected + sut = VPNStatusMonitor(vpnStatusProvider: fixture.makeVPNStatusProvider(result: status), + notificationCenter: fixture.notificationCenterMock) + + sut.getStatus().sink { status in + self.capturedVPNStatus.append(status) + }.store(in: &cancellables) + + // WHEN + sut.vpnStatusDidChange(notification: Notification(name: Notification.Name(""))) + + // THEN + XCTAssertEqual(capturedVPNStatus, [status]) + } +} diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index 11f29659..1dd36622 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -180,7 +180,6 @@ 692483202AB05F18002A0407 /* PIAConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6924831F2AB05F18002A0407 /* PIAConnectionView.swift */; }; 692483222AB05F37002A0407 /* PIACircleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483212AB05F37002A0407 /* PIACircleIcon.swift */; }; 692483262AB05F85002A0407 /* PIAConnectionActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483252AB05F85002A0407 /* PIAConnectionActivityWidget.swift */; }; - 693CA5AA2B3ED5F600D38378 /* Bootstrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E98BB6D1FD5BC6200B41D6B /* Bootstrapper.swift */; }; 693CA5AB2B3ED67A00D38378 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1DF1FE4A450007C0B9B /* AppPreferences.swift */; }; 693CA5AC2B3ED69B00D38378 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1EB1FE4B9DC007C0B9B /* AppConstants.swift */; }; 693CA5AD2B3ED74100D38378 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1EE1FE4B9E6007C0B9B /* AppConfiguration.swift */; }; @@ -562,6 +561,16 @@ E52E69022B56E22A00471913 /* Signup.strings in Resources */ = {isa = PBXBuildFile; fileRef = E501CBCE2AE9806800515006 /* Signup.strings */; }; E52E69032B56E22D00471913 /* Welcome.strings in Resources */ = {isa = PBXBuildFile; fileRef = E501CBD02AE9806800515006 /* Welcome.strings */; }; E52E69042B56E26700471913 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0E7EC043209326E30029811E /* Localizable.strings */; }; + E52E690A2B5BC0ED00471913 /* VPNStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69092B5BC0ED00471913 /* VPNStatusMonitor.swift */; }; + E52E690C2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E690B2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift */; }; + E52E690F2B5D695A00471913 /* VPNStatusMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E690E2B5D695A00471913 /* VPNStatusMonitorTests.swift */; }; + E52E69112B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69102B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift */; }; + E52E69122B5DAE6100471913 /* Bootstrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69062B59CCE600471913 /* Bootstrapper.swift */; }; + E52E69152B5DB05600471913 /* BootstraperFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69142B5DB05600471913 /* BootstraperFactory.swift */; }; + E52E691A2B5DB86F00471913 /* BootstraperMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69192B5DB86F00471913 /* BootstraperMock.swift */; }; + E52E691D2B5DC2B200471913 /* StateMonitorsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E691C2B5DC2B200471913 /* StateMonitorsFactory.swift */; }; + E52E691F2B5DCEA500471913 /* UserAuthenticationStatusMonitorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E691E2B5DCEA500471913 /* UserAuthenticationStatusMonitorMock.swift */; }; + E52E69222B5DCF1F00471913 /* VPNStatusProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E52E69212B5DCF1F00471913 /* VPNStatusProviderMock.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 */; }; @@ -1244,6 +1253,16 @@ 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 = ""; }; + E52E69062B59CCE600471913 /* Bootstrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Bootstrapper.swift; path = ../Bootstrapper.swift; sourceTree = ""; }; + E52E69092B5BC0ED00471913 /* VPNStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNStatusMonitor.swift; sourceTree = ""; }; + E52E690B2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAuthenticationStatusMonitor.swift; sourceTree = ""; }; + E52E690E2B5D695A00471913 /* VPNStatusMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNStatusMonitorTests.swift; sourceTree = ""; }; + E52E69102B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAuthenticationStatusMonitorTests.swift; sourceTree = ""; }; + E52E69142B5DB05600471913 /* BootstraperFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootstraperFactory.swift; sourceTree = ""; }; + E52E69192B5DB86F00471913 /* BootstraperMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootstraperMock.swift; sourceTree = ""; }; + E52E691C2B5DC2B200471913 /* StateMonitorsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMonitorsFactory.swift; sourceTree = ""; }; + E52E691E2B5DCEA500471913 /* UserAuthenticationStatusMonitorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAuthenticationStatusMonitorMock.swift; sourceTree = ""; }; + E52E69212B5DCF1F00471913 /* VPNStatusProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNStatusProviderMock.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 = ""; }; @@ -1905,6 +1924,7 @@ 696E8F0E2B31AC3C0080BB31 /* RootContainer */ = { isa = PBXGroup; children = ( + E52E69182B5DB86100471913 /* Mocks */, 696E8F0F2B31AC690080BB31 /* RootContainerViewModelTests.swift */, ); path = RootContainer; @@ -2140,6 +2160,7 @@ 69F053442B1E054900AE0665 /* Shared */ = { isa = PBXGroup; children = ( + E52E691B2B5DC29200471913 /* StateMonitors */, 69A226B62B3079D00065EDDB /* Utils */, 69F053452B1E055300AE0665 /* UI */, ); @@ -2504,6 +2525,52 @@ path = iOS; sourceTree = ""; }; + E52E690D2B5D694500471913 /* Shared */ = { + isa = PBXGroup; + children = ( + E52E69202B5DCF1800471913 /* Mocks */, + E52E690E2B5D695A00471913 /* VPNStatusMonitorTests.swift */, + E52E69102B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift */, + ); + path = Shared; + sourceTree = ""; + }; + E52E69132B5DB04400471913 /* Bootstraper */ = { + isa = PBXGroup; + children = ( + E52E69142B5DB05600471913 /* BootstraperFactory.swift */, + E52E69062B59CCE600471913 /* Bootstrapper.swift */, + ); + path = Bootstraper; + sourceTree = ""; + }; + E52E69182B5DB86100471913 /* Mocks */ = { + isa = PBXGroup; + children = ( + E52E69192B5DB86F00471913 /* BootstraperMock.swift */, + E52E691E2B5DCEA500471913 /* UserAuthenticationStatusMonitorMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + E52E691B2B5DC29200471913 /* StateMonitors */ = { + isa = PBXGroup; + children = ( + E52E69092B5BC0ED00471913 /* VPNStatusMonitor.swift */, + E52E690B2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift */, + E52E691C2B5DC2B200471913 /* StateMonitorsFactory.swift */, + ); + path = StateMonitors; + sourceTree = ""; + }; + E52E69202B5DCF1800471913 /* Mocks */ = { + isa = PBXGroup; + children = ( + E52E69212B5DCF1F00471913 /* VPNStatusProviderMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; E5AB286D2B27975E00744E5F /* CompositionRoot */ = { isa = PBXGroup; children = ( @@ -2617,6 +2684,7 @@ E5C507782B0E144E00200A6A /* PIA VPN-tvOS */ = { isa = PBXGroup; children = ( + E52E69132B5DB04400471913 /* Bootstraper */, 693CA5AF2B3ED9E100D38378 /* PIA VPN-tvOS.entitlements */, 69A226B92B307C8A0065EDDB /* RootContainer */, 697A5F472B514D1A00661977 /* UserActivatedContainer */, @@ -2646,6 +2714,7 @@ isa = PBXGroup; children = ( 69793D9E2B59B94A000CB845 /* RegionsList */, + E52E690D2B5D694500471913 /* Shared */, E5AB28AF2B34E06000744E5F /* VPNConfigurationInstalling */, 696E8F132B31AF110080BB31 /* Common */, 696E8F0E2B31AC3C0080BB31 /* RootContainer */, @@ -3910,6 +3979,7 @@ buildActionMask = 2147483647; files = ( 6913179F2B32E4D4009B4E85 /* PIALibrary+Protocols.swift in Sources */, + E52E690A2B5BC0ED00471913 /* VPNStatusMonitor.swift in Sources */, 69FF0B082B3AD3E60074AA04 /* SelectedServerViewModel.swift in Sources */, 69FF0B0B2B3AD4C70074AA04 /* SwiftGen+Strings.swift in Sources */, 69F675AA2B592ED6000E31D5 /* ClientPreferences+Protocols.swift in Sources */, @@ -3940,6 +4010,7 @@ E5AB287D2B28E4E700744E5F /* UserAccountMapper.swift in Sources */, 697A5F512B514DC500661977 /* UserActivatedContainerFactory.swift in Sources */, 69FF0B0A2B3AD3F60074AA04 /* QuickConnectViewModel.swift in Sources */, + E52E690C2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift in Sources */, 69E61DEC2B5564C600085648 /* RegionsSelectionFactory.swift in Sources */, 699311A02B31894D00D316C8 /* Foundation+Protocols.swift in Sources */, E5AB28792B28B4B300744E5F /* AccountInfo.swift in Sources */, @@ -3956,6 +4027,7 @@ 69FF0B0D2B3AD57C0074AA04 /* SelectedServerUseCase.swift in Sources */, 69FF0B112B3AF1F00074AA04 /* QuickConnectButton.swift in Sources */, E5AB28AD2B32339C00744E5F /* VPNConfigurationInstallingView.swift in Sources */, + E52E69152B5DB05600471913 /* BootstraperFactory.swift in Sources */, E5AB28CB2B48AFA900744E5F /* VPNConfigurationAvailability.swift in Sources */, E5AB28712B279B4000744E5F /* LoginProviderType.swift in Sources */, 693CA5B02B3EDB4900D38378 /* RegionFilter.swift in Sources */, @@ -3963,7 +4035,6 @@ E52E68FE2B55E47600471913 /* Routes.swift in Sources */, E5C5077C2B0E144E00200A6A /* ContentView.swift in Sources */, 69A226BE2B307D5F0065EDDB /* RootContainerView.swift in Sources */, - 693CA5AA2B3ED5F600D38378 /* Bootstrapper.swift in Sources */, 694AC74E2B17AB9C007E7B56 /* DashboardView.swift in Sources */, 69F1C2932B2B10D400E924AE /* DashboardFactory.swift in Sources */, E5AB28B62B361E6C00744E5F /* VPNConfigurationInstallingErrorMapper.swift in Sources */, @@ -3975,10 +4046,12 @@ E5AB28A02B30C68A00744E5F /* VPNConfigurationInstallingViewModel.swift in Sources */, 69F1C2972B2B239000E924AE /* PIAConnectionButtonViewModel.swift in Sources */, 693CA5B12B3EDBEF00D38378 /* ThemeCode.swift in Sources */, + E52E691D2B5DC2B200471913 /* StateMonitorsFactory.swift in Sources */, E5C507B32B17E5AD00200A6A /* LoginError.swift in Sources */, E5AB28BB2B48815300744E5F /* VPNConfigurationInstallingStatus.swift in Sources */, E5C507CA2B1FAC3600200A6A /* LoginDomainErrorMapper.swift in Sources */, 693CA5AC2B3ED69B00D38378 /* AppConstants.swift in Sources */, + E52E69122B5DAE6100471913 /* Bootstrapper.swift in Sources */, 69FF0B0F2B3AE8F60074AA04 /* PIAImages+SwiftUI.swift in Sources */, E5AB28772B28B49A00744E5F /* UserAccount.swift in Sources */, 69A226C02B307DBF0065EDDB /* RootContainerFactory.swift in Sources */, @@ -4001,10 +4074,14 @@ E5AB286C2B2796E700744E5F /* LoginProviderMock.swift in Sources */, E5AB28872B2911C900744E5F /* AccountProviderMock.swift in Sources */, 696E8F102B31AC690080BB31 /* RootContainerViewModelTests.swift in Sources */, + E52E691F2B5DCEA500471913 /* UserAuthenticationStatusMonitorMock.swift in Sources */, + E52E690F2B5D695A00471913 /* VPNStatusMonitorTests.swift in Sources */, 698C3B4E2B2B3CBE0012D527 /* LoginIntegrationTests.swift in Sources */, + E52E69112B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift in Sources */, E5AB28D12B4B39F000744E5F /* VPNConfigurationInstallingViewModelTests.swift in Sources */, E5C5078B2B0E145100200A6A /* PIA_VPN_tvOSTests.swift in Sources */, 690FC4802B3C59AF00F6DCC8 /* QuickConnectButtonViewModelTests.swift in Sources */, + E52E691A2B5DB86F00471913 /* BootstraperMock.swift in Sources */, 698C3B4B2B2B34760012D527 /* PIAConnectionButtonViewModelTests.swift in Sources */, 690FC4862B3C5F7300F6DCC8 /* SelectedServerUseCaseMock.swift in Sources */, E5AB28E02B4C107F00744E5F /* VPNConfigurationAvailabilityMock.swift in Sources */, @@ -4022,6 +4099,7 @@ 69793DA12B59B96B000CB845 /* RegionsListViewModelTests.swift in Sources */, E5C507C02B1F700C00200A6A /* CheckLoginAvailabilityMock.swift in Sources */, 691317A12B32ED76009B4E85 /* AccountProviderTypeMock.swift in Sources */, + E52E69222B5DCF1F00471913 /* VPNStatusProviderMock.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };