mirror of
https://github.com/bitwarden/android.git
synced 2026-06-01 10:16:47 -05:00
BIT-513: View Card Item (#573)
This commit is contained in:
@@ -15,6 +15,7 @@ import com.bitwarden.core.SecureNoteView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
@@ -71,6 +72,8 @@ class CipherViewExtensionsTest {
|
||||
type = VaultAddEditState.ViewState.Content.ItemType.Card(
|
||||
cardHolderName = "Bit Warden",
|
||||
number = "4012888888881881",
|
||||
brand = VaultCardBrand.VISA,
|
||||
expirationYear = "2030",
|
||||
securityCode = "123",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,8 +10,10 @@ 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.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onLast
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onSiblings
|
||||
@@ -25,7 +27,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
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.onNodeWithContentDescriptionAfterScroll
|
||||
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
@@ -649,7 +653,7 @@ class VaultItemScreenTest : BaseComposeTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in login state, on show password click should send CopyPasswordClick`() {
|
||||
fun `in login state, on show password click should send PasswordVisibilityClicked`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
|
||||
|
||||
composeTestRule
|
||||
@@ -1060,6 +1064,170 @@ class VaultItemScreenTest : BaseComposeTest() {
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(identityName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state, cardholderName should be displayed according to state`() {
|
||||
val cardholderName = "the cardholder name"
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll(cardholderName).assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { currentState ->
|
||||
updateCardType(currentState) { copy(cardholderName = null) }
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(cardholderName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state the number should be displayed according to state`() {
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Number")
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_CARD_VIEW_STATE.copy(
|
||||
type = EMPTY_CARD_TYPE.copy(
|
||||
number = "number",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("Number")
|
||||
.assertTextEquals("Number", "••••••")
|
||||
.assertIsEnabled()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Copy number")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Show")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Number")
|
||||
.assertTextEquals("Number", "number")
|
||||
.assertIsEnabled()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Copy number")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Hide")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state, on copy number click should send CopyNumberClick`() {
|
||||
val number = "123456789"
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_CARD_VIEW_STATE.copy(
|
||||
type = EMPTY_CARD_TYPE.copy(
|
||||
number = number,
|
||||
expiration = "test",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll so we can see the Copy number button but not have it covered by the FAB
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("Expiration")
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Copy number")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state, brand should be displayed according to state`() {
|
||||
val visa = "Visa"
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll(visa).assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { currentState ->
|
||||
updateCardType(currentState) { copy(brand = null) }
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(visa)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state, expiration should be displayed according to state`() {
|
||||
val expiration = "the expiration"
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
|
||||
composeTestRule.onNodeWithTextAfterScroll(expiration).assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { currentState ->
|
||||
updateCardType(currentState) { copy(expiration = null) }
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist(expiration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state the security code should be displayed according to state`() {
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Security code")
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = EMPTY_CARD_VIEW_STATE.copy(
|
||||
type = EMPTY_CARD_TYPE.copy(
|
||||
securityCode = "123",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTextAfterScroll("Security code")
|
||||
.assertTextEquals("Security code", "•••")
|
||||
.assertIsEnabled()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Copy security code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithContentDescription("Show")
|
||||
.onLast()
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Security code")
|
||||
.assertTextEquals("Security code", "123")
|
||||
.assertIsEnabled()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Copy security code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Hide")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in card state, on copy security code click should send CopySecurityCodeClick`() {
|
||||
val number = "1234"
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_CARD_VIEW_STATE.copy(
|
||||
type = EMPTY_CARD_TYPE.copy(
|
||||
securityCode = number,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescriptionAfterScroll("Copy security code")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
@@ -1112,6 +1280,30 @@ private fun updateIdentityType(
|
||||
return currentState.copy(viewState = updatedType)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
private fun updateCardType(
|
||||
currentState: VaultItemState,
|
||||
transform: VaultItemState.ViewState.Content.ItemType.Card.() ->
|
||||
VaultItemState.ViewState.Content.ItemType.Card,
|
||||
): VaultItemState {
|
||||
val updatedType = when (val viewState = currentState.viewState) {
|
||||
is VaultItemState.ViewState.Content -> {
|
||||
when (val type = viewState.type) {
|
||||
is VaultItemState.ViewState.Content.ItemType.Card -> {
|
||||
viewState.copy(
|
||||
type = type.transform(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> viewState
|
||||
}
|
||||
}
|
||||
|
||||
else -> viewState
|
||||
}
|
||||
return currentState.copy(viewState = updatedType)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
private fun updateCommonContent(
|
||||
currentState: VaultItemState,
|
||||
@@ -1196,6 +1388,15 @@ private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity
|
||||
address = "the address",
|
||||
)
|
||||
|
||||
private val DEFAULT_CARD: VaultItemState.ViewState.Content.ItemType.Card =
|
||||
VaultItemState.ViewState.Content.ItemType.Card(
|
||||
cardholderName = "the cardholder name",
|
||||
number = "the number",
|
||||
brand = VaultCardBrand.VISA,
|
||||
expiration = "the expiration",
|
||||
securityCode = "the security code",
|
||||
)
|
||||
|
||||
private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
|
||||
VaultItemState.ViewState.Content.Common(
|
||||
name = "cipher",
|
||||
@@ -1229,6 +1430,15 @@ private val EMPTY_IDENTITY_TYPE: VaultItemState.ViewState.Content.ItemType.Ident
|
||||
address = "",
|
||||
)
|
||||
|
||||
private val EMPTY_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =
|
||||
VaultItemState.ViewState.Content.ItemType.Card(
|
||||
cardholderName = "",
|
||||
number = "",
|
||||
brand = VaultCardBrand.SELECT,
|
||||
expiration = "",
|
||||
securityCode = "",
|
||||
)
|
||||
|
||||
private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
VaultItemState.ViewState.Content(
|
||||
common = EMPTY_COMMON,
|
||||
@@ -1241,6 +1451,12 @@ private val EMPTY_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
type = EMPTY_IDENTITY_TYPE,
|
||||
)
|
||||
|
||||
private val EMPTY_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
VaultItemState.ViewState.Content(
|
||||
common = EMPTY_COMMON,
|
||||
type = EMPTY_CARD_TYPE,
|
||||
)
|
||||
|
||||
private val EMPTY_SECURE_NOTE_VIEW_STATE =
|
||||
VaultItemState.ViewState.Content(
|
||||
common = EMPTY_COMMON,
|
||||
@@ -1259,6 +1475,12 @@ private val DEFAULT_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
common = DEFAULT_COMMON,
|
||||
)
|
||||
|
||||
private val DEFAULT_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
VaultItemState.ViewState.Content(
|
||||
type = DEFAULT_CARD,
|
||||
common = DEFAULT_COMMON,
|
||||
)
|
||||
|
||||
private val DEFAULT_SECURE_NOTE_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
VaultItemState.ViewState.Content(
|
||||
common = DEFAULT_COMMON,
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.createLoginContent
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
@@ -588,6 +589,106 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class CardActions {
|
||||
private lateinit var viewModel: VaultItemViewModel
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
viewModel = createViewModel(
|
||||
state = DEFAULT_STATE.copy(
|
||||
viewState = CARD_VIEW_STATE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyNumberClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(cardState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
|
||||
assertEquals(
|
||||
cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on CopyNumberClick should call setText on the ClipboardManager when re-prompt is not required`() {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns createViewState(
|
||||
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||
type = DEFAULT_CARD_TYPE,
|
||||
)
|
||||
}
|
||||
every { clipboardManager.setText(text = "12345436") } just runs
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
|
||||
|
||||
verify(exactly = 1) {
|
||||
clipboardManager.setText(text = "12345436")
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopySecurityCodeClick should show password dialog when re-prompt is required`() =
|
||||
runTest {
|
||||
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
assertEquals(cardState, viewModel.stateFlow.value)
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
|
||||
assertEquals(
|
||||
cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on CopySecurityCodeClick should call setText on the ClipboardManager when re-prompt is not required`() {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(isPremiumUser = true)
|
||||
} returns createViewState(
|
||||
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||
type = DEFAULT_CARD_TYPE,
|
||||
)
|
||||
}
|
||||
every { clipboardManager.setText(text = "987") } just runs
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
|
||||
|
||||
verify(exactly = 1) {
|
||||
clipboardManager.setText(text = "987")
|
||||
mockCipherView.toViewState(isPremiumUser = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: VaultItemState?,
|
||||
vaultItemId: String = VAULT_ITEM_ID,
|
||||
@@ -665,6 +766,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
isPremiumUser = true,
|
||||
)
|
||||
|
||||
private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =
|
||||
VaultItemState.ViewState.Content.ItemType.Card(
|
||||
cardholderName = "mockName",
|
||||
number = "12345436",
|
||||
brand = VaultCardBrand.VISA,
|
||||
expiration = "03/2027",
|
||||
securityCode = "987",
|
||||
)
|
||||
|
||||
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
|
||||
VaultItemState.ViewState.Content.Common(
|
||||
name = "login cipher",
|
||||
@@ -703,5 +813,11 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
common = DEFAULT_COMMON,
|
||||
type = DEFAULT_LOGIN_TYPE,
|
||||
)
|
||||
|
||||
private val CARD_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
VaultItemState.ViewState.Content(
|
||||
common = DEFAULT_COMMON,
|
||||
type = DEFAULT_CARD_TYPE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.x8bit.bitwarden.ui.vault.model
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VaultCardBrandTest {
|
||||
|
||||
@Test
|
||||
fun `findVaultCardBrandWithNameOrNull should return matching brand, regardless of format`() {
|
||||
val names = listOf(
|
||||
"UNIONpay",
|
||||
"AMERICAN_EXPRESS",
|
||||
"diNERs cLub",
|
||||
"rupay",
|
||||
"nothing card",
|
||||
)
|
||||
|
||||
val result = names.map { it.findVaultCardBrandWithNameOrNull() }
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
VaultCardBrand.UNIONPAY,
|
||||
VaultCardBrand.AMERICAN_EXPRESS,
|
||||
VaultCardBrand.DINERS_CLUB,
|
||||
VaultCardBrand.RUPAY,
|
||||
null,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user