diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift index 70274cf670..e7f2a186f8 100644 --- a/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessorTests.swift @@ -482,7 +482,6 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ @MainActor func test_receive_accountLongPressed_lock() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -512,11 +511,46 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// lock the selected account, dismissing the profile switcher after confirmation. + @MainActor + func test_receive_accountLongPressed_lock_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify the profile switcher sheet is dismissed after lock action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .action(.lockVault(userId: otherProfile.userId, isManuallyLocking: true)), + ) + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from locking the account. @MainActor func test_receive_accountLongPressed_lock_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -542,12 +576,43 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from locking + /// the account and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_lock_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "2") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = nil + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify the profile switcher sheet is dismissed even after error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account. @MainActor func test_receive_accountLongPressed_logout() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -581,12 +646,51 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the selected account, dismissing the profile switcher after confirmation. + @MainActor + func test_receive_accountLongPressed_logout_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "2") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLoggedOutSuccessfully)) + XCTAssertEqual( + coordinator.events.last, + .action(.logout(userId: otherProfile.userId, userInitiated: true)), + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from logging out the /// account. @MainActor func test_receive_accountLongPressed_logout_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -616,12 +720,47 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from logging out + /// the account and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "2") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = nil + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed even after error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` with the active account /// dismisses the profile switcher. @MainActor func test_receive_accountPressed_active_unlocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -655,12 +794,45 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 with the active unlocked account + /// dismisses the profile switcher. + @MainActor + func test_receive_accountPressed_active_unlocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture() + authRepository.profileSwitcherState = .init( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events, + [ + .action(.switchAccount(isAutomatic: false, userId: profile.userId)), + ], + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` with the active account /// dismisses the profile switcher. @MainActor func test_receive_accountPressed_active_locked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -698,11 +870,48 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 with the active locked account + /// dismisses the profile switcher. + @MainActor + func test_receive_accountPressed_active_locked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture(isUnlocked: false) + let account = Account.fixture(profile: .fixture( + userId: profile.userId, + )) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events, + [ + .action(.switchAccount(isAutomatic: false, userId: profile.userId)), + ], + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_alternateUnlocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -741,11 +950,49 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an alternate unlocked account. + @MainActor + func test_receive_accountPressed_alternateUnlocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture() + let active = ProfileSwitcherItem.fixture() + let account = Account.fixture( + profile: .fixture( + userId: profile.userId, + ), + ) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events, + [.action(.switchAccount(isAutomatic: false, userId: profile.userId))], + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_alternateLocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -782,11 +1029,47 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an alternate locked account. + @MainActor + func test_receive_accountPressed_alternateLocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture(isUnlocked: false) + let active = ProfileSwitcherItem.fixture() + let account = Account.fixture(profile: .fixture( + userId: profile.userId, + )) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events, + [.action(.switchAccount(isAutomatic: false, userId: profile.userId))], + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_noMatch() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -819,11 +1102,43 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an account that doesn't match. + @MainActor + func test_receive_accountPressed_noMatch_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture() + let active = ProfileSwitcherItem.fixture() + authRepository.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events, + [.action(.switchAccount(isAutomatic: false, userId: profile.userId))], + ) + } + /// `receive(_:)` with `.profileSwitcher(.addAccountPressed)` updates the state to reflect the changes. @MainActor func test_receive_addAccountPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -846,11 +1161,31 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(coordinator.routes, []) } + /// `receive(_:)` with `.profileSwitcher(.addAccountPressed)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_addAccountPressed_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.addAccountPressed)) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.profileSwitcher(.backgroundTapped)` updates the state to reflect the changes. @MainActor func test_receive_backgroundTapped() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -873,6 +1208,27 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(coordinator.routes, []) } + /// `receive(_:)` with `.profileSwitcher(.backgroundTapped)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_backgroundTapped_iOS26() throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + subject.receive(.profileSwitcher(.backgroundTapped)) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.showPreLoginSettings` requests the coordinator navigate to the /// pre-login settings. @MainActor diff --git a/BitwardenShared/UI/Auth/VaultUnlock/VaultUnlockProcessorTests.swift b/BitwardenShared/UI/Auth/VaultUnlock/VaultUnlockProcessorTests.swift index 93851d261a..aea5457565 100644 --- a/BitwardenShared/UI/Auth/VaultUnlock/VaultUnlockProcessorTests.swift +++ b/BitwardenShared/UI/Auth/VaultUnlock/VaultUnlockProcessorTests.swift @@ -289,7 +289,6 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t @MainActor func test_perform_requestedProfileSwitcherVisible_false() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -312,7 +311,6 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t @MainActor func test_perform_requestedProfileSwitcherVisible_true() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -331,6 +329,26 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertTrue(subject.state.profileSwitcherState.isVisible) } + /// `showProfileSwitcher()` navigates to present the profile switcher sheet on iOS 26. + @MainActor + func test_perform_requestedProfileSwitcherVisible_true_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.requestedProfileSwitcher(visible: true))) + + XCTAssertEqual(coordinator.routes.last, .viewProfileSwitcher) + } + /// `perform(.profileSwitcher(.rowAppeared))` should not update the state for add Account @MainActor func test_perform_rowAppeared_add() async { @@ -866,8 +884,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t /// lock the selected account. @MainActor func test_receive_accountLongPressed_lock() async throws { - guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher + guard #available(iOS 26, *) else { throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -902,11 +919,51 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows alert and allows locking + /// another account, dismissing the profile switcher after confirmation. + @MainActor + func test_receive_accountLongPressed_lock_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture(userId: "1") + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = Account.fixture(profile: .fixture(userId: "1")) + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + // On iOS 26, the alert presents from the sheet, so the sheet stays visible + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify dismissal happens after lock action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .action( + .lockVault( + userId: otherProfile.userId, + isManuallyLocking: true, + ), + ), + ) + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from locking the account. @MainActor func test_receive_accountLongPressed_lock_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -932,12 +989,42 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from locking. + @MainActor + func test_receive_accountLongPressed_lock_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = nil + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify dismissal happens after lock error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account, which navigates back to the landing page for the active account. @MainActor func test_receive_accountLongPressed_logout_activeAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -972,12 +1059,53 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t ) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the selected account, which navigates back to the landing page for the active account + /// and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_activeAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + + await subject.perform(.profileSwitcher(.accountLongPressed(activeProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .action( + .logout(userId: activeProfile.userId, userInitiated: true), + ), + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account, which triggers an account switch. @MainActor func test_receive_accountLongPressed_logout_activeAccount_withAlternate() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1019,12 +1147,59 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t ) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the selected account, which triggers an account switch and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_activeAccount_withAlternate_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + stateService.accounts = [ + .fixture( + profile: .fixture( + userId: "42", + ), + ), + ] + + await subject.perform(.profileSwitcher(.accountLongPressed(activeProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .action( + .logout(userId: activeProfile.userId, userInitiated: true), + ), + ) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account, which displays a toast. @MainActor func test_receive_accountLongPressed_logout_otherAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1063,12 +1238,56 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLoggedOutSuccessfully)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the selected account, which displays a toast and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_otherAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .action(.logout(userId: otherProfile.userId, userInitiated: true)), + ) + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLoggedOutSuccessfully)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from logging out the /// account. @MainActor func test_receive_accountLongPressed_logout_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1098,11 +1317,46 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from logging out + /// the account and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = nil + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed even after error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_active_unlocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1132,11 +1386,40 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.events, []) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// when pressing the active unlocked account. + @MainActor + func test_receive_accountPressed_active_unlocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture() + authRepository.profileSwitcherState = .init( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events, []) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_active_locked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1169,11 +1452,43 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.events, []) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// when pressing the active locked account. + @MainActor + func test_receive_accountPressed_active_locked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture(isUnlocked: false) + let account = Account.fixture(profile: .fixture( + userId: profile.userId, + )) + authRepository.profileSwitcherState = .init( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile], + activeAccountId: profile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events, []) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_alternateUnlocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1208,11 +1523,45 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an alternate unlocked account. + @MainActor + func test_receive_accountPressed_alternateUnlocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture(isUnlocked: true) + let active = ProfileSwitcherItem.fixture() + let account = Account.fixture(profile: .fixture( + userId: profile.userId, + )) + authRepository.profileSwitcherState = .init( + accounts: [active, profile], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + authRepository.isLockedResult = .success(false) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_alternateLocked() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1246,11 +1595,44 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an alternate locked account. + @MainActor + func test_receive_accountPressed_alternateLocked_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture(isUnlocked: false) + let active = ProfileSwitcherItem.fixture() + let account = Account.fixture(profile: .fixture( + userId: profile.userId, + )) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.accountForItemResult = .success(account) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) + } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` updates the state to reflect the changes. @MainActor func test_receive_accountPressed_noMatch() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1280,11 +1662,40 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) } + /// `receive(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher + /// and switches to an account that doesn't match. + @MainActor + func test_receive_accountPressed_noMatch_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let profile = ProfileSwitcherItem.fixture() + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [profile, active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.accountPressed(profile))) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events, [.action(.switchAccount(isAutomatic: false, userId: profile.userId))]) + } + /// `receive(_:)` with `.profileSwitcher(.addAccountPressed)` updates the state to reflect the changes. @MainActor func test_receive_addAccountPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1307,11 +1718,33 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.routes, [.landing]) } + /// `receive(_:)` with `.profileSwitcher(.addAccountPressed)` for iOS 26 dismisses the profile switcher + /// and navigates to the landing page to add a new account. + @MainActor + func test_receive_addAccountPressed_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + await subject.perform(.profileSwitcher(.addAccountPressed)) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.routes.last, .landing) + } + /// `receive(_:)` with `.profileSwitcher(.backgroundPressed)` updates the state to reflect the changes. @MainActor func test_receive_backgroundPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1334,6 +1767,28 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(coordinator.routes, []) } + /// `receive(_:)` with `.profileSwitcher(.backgroundPressed)` for iOS 26 dismisses the profile switcher + /// when tapping the background. + @MainActor + func test_receive_backgroundPressed_iOS26() throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let active = ProfileSwitcherItem.fixture() + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [active], + activeAccountId: active.userId, + allowLockAndLogout: true, + isVisible: true, + ) + + subject.receive(.profileSwitcher(.backgroundTapped)) + + XCTAssertNotNil(subject.state.profileSwitcherState) + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.cancelPressed` notifies the delegate that cancel was pressed. @MainActor func test_receive_cancelPressed() { diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessorTests.swift index b953fe1960..143f699fed 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessorTests.swift @@ -172,7 +172,6 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: @MainActor func test_perform_profileSwitcher_accountPressed() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -191,6 +190,29 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: XCTAssertEqual(coordinator.events.last, .switchAccount(isAutomatic: false, userId: "1")) } + /// `perform(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_perform_profileSwitcher_accountPressed_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + authRepository.activeAccount = .fixture(profile: .fixture(userId: "42")) + authRepository.altAccounts = [ + .fixture(), + ] + authRepository.vaultTimeout = [ + "1": .fiveMinutes, + "42": .immediately, + ] + + await subject.perform(.profileSwitcher(.accountPressed(ProfileSwitcherItem.fixture(userId: "1")))) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual(coordinator.events.last, .switchAccount(isAutomatic: false, userId: "1")) + } + /// `perform(_:)` with `.profileSwitcher(.lock)` does nothing. @MainActor func test_perform_profileSwitcher_lock() async { @@ -204,7 +226,6 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: @MainActor func test_perform_profileSwitcher_toggleProfilesViewVisibility() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -214,6 +235,20 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: XCTAssertTrue(subject.state.profileSwitcherState.isVisible) } + /// `perform(_:)` with `.profileSwitcher(.requestedProfileSwitcher(visible:))` + /// for iOS 26 navigates to present the profile switcher sheet. + @MainActor + func test_perform_profileSwitcher_toggleProfilesViewVisibility_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = false + await subject.perform(.profileSwitcher(.requestedProfileSwitcher(visible: true))) + + XCTAssertEqual(coordinator.routes.last, .viewProfileSwitcher) + } + /// `perform(_:)` with `.search()` performs a cipher search and updates the state with the results. @MainActor func test_perform_search() { @@ -389,7 +424,6 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: @MainActor func test_receive_profileSwitcher_backgroundPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -399,6 +433,19 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable: XCTAssertFalse(subject.state.profileSwitcherState.isVisible) } + /// `receive(_:)` with `.profileSwitcher(.backgroundTapped)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_profileSwitcher_backgroundPressed_iOS26() throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + subject.receive(.profileSwitcher(.backgroundTapped)) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.profileSwitcher(.logout)` does nothing. @MainActor func test_receive_profileSwitcher_logout() async { diff --git a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift index 65a26a466c..eca2faf387 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift @@ -137,7 +137,6 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable @MainActor func test_perform_profileSwitcher_accountPressed() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -163,6 +162,36 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable ) } + /// `perform(_:)` with `.profileSwitcher(.accountPressed)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_perform_profileSwitcher_accountPressed_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + authRepository.activeAccount = .fixture(profile: .fixture(userId: "42")) + authRepository.altAccounts = [ + .fixture(), + ] + authRepository.vaultTimeout = [ + "1": .fiveMinutes, + "42": .immediately, + ] + + await subject.perform(.profileSwitcher(.accountPressed(ProfileSwitcherItem.fixture(userId: "1")))) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + XCTAssertEqual( + coordinator.events.last, + .switchAccount( + isAutomatic: false, + userId: "1", + authCompletionRoute: .tab(.vault(.vaultItemSelection(.fixtureExample))), + ), + ) + } + /// `perform(_:)` with `.profileSwitcher(.lock)` does nothing. @MainActor func test_perform_profileSwitcher_lock() async { @@ -176,7 +205,6 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable @MainActor func test_perform_profileSwitcher_toggleProfilesViewVisibility() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -186,6 +214,20 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable XCTAssertTrue(subject.state.profileSwitcherState.isVisible) } + /// `perform(_:)` with `.profileSwitcher(.requestedProfileSwitcher(visible:))` + /// for iOS 26 navigates to present the profile switcher sheet. + @MainActor + func test_perform_profileSwitcher_toggleProfilesViewVisibility_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = false + await subject.perform(.profileSwitcher(.requestedProfileSwitcher(visible: true))) + + XCTAssertEqual(coordinator.routes.last, .viewProfileSwitcher) + } + /// `perform(_:)` with `.search()` performs a vault search and updates the state with the results. @MainActor func test_perform_search() throws { @@ -488,7 +530,6 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable @MainActor func test_receive_profileSwitcher_backgroundPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -498,6 +539,19 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable XCTAssertFalse(subject.state.profileSwitcherState.isVisible) } + /// `receive(_:)` with `.profileSwitcher(.backgroundTapped)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_profileSwitcher_backgroundPressed_iOS26() throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + subject.receive(.profileSwitcher(.backgroundTapped)) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.profileSwitcher(.logout)` does nothing. @MainActor func test_receive_profileSwitcher_logout() async { diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index 0aaf1850b7..4746497479 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -707,7 +707,6 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ @MainActor func test_perform_requestedProfileSwitcher() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -726,6 +725,28 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertTrue(authRepository.checkSessionTimeoutCalled) } + /// `perform(.profileSwitcher(.requestedProfileSwitcher))` + /// for iOS 26 navigates to present the profile switcher sheet. + @MainActor + func test_perform_requestedProfileSwitcher_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + let annAccount = ProfileSwitcherItem.anneAccount + let beeAccount = ProfileSwitcherItem.beeAccount + + subject.state.profileSwitcherState.accounts = [annAccount, beeAccount] + subject.state.profileSwitcherState.isVisible = false + + authRepository.profileSwitcherState = ProfileSwitcherState.maximumAccounts + await subject.perform(.profileSwitcher(.requestedProfileSwitcher(visible: true))) + + // Ensure the coordinator navigates to present the profile switcher + XCTAssertEqual(coordinator.routes.last, .viewProfileSwitcher) + XCTAssertTrue(authRepository.checkSessionTimeoutCalled) + } + /// `perform(.profileSwitcher(.rowAppeared))` should not update the state for add Account @MainActor func test_perform_rowAppeared_add() async { @@ -1176,7 +1197,6 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ @MainActor func test_receive_accountLongPressed_lock_activeAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1206,12 +1226,47 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(coordinator.events.last, .lockVault(userId: activeProfile.userId, isManuallyLocking: true)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// lock the active account, dismissing the profile switcher after confirmation. + @MainActor + func test_receive_accountLongPressed_lock_activeAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "1") + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + authRepository.vaultTimeout = [ + "1": .fiveMinutes, + "42": .fifteenMinutes, + ] + + await subject.perform(.profileSwitcher(.accountLongPressed(activeProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify the profile switcher sheet is dismissed after lock action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(coordinator.events.last, .lockVault(userId: activeProfile.userId, isManuallyLocking: true)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// lock the selected account, which displays a toast. @MainActor func test_receive_accountLongPressed_lock_otherAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1242,11 +1297,47 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// lock the other account, which displays a toast and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_lock_otherAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture(userId: "1") + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + authRepository.vaultTimeout = [ + "1": .fiveMinutes, + "42": .fifteenMinutes, + ] + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify the profile switcher sheet is dismissed after lock action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(coordinator.events.last, .lockVault(userId: otherProfile.userId, isManuallyLocking: true)) + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLockedSuccessfully)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from locking the account. @MainActor func test_receive_accountLongPressed_lock_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1272,12 +1363,43 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from locking + /// the account and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_lock_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture(userId: "1") + let otherProfile = ProfileSwitcherItem.fixture(isUnlocked: true, userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + stateService.activeAccount = nil + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to lock the account. + let lockAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await lockAction.handler?(lockAction, []) + + // Verify the profile switcher sheet is dismissed even after error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? StateServiceError, .noActiveAccount) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account, which navigates back to the landing page for the active account. @MainActor func test_receive_accountLongPressed_logout_activeAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1307,12 +1429,47 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(coordinator.events.last, .logout(userId: activeProfile.userId, userInitiated: true)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the active account, dismissing the profile switcher after confirmation. + @MainActor + func test_receive_accountLongPressed_logout_activeAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture(userId: "1") + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + + await subject.perform(.profileSwitcher(.accountLongPressed(activeProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(coordinator.events.last, .logout(userId: activeProfile.userId, userInitiated: true)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` shows the alert and allows the user to /// log out of the selected account, which displays a toast. @MainActor func test_receive_accountLongPressed_logout_otherAccount() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1345,12 +1502,50 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLoggedOutSuccessfully)) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 shows the alert and allows the user to + /// log out of the other account, which displays a toast and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_otherAccount_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.activeAccount = .fixture() + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed after logout action + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual( + coordinator.events.last, + .logout(userId: otherProfile.userId, userInitiated: true), + ) + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.accountLoggedOutSuccessfully)) + } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` records any errors from logging out the /// account. @MainActor func test_receive_accountLongPressed_logout_error() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1380,11 +1575,46 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example) } + /// `receive(_:)` with `.profileSwitcher(.accountLongPressed)` for iOS 26 records any errors from logging out + /// the account and dismisses the profile switcher. + @MainActor + func test_receive_accountLongPressed_logout_error_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + // Set up the mock data. + let activeProfile = ProfileSwitcherItem.fixture() + let otherProfile = ProfileSwitcherItem.fixture(userId: "42") + subject.state.profileSwitcherState = ProfileSwitcherState( + accounts: [otherProfile, activeProfile], + activeAccountId: activeProfile.userId, + allowLockAndLogout: true, + isVisible: true, + ) + authRepository.getAccountError = BitwardenTestError.example + + await subject.perform(.profileSwitcher(.accountLongPressed(otherProfile))) + + // Select the alert action to log out from the account. + let logoutAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await logoutAction.handler?(logoutAction, []) + + // Confirm logging out on the second alert. + let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) + await confirmAction.handler?(confirmAction, []) + + // Verify the profile switcher sheet is dismissed even after error + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + + // Verify the results. + XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example) + } + /// `receive(_:)` with `.addAccountPressed` updates the state correctly @MainActor func test_receive_accountPressed() async throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1394,6 +1624,19 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertFalse(subject.state.profileSwitcherState.isVisible) } + /// `receive(_:)` with `.addAccountPressed` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_accountPressed_iOS26() async throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + await subject.perform(.profileSwitcher(.accountPressed(ProfileSwitcherItem.fixture()))) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.addAccountPressed` updates the state correctly @MainActor func test_receive_addAccountPressed() async { @@ -1552,7 +1795,6 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ @MainActor func test_receive_profileSwitcherBackgroundPressed() throws { guard #unavailable(iOS 26) else { - // TODO: PM-25906 - Backfill tests for new account switcher throw XCTSkip("This test requires iOS 18.6 or earlier") } @@ -1562,6 +1804,19 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertFalse(subject.state.profileSwitcherState.isVisible) } + /// `receive(_:)` with `.profileSwitcher(.backgroundTapped)` for iOS 26 dismisses the profile switcher. + @MainActor + func test_receive_profileSwitcherBackgroundPressed_iOS26() throws { + guard #available(iOS 26, *) else { + throw XCTSkip("This test requires iOS 26 or later") + } + + subject.state.profileSwitcherState.isVisible = true + subject.receive(.profileSwitcher(.backgroundTapped)) + + XCTAssertTrue(coordinator.routes.contains(.dismiss)) + } + /// `receive(_:)` with `.searchStateChanged(isSearching: false)` hides the profile switcher @MainActor func test_receive_searchTextChanged_false_noProfilesChange() {