mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1057: Vault item listing functionality (#379)
This commit is contained in:
parent
65a9f209c2
commit
5c7d0081a3
6 changed files with 967 additions and 31 deletions
|
@ -4,14 +4,21 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toViewState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.updateWithAdditionalDataIfNecessary
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -19,9 +26,10 @@ import javax.inject.Inject
|
|||
* and launches [VaultItemListingEvent] for the [VaultItemListingScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("MagicNumber")
|
||||
@Suppress("MagicNumber", "TooManyFunctions")
|
||||
class VaultItemListingViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val vaultRepository: VaultRepository,
|
||||
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
|
||||
initialState = VaultItemListingState(
|
||||
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
|
||||
|
@ -32,15 +40,10 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
) {
|
||||
|
||||
init {
|
||||
// TODO fetch real listing data in BIT-1057
|
||||
viewModelScope.launch {
|
||||
delay(2000)
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
vaultRepository
|
||||
.vaultDataStateFlow
|
||||
.onEach { sendAction(VaultItemListingsAction.Internal.VaultDataReceive(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: VaultItemListingsAction) {
|
||||
|
@ -50,17 +53,13 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
is VaultItemListingsAction.ItemClick -> handleItemClick(action)
|
||||
is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick()
|
||||
is VaultItemListingsAction.RefreshClick -> handleRefreshClick()
|
||||
is VaultItemListingsAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||
}
|
||||
}
|
||||
|
||||
//region VaultItemListing Handlers
|
||||
private fun handleRefreshClick() {
|
||||
// TODO implement refresh in BIT-1057
|
||||
sendEvent(
|
||||
event = VaultItemListingEvent.ShowToast(
|
||||
text = "Not yet implemented".asText(),
|
||||
),
|
||||
)
|
||||
vaultRepository.sync()
|
||||
}
|
||||
|
||||
private fun handleAddVaultItemClick() {
|
||||
|
@ -88,7 +87,80 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
event = VaultItemListingEvent.NavigateToVaultSearchScreen,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleVaultDataReceive(
|
||||
action: VaultItemListingsAction.Internal.VaultDataReceive,
|
||||
) {
|
||||
when (val vaultData = action.vaultData) {
|
||||
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
|
||||
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
|
||||
is DataState.Loading -> vaultLoadingReceive()
|
||||
is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData = vaultData)
|
||||
is DataState.Pending -> vaultPendingReceive(vaultData = vaultData)
|
||||
}
|
||||
}
|
||||
//endregion VaultItemListing Handlers
|
||||
|
||||
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
|
||||
if (vaultData.data != null) {
|
||||
updateStateWithVaultData(vaultData = vaultData.data)
|
||||
} else {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = VaultItemListingState.ViewState.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
|
||||
updateStateWithVaultData(vaultData = vaultData.data)
|
||||
}
|
||||
|
||||
private fun vaultLoadingReceive() {
|
||||
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.Loading) }
|
||||
}
|
||||
|
||||
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
|
||||
if (vaultData.data != null) {
|
||||
updateStateWithVaultData(vaultData = vaultData.data)
|
||||
} else {
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = VaultItemListingState.ViewState.Error(
|
||||
message = R.string.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(R.string.internet_connection_required_message.asText()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
|
||||
updateStateWithVaultData(vaultData = vaultData.data)
|
||||
}
|
||||
|
||||
private fun updateStateWithVaultData(vaultData: VaultData) {
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
itemListingType = currentState
|
||||
.itemListingType
|
||||
.updateWithAdditionalDataIfNecessary(
|
||||
folderList = vaultData
|
||||
.folderViewList,
|
||||
),
|
||||
viewState = vaultData
|
||||
.cipherViewList
|
||||
.filter { cipherView ->
|
||||
cipherView.determineListingPredicate(currentState.itemListingType)
|
||||
}
|
||||
.toViewState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,14 +211,14 @@ data class VaultItemListingState(
|
|||
*
|
||||
* @property id the id of the item.
|
||||
* @property title title of the item.
|
||||
* @property subtitle subtitle of the item.
|
||||
* @property subtitle subtitle of the item (nullable).
|
||||
* @property uri uri for the icon to be displayed (nullable).
|
||||
* @property iconRes the icon to be displayed.
|
||||
*/
|
||||
data class DisplayItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val subtitle: String?,
|
||||
val uri: String?,
|
||||
@DrawableRes
|
||||
val iconRes: Int,
|
||||
|
@ -302,4 +374,17 @@ sealed class VaultItemListingsAction {
|
|||
* @property id the id of the item that has been clicked.
|
||||
*/
|
||||
data class ItemClick(val id: String) : VaultItemListingsAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [VaultItemListingViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : VaultItemListingsAction() {
|
||||
|
||||
/**
|
||||
* Indicates vault data was received.
|
||||
*/
|
||||
data class VaultDataReceive(
|
||||
val vaultData: DataState<VaultData>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
|
||||
/**
|
||||
* Determines a predicate to filter a list of [CipherView] based on the
|
||||
* [VaultItemListingState.ItemListingType].
|
||||
*/
|
||||
fun CipherView.determineListingPredicate(
|
||||
itemListingType: VaultItemListingState.ItemListingType,
|
||||
): Boolean =
|
||||
when (itemListingType) {
|
||||
is VaultItemListingState.ItemListingType.Card -> {
|
||||
type == CipherType.CARD && deletedDate == null
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.Folder -> {
|
||||
folderId == itemListingType.folderId && deletedDate == null
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.Identity -> {
|
||||
type == CipherType.IDENTITY && deletedDate == null
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.Login -> {
|
||||
type == CipherType.LOGIN && deletedDate == null
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.SecureNote -> {
|
||||
type == CipherType.SECURE_NOTE && deletedDate == null
|
||||
}
|
||||
|
||||
is VaultItemListingState.ItemListingType.Trash -> {
|
||||
deletedDate != null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a list of [CipherView] into [VaultItemListingState.ViewState].
|
||||
*/
|
||||
fun List<CipherView>.toViewState(): VaultItemListingState.ViewState =
|
||||
if (isNotEmpty()) {
|
||||
VaultItemListingState.ViewState.Content(displayItemList = toDisplayItemList())
|
||||
} else {
|
||||
VaultItemListingState.ViewState.NoItems
|
||||
}
|
||||
|
||||
/** * Updates a [VaultItemListingState.ItemListingType] with the given data if necessary. */
|
||||
fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary(
|
||||
folderList: List<FolderView>,
|
||||
): VaultItemListingState.ItemListingType =
|
||||
when (this) {
|
||||
is VaultItemListingState.ItemListingType.Card -> this
|
||||
is VaultItemListingState.ItemListingType.Folder -> copy(
|
||||
folderName = folderList.first { it.id == folderId }.name,
|
||||
)
|
||||
|
||||
is VaultItemListingState.ItemListingType.Identity -> this
|
||||
is VaultItemListingState.ItemListingType.Login -> this
|
||||
is VaultItemListingState.ItemListingType.SecureNote -> this
|
||||
is VaultItemListingState.ItemListingType.Trash -> this
|
||||
}
|
||||
|
||||
private fun List<CipherView>.toDisplayItemList(): List<VaultItemListingState.DisplayItem> =
|
||||
this.map { it.toDisplayItem() }
|
||||
|
||||
private fun CipherView.toDisplayItem(): VaultItemListingState.DisplayItem =
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = id.orEmpty(),
|
||||
title = name,
|
||||
subtitle = subtitle,
|
||||
iconRes = type.iconRes,
|
||||
uri = uri,
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val CipherView.subtitle: String?
|
||||
get() = when (type) {
|
||||
CipherType.LOGIN -> login?.username.orEmpty()
|
||||
CipherType.SECURE_NOTE -> null
|
||||
CipherType.CARD -> {
|
||||
card
|
||||
?.number
|
||||
?.takeLast(4)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
CipherType.IDENTITY -> {
|
||||
identity
|
||||
?.firstName
|
||||
.orEmpty()
|
||||
.plus(identity?.lastName.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@get:DrawableRes
|
||||
private val CipherType.iconRes: Int
|
||||
get() = when (this) {
|
||||
CipherType.LOGIN -> R.drawable.ic_login_item
|
||||
CipherType.SECURE_NOTE -> R.drawable.ic_secure_note_item
|
||||
CipherType.CARD -> R.drawable.ic_card_item
|
||||
CipherType.IDENTITY -> R.drawable.ic_identity_item
|
||||
}
|
||||
|
||||
private val CipherView.uri: String?
|
||||
get() = when (type) {
|
||||
CipherType.LOGIN -> {
|
||||
login
|
||||
?.uris
|
||||
?.firstOrNull()
|
||||
?.uri
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
CipherType.SECURE_NOTE -> null
|
||||
CipherType.CARD -> null
|
||||
CipherType.IDENTITY -> null
|
||||
}
|
|
@ -18,9 +18,17 @@ import java.time.LocalDateTime
|
|||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Create a mock [CipherView] with a given [number].
|
||||
* Create a mock [CipherView].
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
fun createMockCipherView(number: Int): CipherView =
|
||||
fun createMockCipherView(
|
||||
number: Int,
|
||||
isDeleted: Boolean = true,
|
||||
cipherType: CipherType = CipherType.LOGIN,
|
||||
): CipherView =
|
||||
CipherView(
|
||||
id = "mockId-$number",
|
||||
organizationId = "mockOrganizationId-$number",
|
||||
|
@ -29,14 +37,18 @@ fun createMockCipherView(number: Int): CipherView =
|
|||
key = "mockKey-$number",
|
||||
name = "mockName-$number",
|
||||
notes = "mockNotes-$number",
|
||||
type = CipherType.LOGIN,
|
||||
type = cipherType,
|
||||
login = createMockLoginView(number = number),
|
||||
creationDate = LocalDateTime
|
||||
.parse("2023-10-27T12:00:00")
|
||||
.toInstant(ZoneOffset.UTC),
|
||||
deletedDate = LocalDateTime
|
||||
.parse("2023-10-27T12:00:00")
|
||||
.toInstant(ZoneOffset.UTC),
|
||||
deletedDate = if (isDeleted) {
|
||||
LocalDateTime
|
||||
.parse("2023-10-27T12:00:00")
|
||||
.toInstant(ZoneOffset.UTC)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
revisionDate = LocalDateTime
|
||||
.parse("2023-10-27T12:00:00")
|
||||
.toInstant(ZoneOffset.UTC),
|
||||
|
|
|
@ -2,15 +2,33 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockItemListingDisplayItem
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableVaultDataStateFlow =
|
||||
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
|
||||
every { sync() } returns Unit
|
||||
}
|
||||
private val initialState = createVaultItemListingState()
|
||||
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
|
||||
vaultItemListingType = VaultItemListingType.Login,
|
||||
|
@ -63,15 +81,330 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshClick should emit ShowToast`() = runTest {
|
||||
fun `RefreshClick should sync`() = runTest {
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
|
||||
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
|
||||
verify { vaultRepository.sync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() =
|
||||
runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(
|
||||
createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
),
|
||||
),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingEvent.ShowToast("Not yet implemented".asText()),
|
||||
awaitItem(),
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Content(
|
||||
displayItemList = listOf(
|
||||
createMockItemListingDisplayItem(number = 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() =
|
||||
runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
cipherViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
assertEquals(
|
||||
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with trash items should update ViewState to NoItems`() =
|
||||
runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loading should update state to Loading`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(value = DataState.Loading)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(viewState = VaultItemListingState.ViewState.Loading),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Pending with data should update state to Content`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Pending(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Content(
|
||||
displayItemList = listOf(
|
||||
createMockItemListingDisplayItem(number = 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Pending with empty data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Pending(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Pending with trash data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Pending(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error without data should update state to Error`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with data should update state to Content`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Content(
|
||||
displayItemList = listOf(
|
||||
createMockItemListingDisplayItem(number = 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with empty data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
data = VaultData(
|
||||
cipherViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
),
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with trash data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.NoNetwork(),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Error(
|
||||
message = R.string.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(R.string.internet_connection_required_message.asText()),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.NoNetwork(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
)),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Content(
|
||||
displayItemList = listOf(
|
||||
createMockItemListingDisplayItem(number = 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow NoNetwork with empty data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.NoNetwork(
|
||||
data = VaultData(
|
||||
cipherViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow NoNetwork with trash data should update state to NoItems`() = runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.NoNetwork(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.NoItems,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSavedStateHandleWithVaultItemListingType(
|
||||
|
@ -103,9 +436,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private fun createVaultItemListingViewModel(
|
||||
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
|
||||
vaultRepository: VaultRepository = this.vaultRepository,
|
||||
): VaultItemListingViewModel =
|
||||
VaultItemListingViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
vaultRepository = vaultRepository,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
|
|
@ -0,0 +1,327 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
|
||||
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class VaultItemListingDataExtensionsTest {
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `determineListingPredicate should return the correct predicate for non trash Login cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.LOGIN,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to true,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to false,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
|
||||
)
|
||||
.forEach { (type, expected) ->
|
||||
val result = cipherView.determineListingPredicate(
|
||||
itemListingType = type,
|
||||
)
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `determineListingPredicate should return the correct predicate for trash Login cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = true,
|
||||
cipherType = CipherType.LOGIN,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to true,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "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 non trash Card cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.CARD,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to true,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to false,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
|
||||
)
|
||||
.forEach { (type, expected) ->
|
||||
val result = cipherView.determineListingPredicate(
|
||||
itemListingType = type,
|
||||
)
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `determineListingPredicate should return the correct predicate for trash Card cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = true,
|
||||
cipherType = CipherType.CARD,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to true,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "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 non trash Identity cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.IDENTITY,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to true,
|
||||
VaultItemListingState.ItemListingType.Trash to false,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
|
||||
)
|
||||
.forEach { (type, expected) ->
|
||||
val result = cipherView.determineListingPredicate(
|
||||
itemListingType = type,
|
||||
)
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `determineListingPredicate should return the correct predicate for trash Identity cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = true,
|
||||
cipherType = CipherType.IDENTITY,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to true,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "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 non trash SecureNote cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.SECURE_NOTE,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to true,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to false,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
|
||||
)
|
||||
.forEach { (type, expected) ->
|
||||
val result = cipherView.determineListingPredicate(
|
||||
itemListingType = type,
|
||||
)
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `determineListingPredicate should return the correct predicate for trash SecureNote cipherView`() {
|
||||
val cipherView = createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = true,
|
||||
cipherType = CipherType.SECURE_NOTE,
|
||||
)
|
||||
|
||||
mapOf(
|
||||
VaultItemListingState.ItemListingType.Login to false,
|
||||
VaultItemListingState.ItemListingType.Card to false,
|
||||
VaultItemListingState.ItemListingType.SecureNote to false,
|
||||
VaultItemListingState.ItemListingType.Identity to false,
|
||||
VaultItemListingState.ItemListingType.Trash to true,
|
||||
VaultItemListingState.ItemListingType.Folder(folderId = "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`() {
|
||||
val cipherViewList = listOf(
|
||||
createMockCipherView(
|
||||
number = 1,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.LOGIN,
|
||||
),
|
||||
createMockCipherView(
|
||||
number = 2,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.CARD,
|
||||
),
|
||||
createMockCipherView(
|
||||
number = 3,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.SECURE_NOTE,
|
||||
),
|
||||
createMockCipherView(
|
||||
number = 4,
|
||||
isDeleted = false,
|
||||
cipherType = CipherType.IDENTITY,
|
||||
),
|
||||
)
|
||||
|
||||
val result = cipherViewList.toViewState()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.ViewState.Content(
|
||||
displayItemList = listOf(
|
||||
createMockItemListingDisplayItem(
|
||||
number = 1,
|
||||
cipherType = CipherType.LOGIN,
|
||||
),
|
||||
createMockItemListingDisplayItem(
|
||||
number = 2,
|
||||
cipherType = CipherType.CARD,
|
||||
),
|
||||
createMockItemListingDisplayItem(
|
||||
number = 3,
|
||||
cipherType = CipherType.SECURE_NOTE,
|
||||
),
|
||||
createMockItemListingDisplayItem(
|
||||
number = 4,
|
||||
cipherType = CipherType.IDENTITY,
|
||||
),
|
||||
),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateWithAdditionalDataIfNecessary should update a folder itemListingType`() {
|
||||
val folderViewList = listOf(
|
||||
createMockFolderView(number = 1),
|
||||
createMockFolderView(number = 2),
|
||||
createMockFolderView(number = 3),
|
||||
)
|
||||
|
||||
val result = VaultItemListingState.ItemListingType.Folder(
|
||||
folderId = "mockId-1",
|
||||
folderName = "wrong name",
|
||||
)
|
||||
.updateWithAdditionalDataIfNecessary(folderList = folderViewList)
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.ItemListingType.Folder(
|
||||
folderId = "mockId-1",
|
||||
folderName = "mockName-1",
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateWithAdditionalDataIfNecessary should not change a non folder itemListingType`() {
|
||||
val folderViewList = listOf(
|
||||
createMockFolderView(number = 1),
|
||||
createMockFolderView(number = 2),
|
||||
createMockFolderView(number = 3),
|
||||
)
|
||||
|
||||
val result = VaultItemListingState.ItemListingType.Login
|
||||
.updateWithAdditionalDataIfNecessary(folderList = folderViewList)
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.ItemListingType.Login,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
|
||||
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
|
||||
/**
|
||||
* Create a mock [VaultItemListingState.DisplayItem] with a given [number].
|
||||
*/
|
||||
fun createMockItemListingDisplayItem(
|
||||
number: Int,
|
||||
cipherType: CipherType = CipherType.LOGIN,
|
||||
): VaultItemListingState.DisplayItem =
|
||||
when (cipherType) {
|
||||
CipherType.LOGIN -> {
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "mockUsername-$number",
|
||||
iconRes = R.drawable.ic_login_item,
|
||||
uri = "mockUri-$number",
|
||||
)
|
||||
}
|
||||
|
||||
CipherType.SECURE_NOTE -> {
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = null,
|
||||
iconRes = R.drawable.ic_secure_note_item,
|
||||
uri = null,
|
||||
)
|
||||
}
|
||||
|
||||
CipherType.CARD -> {
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "er-$number",
|
||||
iconRes = R.drawable.ic_card_item,
|
||||
uri = null,
|
||||
)
|
||||
}
|
||||
|
||||
CipherType.IDENTITY -> {
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "mockFirstName-${number}mockLastName-$number",
|
||||
iconRes = R.drawable.ic_identity_item,
|
||||
uri = null,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue