diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt index 96a9708b1..6282193fc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.api import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import retrofit2.http.Body import retrofit2.http.DELETE @@ -28,6 +29,15 @@ interface CiphersApi { @Body body: CipherJsonRequest, ): Result + /** + * Shares a cipher. + */ + @PUT("ciphers/{cipherId}/share") + suspend fun shareCipher( + @Path("cipherId") cipherId: String, + @Body body: ShareCipherJsonRequest, + ): Result + /** * Deletes a cipher. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ShareCipherJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ShareCipherJsonRequest.kt new file mode 100644 index 000000000..05a6bf401 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ShareCipherJsonRequest.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a share cipher request. + * + * @property cipher The cipher to share. + * @property collectionIds A list of collection ids associated with the cipher. + */ +@Serializable +data class ShareCipherJsonRequest( + @SerialName("Cipher") + val cipher: CipherJsonRequest, + + @SerialName("CollectionIds") + val collectionIds: List, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt index 840e214bf..6e977fcc3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.service import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson @@ -21,6 +22,14 @@ interface CiphersService { body: CipherJsonRequest, ): Result + /** + * Attempt to share a cipher. + */ + suspend fun shareCipher( + cipherId: String, + body: ShareCipherJsonRequest, + ): Result + /** * Attempt to delete a cipher. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt index a08b9f38f..03dcbfc20 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenErr import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import kotlinx.serialization.json.Json @@ -35,6 +36,15 @@ class CiphersServiceImpl constructor( ?: throw throwable } + override suspend fun shareCipher( + cipherId: String, + body: ShareCipherJsonRequest, + ): Result = + ciphersApi.shareCipher( + cipherId = cipherId, + body = body, + ) + override suspend fun deleteCipher(cipherId: String): Result = ciphersApi.deleteCipher(cipherId = cipherId) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 9446ebcb6..a1807ee5f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData +import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult @@ -169,6 +170,15 @@ interface VaultRepository : VaultLockManager { cipherView: CipherView, ): UpdateCipherResult + /** + * Attempt to share a cipher to the collections with the given collectionIds. + */ + suspend fun shareCipher( + cipherId: String, + cipherView: CipherView, + collectionIds: List, + ): ShareCipherResult + /** * Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a * [SendView.type] of [SendType.FILE]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 63ec8923f..0732ef5c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoadin import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson @@ -38,6 +39,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData +import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult @@ -77,7 +79,7 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L /** * Default implementation of [VaultRepository]. */ -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") class VaultRepositoryImpl( private val syncService: SyncService, private val ciphersService: CiphersService, @@ -423,6 +425,35 @@ class VaultRepositoryImpl( ) } + override suspend fun shareCipher( + cipherId: String, + cipherView: CipherView, + collectionIds: List, + ): ShareCipherResult { + val userId = requireNotNull(activeUserId) + return vaultSdkSource + .encryptCipher( + userId = userId, + cipherView = cipherView, + ) + .flatMap { cipher -> + ciphersService.shareCipher( + cipherId = cipherId, + body = ShareCipherJsonRequest( + cipher = cipher.toEncryptedNetworkCipher(), + collectionIds = collectionIds, + ), + ) + } + .fold( + onFailure = { ShareCipherResult.Error(errorMessage = null) }, + onSuccess = { + vaultDiskSource.saveCipher(userId = userId, cipher = it) + ShareCipherResult.Success + }, + ) + } + override suspend fun createSend( sendView: SendView, fileUri: Uri?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ShareCipherResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ShareCipherResult.kt new file mode 100644 index 000000000..fff264f35 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ShareCipherResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of sharing a cipher. + */ +sealed class ShareCipherResult { + /** + * Cipher shared successfully. + */ + data object Success : ShareCipherResult() + + /** + * Generic error while sharing cipher. The optional [errorMessage] may be displayed directly in + * the UI when present. + */ + data class Error(val errorMessage: String?) : ShareCipherResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt index 6bb9a755c..bd1c33a4f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModel.kt @@ -3,14 +3,27 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.CipherView +import com.bitwarden.core.CollectionView import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult 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.movetoorganization.util.toViewState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -18,13 +31,13 @@ private const val KEY_STATE = "state" /** * ViewModel responsible for handling user interactions in the [VaultMoveToOrganizationScreen]. - * - * @param savedStateHandle Handles the navigation arguments of this ViewModel. */ @HiltViewModel -@Suppress("MaxLineLength") +@Suppress("MaxLineLength", "TooManyFunctions") class VaultMoveToOrganizationViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val vaultRepository: VaultRepository, + authRepository: AuthRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { @@ -37,16 +50,27 @@ class VaultMoveToOrganizationViewModel @Inject constructor( ) { init { - // TODO Load real orgs/collections BIT-769 - viewModelScope.launch { - @Suppress("MagicNumber") - delay(1500) - mutableStateFlow.update { - it.copy( - viewState = VaultMoveToOrganizationState.ViewState.Empty, - ) - } + combine( + vaultRepository.getVaultItemStateFlow(state.vaultItemId), + vaultRepository.collectionsStateFlow, + authRepository.userStateFlow, + ) { cipherViewState, collectionsState, userState -> + VaultMoveToOrganizationAction.Internal.VaultDataReceive( + vaultData = combineDataStates( + dataState1 = cipherViewState, + dataState2 = collectionsState, + dataState3 = DataState.Loaded(userState), + ) { ciphersData, collectionsData, userData -> + Triple( + first = ciphersData, + second = collectionsData, + third = userData, + ) + }, + ) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultMoveToOrganizationAction) { @@ -56,6 +80,13 @@ class VaultMoveToOrganizationViewModel @Inject constructor( is VaultMoveToOrganizationAction.MoveClick -> handleMoveClick() is VaultMoveToOrganizationAction.DismissClick -> handleDismissClick() is VaultMoveToOrganizationAction.OrganizationSelect -> handleOrganizationSelect(action) + is VaultMoveToOrganizationAction.Internal.VaultDataReceive -> { + handleVaultDataReceive(action) + } + + is VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive -> { + handleShareCipherResultReceive(action) + } } } @@ -76,28 +107,25 @@ class VaultMoveToOrganizationViewModel @Inject constructor( selectedOrganizationId = currentContentState.selectedOrganizationId, selectedCollectionId = action.collection.id, ), - ) } } private fun handleMoveClick() { - mutableStateFlow.update { - it.copy( - dialogState = VaultMoveToOrganizationState.DialogState.Loading( - message = R.string.saving.asText(), - ), - ) - } - // TODO implement move organization functionality BIT-769 - viewModelScope.launch { - @Suppress("MagicNumber") - delay(1500) - sendEvent(VaultMoveToOrganizationEvent.ShowToast("Not yet implemented!".asText())) - mutableStateFlow.update { - it.copy(dialogState = null) + onContent { contentState -> + contentState.cipherToMove?.let { cipherView -> + if (contentState.selectedOrganization.collections.any { it.isSelected }) { + moveCipher(cipherView = cipherView, contentState = contentState) + } else { + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Error( + message = R.string.select_one_collection.asText(), + ), + ) + } + } } - sendEvent(VaultMoveToOrganizationEvent.NavigateBack) } } @@ -105,6 +133,118 @@ class VaultMoveToOrganizationViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialogState = null) } } + private fun handleVaultDataReceive( + action: VaultMoveToOrganizationAction.Internal.VaultDataReceive, + ) { + when (action.vaultData) { + is DataState.Error -> vaultErrorReceive(action.vaultData) + is DataState.Loaded -> vaultLoadedReceive(action.vaultData) + is DataState.Loading -> vaultLoadingReceive() + is DataState.NoNetwork -> vaultNoNetworkReceive(action.vaultData) + is DataState.Pending -> vaultPendingReceive(action.vaultData) + } + } + + private fun handleShareCipherResultReceive( + action: VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive, + ) { + mutableStateFlow.update { it.copy(dialogState = null) } + when (action.shareCipherResult) { + is ShareCipherResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is ShareCipherResult.Success -> { + sendEvent(VaultMoveToOrganizationEvent.NavigateBack) + } + } + } + + private fun vaultErrorReceive( + vaultData: DataState.Error, UserState?>>, + ) { + mutableStateFlow.update { + if (vaultData.data != null) { + it.copy( + viewState = vaultData.data.toViewState(), + dialogState = VaultMoveToOrganizationState.DialogState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } else { + it.copy( + viewState = VaultMoveToOrganizationState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + dialogState = null, + ) + } + } + } + + private fun vaultLoadedReceive( + vaultData: DataState.Loaded, UserState?>>, + ) { + mutableStateFlow.update { + it.copy( + viewState = vaultData.data.toViewState(), + dialogState = null, + ) + } + } + + private fun vaultLoadingReceive() { + mutableStateFlow.update { + it.copy( + viewState = VaultMoveToOrganizationState.ViewState.Loading, + dialogState = null, + ) + } + } + + private fun vaultNoNetworkReceive( + vaultData: DataState.NoNetwork, UserState?>>, + ) { + mutableStateFlow.update { + if (vaultData.data != null) { + it.copy( + viewState = vaultData.data.toViewState(), + dialogState = VaultMoveToOrganizationState.DialogState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ) + } else { + it.copy( + viewState = VaultMoveToOrganizationState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + dialogState = null, + ) + } + } + } + + private fun vaultPendingReceive( + vaultData: DataState.Pending, UserState?>>, + ) { + mutableStateFlow.update { + it.copy( + viewState = vaultData.data.toViewState(), + dialogState = null, + ) + } + } + private inline fun updateContent( crossinline block: ( VaultMoveToOrganizationState.ViewState.Content, @@ -116,12 +256,48 @@ class VaultMoveToOrganizationViewModel @Inject constructor( ?: return mutableStateFlow.update { it.copy(viewState = updatedContent) } } + + private inline fun onContent( + crossinline block: (VaultMoveToOrganizationState.ViewState.Content) -> Unit, + ) { + (state.viewState as? VaultMoveToOrganizationState.ViewState.Content)?.let(block) + } + + private fun moveCipher( + cipherView: CipherView, + contentState: VaultMoveToOrganizationState.ViewState.Content, + ) { + mutableStateFlow.update { + it.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ) + } + viewModelScope.launch { + trySendAction( + VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive( + vaultRepository.shareCipher( + cipherId = mutableStateFlow.value.vaultItemId, + cipherView = cipherView.copy( + organizationId = contentState.selectedOrganizationId, + ), + collectionIds = contentState + .selectedOrganization + .collections + .filter { it.isSelected } + .map { it.id }, + ), + ), + ) + } + } } /** * Models state for the [VaultMoveToOrganizationScreen]. * - * @property vaultItemId Indicates whether the VM is in add or edit mode. + * @property vaultItemId the id for the item being moved. * @property viewState indicates what view state the screen is in. * @property dialogState the dialog state. */ @@ -176,12 +352,16 @@ data class VaultMoveToOrganizationState( /** * Represents a loaded content state for the [VaultMoveToOrganizationScreen]. * + * @property selectedOrganizationId the selected organization id. * @property organizations the organizations available. + * @property cipherToMove the cipher that is being moved to an organization. */ @Parcelize data class Content( val selectedOrganizationId: String, val organizations: List, + @IgnoredOnParcel + val cipherToMove: CipherView? = null, ) : ViewState() { val selectedOrganization: Organization @@ -192,7 +372,6 @@ data class VaultMoveToOrganizationState( * * @property id the organization id. * @property name the organization name. - * @property isSelected if the organization is selected or not. * @property collections the list of collections associated with the organization. */ @Parcelize @@ -280,6 +459,26 @@ sealed class VaultMoveToOrganizationAction { data class CollectionSelect( val collection: VaultMoveToOrganizationState.ViewState.Content.Collection, ) : VaultMoveToOrganizationAction() + + /** + * Models actions that the [VaultMoveToOrganizationViewModel] itself might send. + */ + sealed class Internal : VaultMoveToOrganizationAction() { + + /** + * Indicates that the vault item data has been received. + */ + data class VaultDataReceive( + val vaultData: DataState, UserState?>>, + ) : Internal() + + /** + * Indicates a result for sharing a cipher has been received. + */ + data class ShareCipherResultReceive( + val shareCipherResult: ShareCipherResult, + ) : Internal() + } } @Suppress("MaxLineLength") diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationViewExtensions.kt new file mode 100644 index 000000000..e85374edb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationViewExtensions.kt @@ -0,0 +1,63 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util + +import com.bitwarden.core.CipherView +import com.bitwarden.core.CollectionView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState + +/** + * Transforms a triple of [CipherView] (nullable), list of [CollectionView], + * and [UserState] (nullable) into a [VaultMoveToOrganizationState.ViewState]. + */ +fun Triple, UserState?>.toViewState(): + VaultMoveToOrganizationState.ViewState { + val userOrganizations = third + ?.activeAccount + ?.organizations + + val currentCipher = first + + val collections = second + .filter { !it.readOnly } + + return when { + (currentCipher == null) -> { + VaultMoveToOrganizationState.ViewState.Error(R.string.generic_error_message.asText()) + } + (userOrganizations?.isNotEmpty() == true) -> { + VaultMoveToOrganizationState.ViewState.Content( + selectedOrganizationId = currentCipher + .organizationId + ?: userOrganizations + .first() + .id, + organizations = userOrganizations.map { organization -> + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = organization.id, + name = organization + .name + .orEmpty(), + collections = collections + .filter { collection -> + collection.organizationId == organization.id && + collection.id != null + } + .map { collection -> + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = collection.id.orEmpty(), + name = collection.name, + isSelected = currentCipher + .collectionIds + .contains(collection.id), + ) + }, + ) + }, + cipherToMove = currentCipher, + ) + } + else -> VaultMoveToOrganizationState.ViewState.Empty + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index 82613e04e..3a3b8f503 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.service import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest @@ -71,6 +72,27 @@ class CiphersServiceTest : BaseServiceTest() { val result = ciphersService.deleteCipher(cipherId = cipherId) assertEquals(Unit, result.getOrThrow()) } + + @Test + fun `shareCipher should execute the share cipher API`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(CREATE_UPDATE_CIPHER_SUCCESS_JSON), + ) + + val result = ciphersService.shareCipher( + cipherId = "mockId-1", + body = ShareCipherJsonRequest( + cipher = createMockCipherJsonRequest(number = 1), + collectionIds = listOf("mockId-1"), + ), + ) + assertEquals( + createMockCipher(number = 1), + result.getOrThrow(), + ) + } } private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 1f30b9411..baaaa2624 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson @@ -58,6 +59,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData +import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -2000,6 +2002,111 @@ class VaultRepositoryTest { assertEquals(DeleteSendResult.Success, result) } + @Test + @Suppress("MaxLineLength") + fun `shareCipher with cipherService shareCipher success should return ShareCipherResultSuccess`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = createMockCipherView(number = 1), + ) + } returns createMockSdkCipher(number = 1).asSuccess() + coEvery { + ciphersService.shareCipher( + cipherId = "mockId-1", + body = ShareCipherJsonRequest( + cipher = createMockCipherJsonRequest(number = 1), + collectionIds = listOf("mockId-1"), + ), + ) + } returns createMockCipher(number = 1).asSuccess() + coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs + + val result = vaultRepository.shareCipher( + cipherId = "mockId-1", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + + assertEquals( + ShareCipherResult.Success, + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `shareCipher with cipherService shareCipher failure should return ShareCipherResultError`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = createMockCipherView(number = 1), + ) + } returns createMockSdkCipher(number = 1).asSuccess() + coEvery { + ciphersService.shareCipher( + cipherId = "mockId-1", + body = ShareCipherJsonRequest( + cipher = createMockCipherJsonRequest(number = 1), + collectionIds = listOf("mockId-1"), + ), + ) + } returns Throwable("Fail").asFailure() + coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs + + val result = vaultRepository.shareCipher( + cipherId = "mockId-1", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + + assertEquals( + ShareCipherResult.Error(errorMessage = null), + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `shareCipher with cipherService encryptCipher failure should return ShareCipherResultError`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = createMockCipherView(number = 1), + ) + } returns Throwable("Fail").asFailure() + coEvery { + ciphersService.shareCipher( + cipherId = "mockId-1", + body = ShareCipherJsonRequest( + cipher = createMockCipherJsonRequest(number = 1), + collectionIds = listOf("mockId-1"), + ), + ) + } returns createMockCipher(number = 1).asSuccess() + coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs + + val result = vaultRepository.shareCipher( + cipherId = "mockId-1", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + + assertEquals( + ShareCipherResult.Error(errorMessage = null), + result, + ) + } + //region Helper functions /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt index 24a88bb16..398caecab 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationScreenTest.kt @@ -84,11 +84,11 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { @Test fun `selecting an organization should send OrganizationSelect action`() { composeTestRule - .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1") + .onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-1") .performClick() // Choose the option from the menu composeTestRule - .onAllNodesWithText(text = "Organization 2") + .onAllNodesWithText(text = "mockOrganizationName-2") .onLast() .performScrollTo() .performClick() @@ -97,22 +97,12 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { viewModel.trySendAction( VaultMoveToOrganizationAction.OrganizationSelect( VaultMoveToOrganizationState.ViewState.Content.Organization( - id = "2", - name = "Organization 2", + id = "mockOrganizationId-2", + name = "mockOrganizationName-2", collections = listOf( VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "1", - name = "Collection 1", - isSelected = true, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "2", - name = "Collection 2", - isSelected = false, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "3", - name = "Collection 3", + id = "mockId-2", + name = "mockName-2", isSelected = false, ), ), @@ -125,36 +115,36 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { @Test fun `the organization option field should display according to state`() { composeTestRule - .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1") + .onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-1") .assertIsDisplayed() mutableStateFlow.update { currentState -> currentState.copy( viewState = VaultMoveToOrganizationState.ViewState.Content( organizations = createMockOrganizationList(), - selectedOrganizationId = "2", + selectedOrganizationId = "mockOrganizationId-2", ), ) } composeTestRule - .onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 2") + .onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-2") .assertIsDisplayed() } @Test fun `selecting a collection should send CollectionSelect action`() { composeTestRule - .onNodeWithText(text = "Collection 2") + .onNodeWithText(text = "mockName-1") .performClick() verify { viewModel.trySendAction( VaultMoveToOrganizationAction.CollectionSelect( VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "2", - name = "Collection 2", - isSelected = false, + id = "mockId-1", + name = "mockName-1", + isSelected = true, ), ), ) @@ -164,14 +154,8 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { @Test fun `the collection list should display according to state`() { composeTestRule - .onNodeWithText("Collection 1") + .onNodeWithText("mockName-1") .assertIsOn() - composeTestRule - .onNodeWithText("Collection 2") - .assertIsOff() - composeTestRule - .onNodeWithText("Collection 3") - .assertIsOff() mutableStateFlow.update { currentState -> currentState.copy( @@ -180,31 +164,27 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() { .map { organization -> organization.copy( collections = - if (organization.id == "1") { + if (organization.id == "mockOrganizationId-1") { organization .collections .map { collection -> - collection.copy(isSelected = collection.id != "1") + collection.copy( + isSelected = collection.id != "mockId-1", + ) } } else { organization.collections }, ) }, - selectedOrganizationId = "1", + selectedOrganizationId = "mockOrganizationId-1", ), ) } composeTestRule - .onNodeWithText("Collection 1") + .onNodeWithText("mockName-1") .assertIsOff() - composeTestRule - .onNodeWithText("Collection 2") - .assertIsOn() - composeTestRule - .onNodeWithText("Collection 3") - .assertIsOn() } @Test @@ -251,7 +231,7 @@ private fun createVaultMoveToOrganizationState(): VaultMoveToOrganizationState = vaultItemId = "mockId", viewState = VaultMoveToOrganizationState.ViewState.Content( organizations = createMockOrganizationList(), - selectedOrganizationId = "1", + selectedOrganizationId = "mockOrganizationId-1", ), dialogState = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt index 83285792b..3c1ee79c0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -2,10 +2,27 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.CipherView +import com.bitwarden.core.CollectionView import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.Organization +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult 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.movetoorganization.util.createMockOrganizationList +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -17,6 +34,22 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { state = initialState, ) + private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) + + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + + private val mutableCollectionFlow = + MutableStateFlow>>(DataState.Loading) + + private val vaultRepository: VaultRepository = mockk { + every { getVaultItemStateFlow(DEFAULT_ITEM_ID) } returns mutableVaultItemFlow + every { collectionsStateFlow } returns mutableCollectionFlow + } + + private val authRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + @Test fun `initial state should be correct when state is null`() = runTest { val viewModel = createViewModel( @@ -32,14 +65,13 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct`() = runTest { - val initState = createVaultMoveToOrganizationState() val viewModel = createViewModel( savedStateHandle = createSavedStateHandleWithState( - state = initState, + state = initialState, ), ) - assertEquals(initState, viewModel.stateFlow.value) + assertEquals(initialState, viewModel.stateFlow.value) } @Test @@ -55,27 +87,21 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { @Test fun `OrganizationSelect should update selected Organization`() = runTest { - val viewModel = createViewModel( - savedStateHandle = createSavedStateHandleWithState( - state = createVaultMoveToOrganizationState( - viewState = VaultMoveToOrganizationState.ViewState.Content( - organizations = createMockOrganizationList(), - selectedOrganizationId = "1", - ), - ), - ), - ) + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1))) val action = VaultMoveToOrganizationAction.OrganizationSelect( VaultMoveToOrganizationState.ViewState.Content.Organization( - id = "3", - name = "Organization 3", + id = "mockOrganizationId-3", + name = "mockOrganizationName-3", collections = emptyList(), ), ) val expectedState = createVaultMoveToOrganizationState( viewState = VaultMoveToOrganizationState.ViewState.Content( organizations = createMockOrganizationList(), - selectedOrganizationId = "3", + selectedOrganizationId = "mockOrganizationId-3", + cipherToMove = createMockCipherView(number = 1), ), ) @@ -89,50 +115,34 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { @Test fun `CollectionSelect should update selected Collections`() = runTest { - val viewModel = createViewModel( - savedStateHandle = createSavedStateHandleWithState( - state = createVaultMoveToOrganizationState( - viewState = VaultMoveToOrganizationState.ViewState.Content( - organizations = createMockOrganizationList(), - selectedOrganizationId = "1", - ), - ), - ), - ) - val selectCollection3Action = VaultMoveToOrganizationAction.CollectionSelect( - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "3", - name = "Collection 3", - isSelected = false, - ), - ) + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1))) val unselectCollection1Action = VaultMoveToOrganizationAction.CollectionSelect( VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "1", - name = "Collection 1", + id = "mockId-1", + name = "mockName-1", isSelected = true, ), ) val expectedState = createVaultMoveToOrganizationState( viewState = VaultMoveToOrganizationState.ViewState.Content( - organizations = createMockOrganizationList() - .map { organization -> - organization.copy( - collections = - if (organization.id == "1") { - organization.collections.map { - it.copy(isSelected = it.id == "3") - } - } else { - organization.collections - }, - ) - }, - selectedOrganizationId = "1", + organizations = createMockOrganizationList().map { organization -> + organization.copy( + collections = if (organization.id == "mockOrganizationId-1") { + organization.collections.map { + it.copy(isSelected = false) + } + } else { + organization.collections + }, + ) + }, + cipherToMove = createMockCipherView(number = 1), + selectedOrganizationId = "mockOrganizationId-1", ), ) - viewModel.actionChannel.trySend(selectCollection3Action) viewModel.actionChannel.trySend(unselectCollection1Action) assertEquals( @@ -142,40 +152,243 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { } @Test - fun `MoveClick should show dialog, and remove it once an item is moved`() = runTest { - val viewModel = createViewModel( - savedStateHandle = createSavedStateHandleWithState( - state = initialState, + fun `DataState Loading should show loading state`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loading) + viewModel.stateFlow.test { + assertEquals( + initialState.copy(viewState = VaultMoveToOrganizationState.ViewState.Loading), + awaitItem(), + ) + } + } + + @Test + fun `DataState Pending should show content state`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit( + value = DataState.Pending( + data = createMockCipherView(number = 1), ), ) viewModel.stateFlow.test { - assertEquals(initialState, awaitItem()) + assertEquals( + initialState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), dialogState = null, + ), + awaitItem(), + ) + } + } + + @Test + fun `DataState Error should show Error State`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit( + value = DataState.Error( + error = IllegalStateException(), + data = null, + ), + ) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `DataState NoNetwork should show Error Dialog`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit( + value = DataState.NoNetwork(), + ) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `MoveClick with shareCipher success should show loading dialog, and remove it`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1))) + coEvery { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } returns ShareCipherResult.Success + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), + ), + awaitItem(), + ) viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick) assertEquals( initialState.copy( dialogState = VaultMoveToOrganizationState.DialogState.Loading( message = R.string.saving.asText(), ), + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), ), awaitItem(), ) assertEquals( - initialState, + initialState.copy( + dialogState = null, + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), + ), awaitItem(), ) } + coVerify { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } + } + + @Test + fun `MoveClick with shareCipher error should show error dialog`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1))) + coEvery { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } returns ShareCipherResult.Error(errorMessage = null) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), + ), + awaitItem(), + ) + viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick) + assertEquals( + initialState.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Loading( + message = R.string.saving.asText(), + ), + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), + ), + awaitItem(), + ) + assertEquals( + initialState.copy( + dialogState = VaultMoveToOrganizationState.DialogState.Error( + message = R.string.generic_error_message.asText(), + ), + viewState = VaultMoveToOrganizationState.ViewState.Content( + organizations = createMockOrganizationList(), + selectedOrganizationId = "mockOrganizationId-1", + cipherToMove = createMockCipherView(number = 1), + ), + ), + awaitItem(), + ) + } + coVerify { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } + } + + @Test + fun `MoveClick with shareCipher success should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS)) + mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1))) + coEvery { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } returns ShareCipherResult.Success + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick) + assertEquals( + VaultMoveToOrganizationEvent.NavigateBack, + awaitItem(), + ) + } + coVerify { + vaultRepository.shareCipher( + cipherId = "mockCipherId", + cipherView = createMockCipherView(number = 1), + collectionIds = listOf("mockId-1"), + ) + } } private fun createViewModel( savedStateHandle: SavedStateHandle = initialSavedStateHandle, - ): VaultMoveToOrganizationViewModel = - VaultMoveToOrganizationViewModel( - savedStateHandle = savedStateHandle, - ) + vaultRepo: VaultRepository = vaultRepository, + authRepo: AuthRepository = authRepository, + ): VaultMoveToOrganizationViewModel = VaultMoveToOrganizationViewModel( + savedStateHandle = savedStateHandle, + authRepository = authRepo, + vaultRepository = vaultRepo, + ) private fun createSavedStateHandleWithState( state: VaultMoveToOrganizationState? = null, - vaultItemId: String = "mockId", + vaultItemId: String = "mockCipherId", ) = SavedStateHandle().apply { set("state", state) set("vault_move_to_organization_id", vaultItemId) @@ -183,13 +396,51 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") private fun createVaultMoveToOrganizationState( - viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Empty, - vaultItemId: String = "mockId", + viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Loading, + vaultItemId: String = "mockCipherId", dialogState: VaultMoveToOrganizationState.DialogState? = null, - ): VaultMoveToOrganizationState = - VaultMoveToOrganizationState( - vaultItemId = vaultItemId, - viewState = viewState, - dialogState = dialogState, - ) + ): VaultMoveToOrganizationState = VaultMoveToOrganizationState( + vaultItemId = vaultItemId, + viewState = viewState, + dialogState = dialogState, + ) } + +private const val DEFAULT_ITEM_ID: String = "mockCipherId" + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + organizations = listOf( + Organization( + id = "mockOrganizationId-1", + name = "mockOrganizationName-1", + ), + Organization( + id = "mockOrganizationId-2", + name = "mockOrganizationName-2", + ), + Organization( + id = "mockOrganizationId-3", + name = "mockOrganizationName-3", + ), + ), + ), + ), +) + +private val DEFAULT_COLLECTIONS = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + createMockCollectionView(number = 3), + createMockCollectionView(number = 4), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt new file mode 100644 index 000000000..1452aec23 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt @@ -0,0 +1,117 @@ +package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.Organization +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VaultMoveToOrganizationExtensionsTest { + + @Test + @Suppress("MaxLineLength") + fun `toViewState should transform a valid triple of CipherView, CollectionView list, and UserState into Content ViewState`() { + val triple = Triple( + first = createMockCipherView(number = 1), + second = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + createMockCollectionView(number = 3), + ), + third = createMockUserState(), + ) + + val result = triple.toViewState() + + assertEquals( + VaultMoveToOrganizationState.ViewState.Content( + selectedOrganizationId = "mockOrganizationId-1", + organizations = createMockOrganizationList(), + cipherToMove = createMockCipherView(number = 1), + ), + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `toViewState should transform a triple of null CipherView, CollectionView list, and UserState into Error ViewState`() { + val triple = Triple( + first = null, + second = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + createMockCollectionView(number = 3), + ), + third = createMockUserState(), + ) + + val result = triple.toViewState() + + assertEquals( + VaultMoveToOrganizationState.ViewState.Error(R.string.generic_error_message.asText()), + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `toViewState should transform a triple of CipherView, CollectionView list, and UserState without organizations into Empty ViewState`() { + val triple = Triple( + first = createMockCipherView(number = 1), + second = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + createMockCollectionView(number = 3), + ), + third = createMockUserState(hasOrganizations = false), + ) + + val result = triple.toViewState() + + assertEquals( + VaultMoveToOrganizationState.ViewState.Empty, + result, + ) + } +} + +private fun createMockUserState(hasOrganizations: Boolean = true): UserState = + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + organizations = if (hasOrganizations) { + listOf( + Organization( + id = "mockOrganizationId-1", + name = "mockOrganizationName-1", + ), + Organization( + id = "mockOrganizationId-2", + name = "mockOrganizationName-2", + ), + Organization( + id = "mockOrganizationId-3", + name = "mockOrganizationName-3", + ), + ) + } else { + emptyList() + }, + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt index f0a659788..f0f7fccac 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationTestUtil.kt @@ -3,56 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState /** - * Creates a list of mock organizations. + * Creates a list of mock [VaultMoveToOrganizationState.ViewState.Content.Organization]. */ fun createMockOrganizationList(): List = listOf( - VaultMoveToOrganizationState.ViewState.Content.Organization( - id = "1", - name = "Organization 1", - collections = listOf( - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "1", - name = "Collection 1", - isSelected = true, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "2", - name = "Collection 2", - isSelected = false, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "3", - name = "Collection 3", - isSelected = false, - ), + createMockOrganization(number = 1), + createMockOrganization(number = 2, isCollectionSelected = false), + createMockOrganization(number = 3, isCollectionSelected = false), + ) + +/** + * Creates a [VaultMoveToOrganizationState.ViewState.Content.Organization] with a given number. + */ +fun createMockOrganization( + number: Int, + isCollectionSelected: Boolean = true, +): VaultMoveToOrganizationState.ViewState.Content.Organization = + VaultMoveToOrganizationState.ViewState.Content.Organization( + id = "mockOrganizationId-$number", + name = "mockOrganizationName-$number", + collections = listOf( + VaultMoveToOrganizationState.ViewState.Content.Collection( + id = "mockId-$number", + name = "mockName-$number", + isSelected = isCollectionSelected, ), ), - VaultMoveToOrganizationState.ViewState.Content.Organization( - id = "2", - name = "Organization 2", - collections = listOf( - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "1", - name = "Collection 1", - isSelected = true, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "2", - name = "Collection 2", - isSelected = false, - ), - VaultMoveToOrganizationState.ViewState.Content.Collection( - id = "3", - name = "Collection 3", - isSelected = false, - ), - ), - ), - VaultMoveToOrganizationState.ViewState.Content.Organization( - id = "3", - name = "Organization 3", - collections = emptyList(), - ), )