mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
Add VaultItemView tests (#315)
This commit is contained in:
parent
ca582ce271
commit
2e2fede945
4 changed files with 1325 additions and 14 deletions
|
@ -1,6 +1,9 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
@ -106,17 +109,23 @@ fun VaultItemScreen(
|
|||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultItemAction.EditClick) }
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
AnimatedVisibility(
|
||||
visible = state.viewState is VaultItemState.ViewState.Content,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_edit),
|
||||
contentDescription = stringResource(id = R.string.edit_item),
|
||||
)
|
||||
FloatingActionButton(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultItemAction.EditClick) }
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_edit),
|
||||
contentDescription = stringResource(id = R.string.edit_item),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
package com.x8bit.bitwarden.ui.util
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.hasScrollToNodeAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
/**
|
||||
* A [SemanticsMatcher] used to find progressbar nodes.
|
||||
*/
|
||||
val isProgressBar: SemanticsMatcher
|
||||
get() = SemanticsMatcher("ProgressBar") {
|
||||
it.config
|
||||
.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
|
||||
?.let { true }
|
||||
?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper that asserts that the node does not exist in the scrollable list.
|
||||
*/
|
||||
fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) {
|
||||
val scrollableNodeInteraction = onNode(hasScrollToNodeAction())
|
||||
assertThrows<AssertionError> {
|
||||
// throws since it cannot find the node.
|
||||
scrollableNodeInteraction.performScrollToNode(hasText(text))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper used to scroll to and get the matching node in a scrollable list. This is intended to
|
||||
* be used with lazy lists that would otherwise fail when calling [performScrollToNode].
|
||||
*/
|
||||
fun ComposeContentTestRule.onNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction {
|
||||
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text))
|
||||
return onNodeWithText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper used to scroll to and get a thr first matching node in a scrollable list. This is
|
||||
* intended to be used with lazy lists that would otherwise fail when calling [performScrollToNode].
|
||||
*/
|
||||
fun ComposeContentTestRule.onFirstNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction {
|
||||
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text))
|
||||
return onAllNodesWithText(text).onFirst()
|
||||
}
|
|
@ -1,14 +1,38 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onSiblings
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
|
||||
import com.x8bit.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
|
||||
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -17,6 +41,9 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToVaultEditItemId: String? = null
|
||||
|
||||
private val clipboardManager = mockk<ClipboardManager>()
|
||||
private val intentHandler = mockk<IntentHandler>()
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<VaultItemEvent>(
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
)
|
||||
|
@ -33,6 +60,8 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
viewModel = viewModel,
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToVaultEditItem = { onNavigateToVaultEditItemId = it },
|
||||
clipboardManager = clipboardManager,
|
||||
intentHandler = intentHandler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -45,13 +74,609 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `clicking close button should send CloseClick action`() {
|
||||
fun `on close click should send CloseClick`() {
|
||||
composeTestRule.onNodeWithContentDescription(label = "Close").performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.CloseClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CopyToClipboard event should invoke setText`() {
|
||||
val textString = "text"
|
||||
val text = textString.asText()
|
||||
every { clipboardManager.setText(textString.toAnnotatedString()) } just runs
|
||||
|
||||
mutableEventFlow.tryEmit(VaultItemEvent.CopyToClipboard(text))
|
||||
|
||||
verify(exactly = 1) {
|
||||
clipboardManager.setText(textString.toAnnotatedString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should invoke onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(VaultItemEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToUri event should invoke launchUri`() {
|
||||
val uriString = "http://www.example.com"
|
||||
val uri = uriString.toUri()
|
||||
every { intentHandler.launchUri(uri) } just runs
|
||||
|
||||
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToUri(uriString))
|
||||
|
||||
verify(exactly = 1) {
|
||||
intentHandler.launchUri(uri)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `basic dialog should be displayed according to state`() {
|
||||
val message = "message"
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(message).assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultItemState.DialogState.Generic("message".asText()))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(message)
|
||||
.assertIsDisplayed()
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ok click on generic dialog should emit DismissDialogClick`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultItemState.DialogState.Generic("message".asText()))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.DismissDialogClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should be displayed according to state`() {
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Loading").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultItemState.DialogState.Loading)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Loading")
|
||||
.assertIsDisplayed()
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MasterPassword dialog should be displayed according to state`() {
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Master password confirmation").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Master password confirmation")
|
||||
.assertIsDisplayed()
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ok click on master password dialog should emit DismissDialogClick`() {
|
||||
val enteredPassword = "pass1234"
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword)
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(enteredPassword))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on username copy click should send CopyUsernameClick`() {
|
||||
val username = "username1234"
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(username = username))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(username)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy username"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on breach check click should send CheckForBreachClick`() {
|
||||
val passwordData = VaultItemState.ViewState.Content.PasswordData(
|
||||
password = "12345",
|
||||
isVisible = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(passwordData.password)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Check known data breaches for this password"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on show password click should send CopyPasswordClick`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("Password")
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("Show"))
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on copy password click should send CopyPasswordClick`() {
|
||||
val passwordData = VaultItemState.ViewState.Content.PasswordData(
|
||||
password = "12345",
|
||||
isVisible = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(passwordData.password)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy password"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, launch uri button should be displayed according to state`() {
|
||||
val uriData = VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Launch"))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
uris = listOf(uriData.copy(isLaunchable = false)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Launch"))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, copy uri button should be displayed according to state`() {
|
||||
val uriData = VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
uris = listOf(uriData.copy(isCopyable = false)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on launch URI click should send LaunchClick`() {
|
||||
val uriData = VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Launch"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uriData.uri))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on copy URI click should send CopyUriClick`() {
|
||||
val uriData = VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(uriData.uri)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uriData.uri))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on show hidden field click should send HiddenFieldVisibilityClicked`() {
|
||||
val textField = VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "hidden password",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(textField.name)
|
||||
.onChildren()
|
||||
.filterToOne(hasContentDescription("Show"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Login.HiddenFieldVisibilityClicked(
|
||||
field = textField,
|
||||
isVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, copy hidden field button should be displayed according to state`() {
|
||||
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "hidden password",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(hiddenField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
customFields = listOf(hiddenField.copy(isCopyable = false)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(hiddenField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on copy hidden field click should send CopyCustomHiddenFieldClick`() {
|
||||
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "hidden password",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(hiddenField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Login.CopyCustomHiddenFieldClick(hiddenField.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on copy text field click should send CopyCustomTextFieldClick`() {
|
||||
val textField = VaultItemState.ViewState.Content.Custom.TextField(
|
||||
name = "text",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(textField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Login.CopyCustomTextFieldClick(textField.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, text field copy button should be displayed according to state`() {
|
||||
val textField = VaultItemState.ViewState.Content.Custom.TextField(
|
||||
name = "text",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
)
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(textField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
customFields = listOf(textField.copy(isCopyable = false)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll(textField.name)
|
||||
.onSiblings()
|
||||
.filterToOne(hasContentDescription("Copy"))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on password history click should send PasswordHistoryClick`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordHistoryCount = 5))
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTextAfterScroll("5")
|
||||
composeTestRule.onNodeWithText("5").performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fab should be displayed according state`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = VaultItemState.ViewState.Loading)
|
||||
}
|
||||
composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText()))
|
||||
}
|
||||
composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
}
|
||||
composeTestRule.onNodeWithContentDescription("Edit item").performClick()
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(VaultItemAction.EditClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error text and retry should be displayed according to state`() {
|
||||
val message = "message"
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = VaultItemState.ViewState.Error(message.asText()))
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(message).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Try again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progressbar should be displayed according to state`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = VaultItemState.ViewState.Loading)
|
||||
}
|
||||
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText()))
|
||||
}
|
||||
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
}
|
||||
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, username should be displayed according to state`() {
|
||||
val username = "the username"
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll(username).assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(username = null))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, uris should be displayed according to state`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll("URIs").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("URI").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("www.example.com").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(uris = emptyList()))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("URIs")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("URI")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("www.example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, notes should be displayed according to state`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onFirstNodeWithTextAfterScroll("Notes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("Lots of notes").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(notes = null))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Notes")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Lots of notes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, custom views should be displayed according to state`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll("Custom fields").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("text").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("value").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("hidden").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("boolean").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("linked username").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("linked password").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(customFields = emptyList()))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Custom fields")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("text")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("value")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("hidden")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("boolean")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("linked username")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("linked password")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, password updated should be displayed according to state`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll("Password updated: ").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("4/14/83 3:56 PM").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordRevisionDate = null))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Password updated: ")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("4/14/83 3:56 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, password history should be displayed according to state`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll("Password history: ").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("1").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordHistoryCount = null))
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Password history: ")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("1")
|
||||
}
|
||||
}
|
||||
|
||||
private const val VAULT_ITEM_ID = "vault_item_id"
|
||||
|
@ -61,3 +686,68 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState(
|
|||
viewState = VaultItemState.ViewState.Loading,
|
||||
dialog = null,
|
||||
)
|
||||
|
||||
private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
|
||||
VaultItemState.ViewState.Content.Login(
|
||||
name = "login cipher",
|
||||
lastUpdated = "12/31/69 06:16 PM",
|
||||
passwordHistoryCount = 1,
|
||||
notes = "Lots of notes",
|
||||
isPremiumUser = true,
|
||||
customFields = listOf(
|
||||
VaultItemState.ViewState.Content.Custom.TextField(
|
||||
name = "text",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "hidden password",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.BooleanField(
|
||||
name = "boolean",
|
||||
value = true,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.LinkedField(
|
||||
name = "linked username",
|
||||
id = 100U,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.LinkedField(
|
||||
name = "linked password",
|
||||
id = 101U,
|
||||
),
|
||||
),
|
||||
requiresReprompt = true,
|
||||
username = "the username",
|
||||
passwordData = VaultItemState.ViewState.Content.PasswordData(
|
||||
password = "the password",
|
||||
isVisible = false,
|
||||
),
|
||||
uris = listOf(
|
||||
VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
),
|
||||
),
|
||||
passwordRevisionDate = "4/14/83 3:56 PM",
|
||||
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
)
|
||||
|
||||
private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
|
||||
VaultItemState.ViewState.Content.Login(
|
||||
name = "login cipher",
|
||||
lastUpdated = "12/31/69 06:16 PM",
|
||||
passwordHistoryCount = null,
|
||||
notes = null,
|
||||
isPremiumUser = true,
|
||||
customFields = emptyList(),
|
||||
requiresReprompt = true,
|
||||
username = null,
|
||||
passwordData = null,
|
||||
uris = emptyList(),
|
||||
passwordRevisionDate = null,
|
||||
totp = null,
|
||||
)
|
||||
|
|
|
@ -3,16 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.item
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.DEFAULT_EMPTY_LOGIN_VIEW_STATE
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
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
|
||||
|
||||
class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
|
@ -27,6 +42,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when not set`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
|
@ -45,16 +70,495 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `on BackClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.CloseClick)
|
||||
assertEquals(VaultItemEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DismissDialogClick should clear the dialog state`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
assertEquals(initialState, viewModel.stateFlow.value)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.DismissDialogClick)
|
||||
assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EditClick should do nothing when ViewState is not Content`() = runTest {
|
||||
val initialState = DEFAULT_STATE
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
|
||||
assertEquals(initialState, viewModel.stateFlow.value)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.EditClick)
|
||||
expectNoEvents()
|
||||
}
|
||||
assertEquals(initialState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EditClick should prompt for master password when required`() = runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.EditClick)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EditClick should navigate password is not required`() = runTest {
|
||||
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.EditClick)
|
||||
assertEquals(VaultItemEvent.NavigateToEdit(VAULT_ITEM_ID), awaitItem())
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on MasterPasswordSubmit should verify the password`() = runTest {
|
||||
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(loginState, awaitItem())
|
||||
viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit("password"))
|
||||
assertEquals(loginState.copy(dialog = VaultItemState.DialogState.Loading), awaitItem())
|
||||
assertEquals(
|
||||
loginState.copy(viewState = loginViewState.copy(requiresReprompt = false)),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on RefreshClick should sync`() = runTest {
|
||||
every { vaultRepo.sync() } just runs
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.RefreshClick)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepo.sync()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class LoginActions {
|
||||
private lateinit var viewModel: VaultItemViewModel
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
viewModel = createViewModel(
|
||||
state = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CheckForBreachClick should process a password`() = runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val breachCount = 5
|
||||
coEvery {
|
||||
authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD)
|
||||
} returns BreachCountResult.Success(breachCount = breachCount)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(loginState, awaitItem())
|
||||
viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.Loading),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
message = R.string.password_exposed.asText(breachCount),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyPasswordClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyPasswordClick should emit CopyToClipboard when re-prompt is not required`() =
|
||||
runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
|
||||
assertEquals(
|
||||
VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_PASSWORD.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick("field"))
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on CopyCustomHiddenFieldClick should emit CopyToClipboard when re-prompt is not required`() =
|
||||
runTest {
|
||||
val field = "field"
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick(field))
|
||||
assertEquals(
|
||||
VaultItemEvent.CopyToClipboard(field.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyCustomTextFieldClick should emit CopyToClipboard`() = runTest {
|
||||
val field = "field"
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyCustomTextFieldClick(field))
|
||||
assertEquals(VaultItemEvent.CopyToClipboard(field.asText()), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyUriClick should emit CopyToClipboard`() = runTest {
|
||||
val uri = "uri"
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uri))
|
||||
assertEquals(VaultItemEvent.CopyToClipboard(uri.asText()), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyUsernameClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyUsernameClick should emit CopyToClipboard when re-prompt is not required`() =
|
||||
runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
|
||||
assertEquals(
|
||||
VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_USERNAME.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on LaunchClick should emit NavigateToUri`() = runTest {
|
||||
val uri = "uri"
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uri))
|
||||
assertEquals(VaultItemEvent.NavigateToUri(uri), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on PasswordHistoryClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on PasswordHistoryClick should emit NavigateToPasswordHistory when re-prompt is not required`() =
|
||||
runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
|
||||
assertEquals(
|
||||
VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on PasswordVisibilityClicked should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on PasswordVisibilityClicked should update password visibility when re-prompt is not required`() =
|
||||
runTest {
|
||||
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
viewState = loginViewState.copy(
|
||||
passwordData = loginViewState.passwordData!!.copy(isVisible = true),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Login.HiddenFieldVisibilityClicked(
|
||||
field = VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
),
|
||||
isVisible = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() =
|
||||
runTest {
|
||||
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
)
|
||||
val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
requiresReprompt = false,
|
||||
customFields = listOf(hiddenField),
|
||||
)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(loginState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Login.HiddenFieldVisibilityClicked(
|
||||
field = hiddenField,
|
||||
isVisible = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
viewState = loginViewState.copy(
|
||||
customFields = listOf(hiddenField.copy(isVisible = true)),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: VaultItemState? = DEFAULT_STATE,
|
||||
state: VaultItemState?,
|
||||
vaultItemId: String = VAULT_ITEM_ID,
|
||||
authRepository: AuthRepository = authRepo,
|
||||
vaultRepository: VaultRepository = vaultRepo,
|
||||
|
@ -68,7 +572,12 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
private const val CIPHER_VIEW_EXTENSIONS_PATH: String =
|
||||
"com.x8bit.bitwarden.ui.vault.feature.item.util.CipherViewExtensionsKt"
|
||||
|
||||
private const val VAULT_ITEM_ID = "vault_item_id"
|
||||
private const val DEFAULT_LOGIN_PASSWORD = "password"
|
||||
private const val DEFAULT_LOGIN_USERNAME = "username"
|
||||
|
||||
private val DEFAULT_STATE: VaultItemState = VaultItemState(
|
||||
vaultItemId = VAULT_ITEM_ID,
|
||||
|
@ -89,3 +598,52 @@ private val DEFAULT_USER_STATE: UserState = UserState(
|
|||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
|
||||
VaultItemState.ViewState.Content.Login(
|
||||
name = "login cipher",
|
||||
lastUpdated = "12/31/69 06:16 PM",
|
||||
passwordHistoryCount = 1,
|
||||
notes = "Lots of notes",
|
||||
isPremiumUser = true,
|
||||
customFields = listOf(
|
||||
VaultItemState.ViewState.Content.Custom.TextField(
|
||||
name = "text",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.HiddenField(
|
||||
name = "hidden",
|
||||
value = "value",
|
||||
isCopyable = true,
|
||||
isVisible = false,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.BooleanField(
|
||||
name = "boolean",
|
||||
value = true,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.LinkedField(
|
||||
name = "linked username",
|
||||
id = 100U,
|
||||
),
|
||||
VaultItemState.ViewState.Content.Custom.LinkedField(
|
||||
name = "linked password",
|
||||
id = 101U,
|
||||
),
|
||||
),
|
||||
requiresReprompt = true,
|
||||
username = DEFAULT_LOGIN_USERNAME,
|
||||
passwordData = VaultItemState.ViewState.Content.PasswordData(
|
||||
password = DEFAULT_LOGIN_PASSWORD,
|
||||
isVisible = false,
|
||||
),
|
||||
uris = listOf(
|
||||
VaultItemState.ViewState.Content.UriData(
|
||||
uri = "www.example.com",
|
||||
isCopyable = true,
|
||||
isLaunchable = true,
|
||||
),
|
||||
),
|
||||
passwordRevisionDate = "12/31/69 06:16 PM",
|
||||
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue