BIT-506: Hard delete (#910)

This commit is contained in:
Ramsey Smith 2024-01-31 15:07:45 -07:00 committed by Álison Fernandes
parent 087018bd26
commit 81d0e2f4db
5 changed files with 122 additions and 9 deletions

View file

@ -40,7 +40,7 @@ interface CiphersDao {
* Deletes the specified cipher associated with the given [userId] and [cipherId]. This will
* return the number of rows deleted by this query.
*/
@Query("DELETE FROM sends WHERE user_id = :userId AND id = :cipherId")
@Query("DELETE FROM ciphers WHERE user_id = :userId AND id = :cipherId")
suspend fun deleteCipher(userId: String, cipherId: String): Int
/**

View file

@ -127,7 +127,7 @@ fun VaultItemScreen(
if (pendingDeleteCipher) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.delete),
message = stringResource(id = R.string.do_you_really_want_to_soft_delete_cipher),
message = state.deletionConfirmationText(),
confirmButtonText = stringResource(id = R.string.ok),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
@ -182,7 +182,6 @@ fun VaultItemScreen(
onClick = { pendingRestoreCipher = true },
)
}
// TODO make action list dependent on item being in an organization BIT-1446
BitwardenOverflowActionItem(
menuItemDataList = persistentListOfNotNull(
OverflowMenuItemData(

View file

@ -297,7 +297,11 @@ class VaultItemViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
R.string.soft_deleting.asText(),
if (state.isCipherDeleted) {
R.string.deleting.asText()
} else {
R.string.soft_deleting.asText()
},
),
)
}
@ -305,10 +309,16 @@ class VaultItemViewModel @Inject constructor(
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.DeleteCipherReceive(
result = vaultRepository.softDeleteCipher(
cipherId = state.vaultItemId,
cipherView = cipher,
),
if (state.isCipherDeleted) {
vaultRepository.hardDeleteCipher(
cipherId = state.vaultItemId,
)
} else {
vaultRepository.softDeleteCipher(
cipherId = state.vaultItemId,
cipherView = cipher,
)
},
),
)
}
@ -693,7 +703,15 @@ class VaultItemViewModel @Inject constructor(
DeleteCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_soft_deleted.asText()))
sendEvent(
VaultItemEvent.ShowToast(
message = if (state.isCipherDeleted) {
R.string.item_deleted.asText()
} else {
R.string.item_soft_deleted.asText()
},
),
)
sendEvent(VaultItemEvent.NavigateBack)
}
}
@ -794,6 +812,17 @@ data class VaultItemState(
?.isNotEmpty()
?: false
/**
* The text to display on the deletion confirmation dialog.
*/
val deletionConfirmationText: Text
get() = if (isCipherDeleted) {
R.string.do_you_really_want_to_permanently_delete_cipher
} else {
R.string.do_you_really_want_to_soft_delete_cipher
}
.asText()
/**
* Represents the specific view states for the [VaultItemScreen].
*/

View file

@ -660,6 +660,44 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `Delete dialog text should display according to state`() {
// Open the overflow menu
composeTestRule
.onNodeWithContentDescription("More")
.performClick()
// Click on the delete item in the dropdown
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to send to the trash?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
currentCipher = createMockCipherView(
number = 1,
isDeleted = true,
),
),
),
)
}
composeTestRule
.onAllNodesWithText(
text = "Do you really want to permanently delete? This cannot be undone.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `Restore click should send show restore confirmation dialog`() {
mutableStateFlow.update {

View file

@ -42,6 +42,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.Instant
@Suppress("LargeClass")
class VaultItemViewModelTest : BaseViewModelTest() {
@ -240,6 +241,52 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `ConfirmDeleteClick with deleted cipher should should invoke hardDeleteCipher`() =
runTest {
val loginViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON
.copy(
requiresReprompt = false,
currentCipher = DEFAULT_COMMON
.currentCipher
?.copy(deletedDate = Instant.MIN),
),
)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.hardDeleteCipher(
cipherId = VAULT_ITEM_ID,
)
} returns DeleteCipherResult.Success
viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick)
viewModel.eventFlow.test {
assertEquals(
VaultItemEvent.ShowToast(R.string.item_deleted.asText()),
awaitItem(),
)
assertEquals(
VaultItemEvent.NavigateBack,
awaitItem(),
)
}
coVerify { vaultRepo.hardDeleteCipher(cipherId = VAULT_ITEM_ID) }
}
@Test
@Suppress("MaxLineLength")
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =