diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index a72456e23f..cea511bab1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -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( 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) { + 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) { + updateStateWithVaultData(vaultData = vaultData.data) + } + + private fun vaultLoadingReceive() { + mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.Loading) } + } + + private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork) { + 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) { + 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, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt new file mode 100644 index 0000000000..f284085292 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -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.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, +): 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.toDisplayItemList(): List = + 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 + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index d0830ee909..42329a3d90 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -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), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 00616ad629..38251688eb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -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.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") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt new file mode 100644 index 0000000000..9e8d4c2ddb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt new file mode 100644 index 0000000000..bf229ee42f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -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, + ) + } + }