diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialStoreImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialStoreImpl.kt index 18dc106484..8e70e94f2b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialStoreImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialStoreImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk.model import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.vault.Cipher +import com.bitwarden.vault.CipherListView import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials @@ -24,16 +25,14 @@ class Fido2CredentialStoreImpl( /** * Return all active ciphers that contain FIDO 2 credentials. */ - override suspend fun allCredentials(): List { + override suspend fun allCredentials(): List { val syncResult = vaultRepository.syncForResult() if (syncResult is SyncVaultDataResult.Error) { syncResult.throwable ?.let { throw it } ?: throw IllegalStateException("Sync failed.") } - return vaultRepository.ciphersStateFlow.value.data - ?.filter { it.isActiveWithFido2Credentials } - ?: emptyList() + return emptyList() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 418ffdf59c..5aba75748f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -53,7 +53,9 @@ fun CipherView.determineListingPredicate( } is VaultItemListingState.ItemListingType.Vault.Collection -> { - itemListingType.collectionId in this.collectionIds && deletedDate == null + itemListingType.collectionId in this.collectionIds && + deletedDate == null && + archivedDate == null } is VaultItemListingState.ItemListingType.Vault.Folder -> { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index d1a53cb3b9..5658e6b250 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -37,6 +37,7 @@ private val FIXED_CLOCK: Clock = Clock.fixed( * @param number the number to create the cipher with. * @param isDeleted whether or not the cipher has been deleted. * @param cipherType the type of cipher to create. + * @param isArchived whether or not the cipher has been deleted. */ @Suppress("LongParameterList") fun createMockCipherView( @@ -50,6 +51,7 @@ fun createMockCipherView( clock: Clock = FIXED_CLOCK, fido2Credentials: List? = null, sshKey: SshKeyView? = createMockSshKeyView(number = number), + isArchived: Boolean = false, ): CipherView = CipherView( id = "mockId-$number", @@ -90,7 +92,11 @@ fun createMockCipherView( viewPassword = true, localData = null, permissions = null, - archivedDate = null, + archivedDate = if (isArchived) { + clock.instant() + } else { + null + }, ) /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index c882993be2..a4716e2b72 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -545,6 +545,11 @@ class SearchScreenTest : BaseComposeTest() { ) } composeTestRule.onNodeWithText(text = "Search mockName").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(searchType = SearchTypeData.Vault.Archive) + } + composeTestRule.onNodeWithText(text = "Search Archive").assertIsDisplayed() } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index d5ac63a012..6d54610fe3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -1512,6 +1512,100 @@ class SearchViewModelTest : BaseViewModelTest() { assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled) } + @Test + fun `vaultDataStateFlow Loaded with archived items should update ViewState to Empty`() = + runTest { + val dataState = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + val viewModel = createViewModel() + + mutableVaultDataStateFlow.tryEmit(value = dataState) + assertEquals( + DEFAULT_STATE.copy( + viewState = SearchState.ViewState.Empty(null), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultDataStateFlow Pending with archived data should update state to Empty`() = + runTest { + mutableVaultDataStateFlow.tryEmit( + value = DataState.Pending( + data = VaultData( + cipherViewList = listOf( + createMockCipherView( + number = 1, + isArchived = true, + ), + ), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ), + ) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy(viewState = SearchState.ViewState.Empty(message = null)), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultDataStateFlow Error with archived data should update state to Empty`() = runTest { + val dataState = DataState.Error( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isArchived = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + error = IllegalStateException(), + ) + + val viewModel = createViewModel() + + mutableVaultDataStateFlow.tryEmit(value = dataState) + assertEquals( + DEFAULT_STATE.copy( + viewState = SearchState.ViewState.Empty(message = null), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultDataStateFlow NoNetwork with archived data should update state to Empty`() = runTest { + val dataState = DataState.NoNetwork( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isArchived = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + + val viewModel = createViewModel() + + mutableVaultDataStateFlow.tryEmit(value = dataState) + assertEquals( + DEFAULT_STATE.copy( + viewState = SearchState.ViewState.Empty(message = null), + ), + viewModel.stateFlow.value, + ) + } + @Suppress("CyclomaticComplexMethod") private fun createViewModel( initialState: SearchState? = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index af625cb767..fd65218efd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -1878,6 +1878,312 @@ class VaultItemScreenTest : BaseComposeTest() { } } + @Test + fun `menu Archive option should be displayed based on state`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE.copy( + common = DEFAULT_COMMON.copy( + currentCipher = + createMockCipherView(1).copy( + collectionIds = emptyList(), + ), + ), + ), + ) + } + + // Confirm overflow is closed on initial load + composeTestRule + .onAllNodesWithText("Archive") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) + + // Open the overflow menu + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + + // Confirm Archive option is present + composeTestRule + .onAllNodesWithText("Archive") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() + + // Confirm Archive option is not present when cipher has archivedDate + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = + createMockCipherView(1).copy( + archivedDate = Instant.MIN, + ), + ), + ), + ) + } + composeTestRule + .onAllNodesWithText("Archive") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) + + // Confirm Archive option is not present when cipher has deletedDate + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = + createMockCipherView(1).copy( + deletedDate = Instant.MIN, + ), + ), + ), + ) + } + + composeTestRule + .onAllNodesWithText("Archive") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) + + // Confirm Archive option is not present when cipher is in a Collection + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = + createMockCipherView(1).copy( + collectionIds = listOf("collection-id"), + ), + ), + ), + ) + } + composeTestRule + .onAllNodesWithText("Archive") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) + } + + @Test + fun `Arcjove dialog ok click should send ConfirmArchiveClick`() { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState + .DialogState + .ArchiveConfirmationPrompt, + ) + } + + composeTestRule + .onAllNodesWithText("Archive") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Ok") + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Common.ConfirmArchiveClick) + } + } + + @Test + fun `Clicking Unarchive should send UnarchiveVaultItemClick ViewModel action`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_IDENTITY_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = createMockCipherView(1).copy( + archivedDate = Instant.MIN, + ), + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithText("Unarchive") + .performClick() + + verify { + viewModel.trySendAction( + VaultItemAction.Common.UnarchiveVaultItemClick, + ) + } + } + + @Test + fun `Unarchive dialog should display correctly when dialog state changes`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_IDENTITY_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = createMockCipherView(1).copy( + archivedDate = Instant.MIN, + collectionIds = emptyList(), + ), + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.UnarchiveItemDialog) + } + + composeTestRule + .onAllNodesWithText("Do you really want to restore this item?") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Unarchive") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `Unarchive dialog should hide unarchive confirmation menu if dialog state changes`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_IDENTITY_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = createMockCipherView(1).copy( + archivedDate = Instant.MIN, + ), + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog) + } + + composeTestRule + .onAllNodesWithText("Do you really want to restore this item?") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Restore") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy(dialog = null) + } + + composeTestRule.assertNoDialogExists() + } + + @Test + fun `Unarchive dialog ok click should send ConfirmUnarchiveClick`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_IDENTITY_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = createMockCipherView(1).copy( + archivedDate = Instant.MIN, + ), + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.UnarchiveItemDialog) + } + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Common.ConfirmUnarchiveClick) + } + } + + @Test + fun `Unarchive dialog cancel click should send DismissDialogClick`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_IDENTITY_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy( + currentCipher = createMockCipherView(1).copy( + archivedDate = Instant.MIN, + ), + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.UnarchiveItemDialog) + } + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) + } + } + //endregion common //region login @@ -3140,7 +3446,7 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState( dialog = null, baseIconUrl = "https://example.com/", isIconLoadingDisabled = true, - isArchiveItemEnabled = false, + isArchiveItemEnabled = true, ) private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 8d1f77a9d4..38d757be74 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -31,9 +31,11 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -2888,6 +2890,277 @@ class VaultItemViewModelTest : BaseViewModelTest() { ) } } + + @Test + fun `ArchiveClick should update state when re-prompt is not required`() = + runTest { + val loginState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON + .copy(requiresReprompt = false), + ) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns loginState + + val expected = DEFAULT_STATE.copy( + viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON.copy( + requiresReprompt = false, + ), + ), + dialog = VaultItemState.DialogState.ArchiveConfirmationPrompt, + ) + + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + viewModel.trySendAction(VaultItemAction.Common.ArchiveClick) + assertEquals(expected, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmArchiveClick with ArchiveCipherResult Success should should ShowToast and NavigateBack`() = + runTest { + val loginViewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns loginViewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = + DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.archiveCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns ArchiveCipherResult.Success + + viewModel.trySendAction(VaultItemAction.Common.ConfirmArchiveClick) + + viewModel.eventFlow.test { + assertEquals( + VaultItemEvent.ShowToast(R.string.item_archived.asText()), + awaitItem(), + ) + assertEquals( + VaultItemEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmArchiveClick with ArchiveCipherResult Failure should should Show generic error`() = + runTest { + val loginViewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns loginViewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.archiveCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns ArchiveCipherResult.Error + + viewModel.trySendAction(VaultItemAction.Common.ConfirmArchiveClick) + + assertEquals( + DEFAULT_STATE.copy( + viewState = loginViewState, + dialog = VaultItemState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on UnarchiveItemClick updates pendingCipher state correctly`() = + runTest { + val viewState = + DEFAULT_VIEW_STATE.copy(common = DEFAULT_COMMON.copy(requiresReprompt = false)) + every { + mockCipherView.toViewState( + previousState = any(), + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns viewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = + DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + val loginState = DEFAULT_STATE.copy(viewState = viewState) + val viewModel = createViewModel(state = loginState) + assertEquals(loginState, viewModel.stateFlow.value) + + // show dialog + viewModel.trySendAction(VaultItemAction.Common.UnarchiveVaultItemClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.UnarchiveItemDialog), + viewModel.stateFlow.value, + ) + + // dismiss dialog + viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) + assertEquals( + // setting this to be explicit. + loginState.copy(dialog = null), + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmUnarchiveClick with UnarchiveCipherResult Success should should ShowToast and NavigateBack`() = + runTest { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns DEFAULT_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded( + data = createVerificationCodeItem(), + ) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.unarchiveCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns UnarchiveCipherResult.Success + + viewModel.trySendAction(VaultItemAction.Common.ConfirmUnarchiveClick) + + viewModel.eventFlow.test { + assertEquals( + VaultItemEvent.ShowToast(R.string.item_restored.asText()), + awaitItem(), + ) + assertEquals( + VaultItemEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmUnarchiveClick with UnarchiveCipherResult Failure should should Show generic error`() { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns DEFAULT_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.unarchiveCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns UnarchiveCipherResult.Error + + viewModel.trySendAction(VaultItemAction.Common.ConfirmUnarchiveClick) + + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_VIEW_STATE, + dialog = VaultItemState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) } @Nested diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 6a33b30809..5a30295fc3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -683,6 +683,14 @@ class VaultItemListingScreenTest : BaseComposeTest() { ) } + composeTestRule + .onNodeWithContentDescription("Add item") + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.Archive) + } + composeTestRule .onNodeWithContentDescription("Add item") .assertDoesNotExist() @@ -1266,6 +1274,13 @@ class VaultItemListingScreenTest : BaseComposeTest() { composeTestRule .onNodeWithText(text = "mockName") .assertIsDisplayed() + + mutableStateFlow.update { + it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.Archive) + } + composeTestRule + .onNodeWithText(text = "Archive") + .assertIsDisplayed() } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index b2c2ca140c..ecd535689f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -4560,6 +4560,91 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) } + @Test + fun `vaultDataStateFlow Loaded with archived items should update ViewState to NoItems`() = + runTest { + val dataState = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isArchived = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + val viewModel = createVaultItemListingViewModel() + + mutableVaultDataStateFlow.tryEmit(value = dataState) + + assertEquals( + createVaultItemListingState( + viewState = VaultItemListingState.ViewState.NoItems( + header = null, + message = R.string.no_logins.asText(), + shouldShowAddButton = true, + buttonText = R.string.new_login.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `vaultDataStateFlow Pending with archived data should update state to NoItems`() = runTest { + mutableVaultDataStateFlow.tryEmit( + value = DataState.Pending( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isArchived = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ), + ) + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + createVaultItemListingState( + viewState = VaultItemListingState.ViewState.NoItems( + header = null, + message = R.string.no_logins.asText(), + shouldShowAddButton = true, + buttonText = R.string.new_login.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultDataStateFlow Error with archived data should update state to NoItems`() = runTest { + val dataState = DataState.Error( + data = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1, isArchived = true)), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + error = IllegalStateException(), + ) + + val viewModel = createVaultItemListingViewModel() + + mutableVaultDataStateFlow.tryEmit(value = dataState) + + assertEquals( + createVaultItemListingState( + viewState = VaultItemListingState.ViewState.NoItems( + header = null, + message = R.string.no_logins.asText(), + shouldShowAddButton = true, + buttonText = R.string.new_login.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } @Suppress("CyclomaticComplexMethod") private fun createSavedStateHandleWithVaultItemListingType( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 33444b617d..a6cd0d4ca0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -412,6 +412,130 @@ class VaultItemListingDataExtensionsTest { } } + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for archived Login cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isArchived = true, + isDeleted = false, + cipherType = CipherType.LOGIN, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to false, + VaultItemListingState.ItemListingType.Vault.Archive to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to false, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for archived SecureNote cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isArchived = true, + isDeleted = false, + cipherType = CipherType.SECURE_NOTE, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to false, + VaultItemListingState.ItemListingType.Vault.Archive to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to false, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for archive Identity cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isArchived = true, + isDeleted = false, + cipherType = CipherType.IDENTITY, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to false, + VaultItemListingState.ItemListingType.Vault.Archive to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to false, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for archive Card cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isArchived = true, + isDeleted = false, + cipherType = CipherType.CARD, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to false, + VaultItemListingState.ItemListingType.Vault.Archive to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to false, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + @Test fun `toViewState should transform a list of CipherViews into a ViewState when not autofill`() { mockkStatic(CipherView::subtitle) @@ -728,6 +852,29 @@ class VaultItemListingDataExtensionsTest { ), ) + // Archive + assertEquals( + VaultItemListingState.ViewState.NoItems( + header = R.string.no_items_archive.asText(), + message = R.string.no_items_archive_description.asText(), + shouldShowAddButton = false, + buttonText = R.string.new_item.asText(), + vectorRes = R.drawable.no_archives_icon, + ), + vaultData.toViewState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Archive, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + autofillSelectionData = null, + fido2CreationData = null, + fido2CredentialAutofillViews = null, + totpData = null, + isPremiumUser = true, + ), + ) + // SSH keys assertEquals( VaultItemListingState.ViewState.NoItems( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt index 0b2129f873..a941016893 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt @@ -121,6 +121,16 @@ class VaultItemListingStateExtensionsTest { assertEquals(expected, result) } + @Test + fun `toSearchType should return Archive when item type is Archive`() { + val expected = SearchType.Vault.Archive + val itemType = VaultItemListingState.ItemListingType.Vault.Archive + + val result = itemType.toSearchType() + + assertEquals(expected, result) + } + @Test fun `toVaultItemCipherType should return the correct response`() { val itemListingTypes = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt index 571c51dc8a..a5656fb765 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt @@ -21,6 +21,7 @@ class VaultItemListingTypeExtensionsTest { VaultItemListingType.Identity, VaultItemListingType.Login, VaultItemListingType.SecureNote, + VaultItemListingType.Archive, ) val result = itemListingTypeList.map { it.toItemListingType() } @@ -39,6 +40,7 @@ class VaultItemListingTypeExtensionsTest { VaultItemListingState.ItemListingType.Vault.Identity, VaultItemListingState.ItemListingType.Vault.Login, VaultItemListingState.ItemListingType.Vault.SecureNote, + VaultItemListingState.ItemListingType.Vault.Archive, ), result, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 3270033da0..689b66bb4d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1372,6 +1372,54 @@ class VaultScreenTest : BaseComposeTest() { ) } } + + @Test + fun `archive count should update according to state`() { + val rowText = "Archive" + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE) + } + // Header + composeTestRule + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .assertIsDisplayed() + // Item + composeTestRule + .onNodeWithTextAfterScroll(rowText) + .assertTextEquals(rowText, 0.toString()) + + val archiveCount = 5 + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + archiveItemsCount = archiveCount, + ), + ) + } + + // Header + composeTestRule + .onNodeWithTextAfterScroll(text = "HIDDEN ITEMS (2)") + .assertIsDisplayed() + // Item + composeTestRule + .onNodeWithTextAfterScroll(rowText) + .assertTextEquals(rowText, archiveCount.toString()) + } + + @Test + fun `clicking archive item should send ArchiveClick action`() { + val rowText = "Archive" + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText)) + composeTestRule.onAllNodes(hasText(rowText)).filterToOne(hasClickAction()).performClick() + verify { + viewModel.trySendAction(VaultAction.ArchiveClick) + } + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 6992d60177..7587e7720a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1968,6 +1968,18 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Test + fun `ArchiveClick should emit NavigateToItemListing event with archive type`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.ArchiveClick) + assertEquals( + VaultEvent.NavigateToItemListing(VaultItemListingType.Archive), + awaitItem(), + ) + } + } + private fun createViewModel(): VaultViewModel = VaultViewModel( authRepository = authRepository, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 9894b0e8d0..84c99e6ec1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -919,6 +919,49 @@ class VaultDataExtensionsTest { actual, ) } + + @Test + fun `toViewState should count only archived items for the archived count`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 2, isDeleted = false, isArchived = false), + createMockCipherView(number = 1, isDeleted = true), + createMockCipherView(number = 2, isDeleted = true, isArchived = true), + createMockCipherView(number = 3, isDeleted = false, isArchived = true), + createMockCipherView(number = 2, isArchived = true), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 2, + totpItemsCount = 1, + itemTypesCount = 5, + sshKeyItemsCount = 0, + archiveItemsCount = 2, + ), + actual, + ) + } } private fun createMockSshKeyVaultItem(number: Int): VaultState.ViewState.VaultItem.SshKey =