diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt index 5353bb74d9..436a061fd3 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt @@ -45,6 +45,8 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut import com.bitwarden.authenticator.ui.authenticator.feature.edititem.model.EditItemData import com.bitwarden.authenticator.ui.platform.components.appbar.AuthenticatorTopAppBar import com.bitwarden.authenticator.ui.platform.components.button.AuthenticatorTextButton +import com.bitwarden.authenticator.ui.platform.components.content.BitwardenErrorContent +import com.bitwarden.authenticator.ui.platform.components.content.BitwardenLoadingContent import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog @@ -204,10 +206,17 @@ fun EditItemScreen( } is EditItemState.ViewState.Error -> { - /*ItemErrorContent(state)*/ + BitwardenErrorContent( + message = viewState.message(), + modifier = Modifier.padding(innerPadding), + ) } - EditItemState.ViewState.Loading -> EditItemState.ViewState.Loading + EditItemState.ViewState.Loading -> { + BitwardenLoadingContent( + modifier = Modifier.padding(innerPadding), + ) + } } } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt index d745644ad4..8f7278358f 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt @@ -64,7 +64,7 @@ class EditItemViewModel @Inject constructor( is EditItemAction.CancelClick -> handleCancelClick() is EditItemAction.TypeOptionClick -> handleTypeOptionClick(action) is EditItemAction.IssuerNameTextChange -> handleIssuerNameTextChange(action) - is EditItemAction.UsernameTextChange -> handleIssuerTextChange(action) + is EditItemAction.UsernameTextChange -> handleUsernameTextChange(action) is EditItemAction.FavoriteToggleClick -> handleFavoriteToggleClick(action) is EditItemAction.RefreshPeriodOptionClick -> handlePeriodTextChange(action) is EditItemAction.TotpCodeTextChange -> handleTotpCodeTextChange(action) @@ -156,7 +156,7 @@ class EditItemViewModel @Inject constructor( } } - private fun handleIssuerTextChange(action: EditItemAction.UsernameTextChange) { + private fun handleUsernameTextChange(action: EditItemAction.UsernameTextChange) { updateItemData { currentItemData -> currentItemData.copy( username = action.username, diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreenTest.kt new file mode 100644 index 0000000000..50395d6922 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreenTest.kt @@ -0,0 +1,410 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onSiblings +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.model.EditItemData +import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.util.asText +import com.bitwarden.ui.util.assertNoDialogExists +import com.bitwarden.ui.util.isProgressBar +import com.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll +import com.bitwarden.ui.util.onNodeWithTextAfterScroll +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class EditItemScreenTest : AuthenticatorComposeTest() { + + private var onNavigateBackCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel: EditItemViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(action = any()) } just runs + } + + @Before + fun setup() { + setContent { + EditItemScreen( + viewModel = viewModel, + onNavigateBack = { onNavigateBackCalled = true }, + ) + } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(EditItemEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `dialogs should update according to state`() { + composeTestRule.assertNoDialogExists() + + val loadingMessage = "loading!" + mutableStateFlow.update { + it.copy(dialog = EditItemState.DialogState.Loading(message = loadingMessage.asText())) + } + composeTestRule + .onNodeWithText(text = loadingMessage) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + val genericTitle = "Generic Title!" + val genericMessage = "Generic message" + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = genericTitle.asText(), + message = genericMessage.asText(), + ), + ) + } + composeTestRule + .onNodeWithText(text = genericTitle) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = genericMessage) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(dialog = null) } + composeTestRule.assertNoDialogExists() + } + + @Test + fun `generic dialog should dismiss upon clicking okay`() { + mutableStateFlow.update { + it.copy( + dialog = EditItemState.DialogState.Generic( + title = "Generic Title!".asText(), + message = "Generic message".asText(), + ), + ) + } + composeTestRule + .onNodeWithText(text = "Okay") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.DismissDialog) + } + } + + @Test + fun `on close click should emit CancelClick`() { + composeTestRule + .onNodeWithContentDescription(label = "Close") + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.CancelClick) + } + } + + @Test + fun `on save click should emit SaveClick`() { + composeTestRule + .onNodeWithText(text = "Save") + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.SaveClick) + } + } + + @Test + fun `view state should update according to state`() { + mutableStateFlow.update { it.copy(viewState = EditItemState.ViewState.Loading) } + + composeTestRule.onNode(isProgressBar).assertIsDisplayed() + + val errorMessage = "Error!" + mutableStateFlow.update { + it.copy(viewState = EditItemState.ViewState.Error(message = errorMessage.asText())) + } + composeTestRule.onNodeWithText(text = errorMessage).assertIsDisplayed() + + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + + composeTestRule.onNodeWithTextAfterScroll(text = "Information").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll(text = "Name").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll(text = "Key").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll(text = "Username").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll(text = "Favorite").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll(text = "Advanced").assertIsDisplayed() + } + + @Test + fun `editing name field should send IssuerNameTextChange`() { + val textInput = "name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + composeTestRule + .onNodeWithTextAfterScroll(text = "Name") + .performTextInput(text = textInput) + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.IssuerNameTextChange(textInput)) + } + } + + @Test + fun `editing username field should send UsernameTextChange`() { + val textInput = "name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + composeTestRule + .onNodeWithTextAfterScroll(text = "Username") + .performTextInput(text = textInput) + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.UsernameTextChange(textInput)) + } + } + + @Test + fun `editing key field should send TotpCodeTextChange`() { + val textInput = "key" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + composeTestRule + .onNodeWithTextAfterScroll(text = "Key") + .performTextInput(text = textInput) + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.TotpCodeTextChange(textInput)) + } + } + + @Test + fun `favorite switch toggle should send FavoriteToggleClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + composeTestRule + .onNodeWithTextAfterScroll(text = "Favorite") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.FavoriteToggleClick(true)) + } + } + + @Test + fun `advanced click should send ExpandAdvancedOptionsClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_CONTENT) } + composeTestRule + .onNodeWithTextAfterScroll(text = "Advanced") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.ExpandAdvancedOptionsClick) + } + } + + @Test + fun `OTP type click should display dialog and selection should send TypeOptionClick`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "TOTP. OTP type") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "OTP type") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "STEAM") + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.TypeOptionClick(AuthenticatorItemType.STEAM)) + } + } + + @Test + fun `OTP type click should display dialog and cancel should dismiss the dialog`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "TOTP. OTP type") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "OTP type") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Cancel") + .performClick() + composeTestRule.assertNoDialogExists() + } + + @Test + fun `algorithm click should display dialog and selection should send TypeOptionClick`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "SHA1. Algorithm") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Algorithm") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "SHA256") + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 1) { + viewModel.trySendAction( + EditItemAction.AlgorithmOptionClick(AuthenticatorItemAlgorithm.SHA256), + ) + } + } + + @Test + fun `algorithm click should display dialog and cancel should dismiss the dialog`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "SHA1. Algorithm") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Algorithm") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Cancel") + .performClick() + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `refresh period click should display dialog and selection should send RefreshPeriodOptionClick`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "30 seconds. Refresh period") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Refresh period") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "60 seconds") + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 1) { + viewModel.trySendAction( + EditItemAction.RefreshPeriodOptionClick(AuthenticatorRefreshPeriodOption.SIXTY), + ) + } + } + + @Test + fun `refresh period click should display dialog and cancel should dismiss the dialog`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "30 seconds. Refresh period") + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Refresh period") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Cancel") + .performClick() + composeTestRule.assertNoDialogExists() + } + + @Test + fun `number of digits plus click should send NumberOfDigitsOptionClick`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithTextAfterScroll(text = "Number of digits") + .onSiblings() + .filterToOne(hasContentDescription("+")) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.NumberOfDigitsOptionClick(7)) + } + } + + @Test + fun `number of digits minus click should send NumberOfDigitsOptionClick`() { + mutableStateFlow.update { + it.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)) + } + composeTestRule + .onNodeWithTextAfterScroll(text = "Number of digits") + .onSiblings() + .filterToOne(hasContentDescription("\u2212")) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(EditItemAction.NumberOfDigitsOptionClick(5)) + } + } +} + +private val DEFAULT_STATE: EditItemState = + EditItemState( + itemId = "item_id", + viewState = EditItemState.ViewState.Loading, + dialog = null, + ) + +private val DEFAULT_ITEM_DATA: EditItemData = + EditItemData( + refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, + totpCode = "", + type = AuthenticatorItemType.TOTP, + username = null, + issuer = "", + algorithm = AuthenticatorItemAlgorithm.SHA1, + digits = 6, + favorite = false, + ) + +private val DEFAULT_CONTENT: EditItemState.ViewState.Content = + EditItemState.ViewState.Content( + isAdvancedOptionsExpanded = false, + minDigitsAllowed = 5, + maxDigitsAllowed = 10, + itemData = DEFAULT_ITEM_DATA, + ) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModelTest.kt new file mode 100644 index 0000000000..51541a1c2f --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModelTest.kt @@ -0,0 +1,496 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.edititem + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.ui.authenticator.feature.edititem.model.EditItemData +import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.util.asText +import com.bitwarden.ui.util.concat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class EditItemViewModelTest : BaseViewModelTest() { + private val mutableItemStateFlow = + MutableStateFlow>(DataState.Loading) + + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { getItemStateFlow(itemId = DEFAULT_ITEM_ID) } returns mutableItemStateFlow + } + + @BeforeEach + fun setup() { + mockkStatic( + SavedStateHandle::toEditItemArgs, + ) + } + + @AfterEach + fun tearDown() { + unmockkStatic( + SavedStateHandle::toEditItemArgs, + ) + } + + @Test + fun `initial state should be correct`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `on DismissDialog should clear the dialog state`() = runTest { + val state = DEFAULT_STATE.copy( + dialog = EditItemState.DialogState.Loading(message = "loading".asText()), + ) + val viewModel = createViewModel(state = state) + viewModel.stateFlow.test { + assertEquals(state, awaitItem()) + viewModel.trySendAction(EditItemAction.DismissDialog) + assertEquals(state.copy(dialog = null), awaitItem()) + } + } + + @Test + fun `on AlgorithmOptionClick should update the algorithm state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val algorithm = AuthenticatorItemAlgorithm.SHA256 + viewModel.trySendAction(EditItemAction.AlgorithmOptionClick(algorithm)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(algorithm = algorithm), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on CancelClick should send NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(EditItemAction.CancelClick) + assertEquals(EditItemEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `on TypeOptionClick should update the type state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val type = AuthenticatorItemType.STEAM + viewModel.trySendAction(EditItemAction.TypeOptionClick(type)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(type = type), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on IssuerNameTextChange should update the issuer state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val issuer = "newIssuer" + viewModel.trySendAction(EditItemAction.IssuerNameTextChange(issuer)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(issuer = issuer), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UsernameTextChange should update the username state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val username = "newUsername" + viewModel.trySendAction(EditItemAction.UsernameTextChange(username)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(username = username), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on FavoriteToggleClick should update the favorite state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val isFavorite = true + viewModel.trySendAction(EditItemAction.FavoriteToggleClick(isFavorite)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(favorite = isFavorite), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on RefreshPeriodOptionClick should update the refresh period state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val period = AuthenticatorRefreshPeriodOption.NINETY + viewModel.trySendAction(EditItemAction.RefreshPeriodOptionClick(period)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(refreshPeriod = period), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on TotpCodeTextChange should update the totp code state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val totpCode = "newTotpCode" + viewModel.trySendAction(EditItemAction.TotpCodeTextChange(totpCode)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(totpCode = totpCode), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on NumberOfDigitsOptionClick should update the number of digits state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + val digits = 8 + viewModel.trySendAction(EditItemAction.NumberOfDigitsOptionClick(digits)) + assertEquals( + state.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(digits = digits), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on SaveClick with blank issuer should display an error dialog`() { + mutableItemStateFlow.tryEmit( + DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY.copy(issuer = "")), + ) + val state = DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(issuer = ""), + ), + ) + val viewModel = createViewModel(state = state) + viewModel.trySendAction(EditItemAction.SaveClick) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText(R.string.name.asText()), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on SaveClick with blank totp code should display an error dialog`() { + mutableItemStateFlow.tryEmit( + DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY.copy(key = "")), + ) + val state = DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(totpCode = ""), + ), + ) + val viewModel = createViewModel(state = state) + viewModel.trySendAction(EditItemAction.SaveClick) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText(R.string.key.asText()), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on SaveClick with non-base32 totp code should display an error dialog`() { + mutableItemStateFlow.tryEmit( + DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY.copy(key = "111%")), + ) + val state = DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + itemData = DEFAULT_ITEM_DATA.copy(totpCode = "111%"), + ), + ) + val viewModel = createViewModel(state = state) + viewModel.trySendAction(EditItemAction.SaveClick) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.key_is_invalid.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on SaveClick with valid data and createItem error should display error dialog`() = + runTest { + mutableItemStateFlow.tryEmit( + DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY), + ) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + coEvery { + authenticatorRepository.createItem(item = any()) + } returns CreateItemResult.Error + val viewModel = createViewModel(state = state) + viewModel.stateFlow.test { + assertEquals(state, awaitItem()) + viewModel.trySendAction(EditItemAction.SaveClick) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Generic( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + coVerify(exactly = 1) { + authenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = DEFAULT_ITEM_ID, + key = "ABCD", + accountName = "mockAccountName", + type = AuthenticatorItemType.TOTP, + algorithm = AuthenticatorItemAlgorithm.SHA1, + period = 30, + digits = 6, + issuer = "mockIssuer", + favorite = false, + ), + ) + } + } + + @Test + fun `on SaveClick with valid data and createItem success navigate back`() = runTest { + mutableItemStateFlow.tryEmit( + DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY), + ) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + coEvery { + authenticatorRepository.createItem(item = any()) + } returns CreateItemResult.Success + val viewModel = createViewModel(state = state) + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(state, stateFlow.awaitItem()) + viewModel.trySendAction(EditItemAction.SaveClick) + assertEquals( + state.copy( + dialog = EditItemState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ), + stateFlow.awaitItem(), + ) + assertEquals( + EditItemEvent.ShowToast(R.string.item_saved.asText()), + eventFlow.awaitItem(), + ) + assertEquals(EditItemEvent.NavigateBack, eventFlow.awaitItem()) + } + coVerify(exactly = 1) { + authenticatorRepository.createItem( + item = AuthenticatorItemEntity( + id = DEFAULT_ITEM_ID, + key = "ABCD", + accountName = "mockAccountName", + type = AuthenticatorItemType.TOTP, + algorithm = AuthenticatorItemAlgorithm.SHA1, + period = 30, + digits = 6, + issuer = "mockIssuer", + favorite = false, + ), + ) + } + } + + @Test + fun `on ExpandAdvancedOptionsClick should update the isAdvancedOptionsExpanded state`() { + mutableItemStateFlow.tryEmit(DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY)) + val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT) + val viewModel = createViewModel(state = state) + viewModel.trySendAction(EditItemAction.ExpandAdvancedOptionsClick) + assertEquals( + state.copy(viewState = DEFAULT_CONTENT.copy(isAdvancedOptionsExpanded = true)), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on EditItemDataReceive should update the view state`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction( + EditItemAction.Internal.EditItemDataReceive(DataState.Error(Throwable())), + ) + assertEquals( + DEFAULT_STATE.copy( + viewState = EditItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + viewModel.trySendAction( + EditItemAction.Internal.EditItemDataReceive( + itemDataState = DataState.Loaded(DEFAULT_AUTHENTICATOR_ENTITY), + ), + ) + assertEquals( + DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT), + awaitItem(), + ) + viewModel.trySendAction(EditItemAction.Internal.EditItemDataReceive(DataState.Loading)) + assertEquals( + DEFAULT_STATE.copy(viewState = EditItemState.ViewState.Loading), + awaitItem(), + ) + viewModel.trySendAction( + EditItemAction.Internal.EditItemDataReceive(DataState.NoNetwork(null)), + ) + assertEquals( + DEFAULT_STATE.copy( + viewState = EditItemState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ), + awaitItem(), + ) + viewModel.trySendAction( + EditItemAction.Internal.EditItemDataReceive(DataState.Pending(null)), + ) + assertEquals( + DEFAULT_STATE.copy( + viewState = EditItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + + private fun createViewModel( + state: EditItemState? = null, + ): EditItemViewModel = EditItemViewModel( + authenticatorRepository = authenticatorRepository, + savedStateHandle = SavedStateHandle().apply { + set(key = "state", value = state) + every { toEditItemArgs() } returns EditItemArgs( + itemId = state?.itemId ?: DEFAULT_ITEM_ID, + ) + }, + ) +} + +private const val DEFAULT_ITEM_ID: String = "item_id" +private val DEFAULT_STATE: EditItemState = + EditItemState( + itemId = DEFAULT_ITEM_ID, + viewState = EditItemState.ViewState.Loading, + dialog = null, + ) + +private val DEFAULT_ITEM_DATA: EditItemData = + EditItemData( + refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, + totpCode = "ABCD", + type = AuthenticatorItemType.TOTP, + username = "mockAccountName", + issuer = "mockIssuer", + algorithm = AuthenticatorItemAlgorithm.SHA1, + digits = 6, + favorite = false, + ) + +private val DEFAULT_CONTENT: EditItemState.ViewState.Content = + EditItemState.ViewState.Content( + isAdvancedOptionsExpanded = false, + minDigitsAllowed = 5, + maxDigitsAllowed = 10, + itemData = DEFAULT_ITEM_DATA, + ) + +private val DEFAULT_AUTHENTICATOR_ENTITY: AuthenticatorItemEntity = + AuthenticatorItemEntity( + id = DEFAULT_ITEM_ID, + key = "ABCD", + issuer = "mockIssuer", + accountName = "mockAccountName", + userId = null, + favorite = false, + type = AuthenticatorItemType.TOTP, + )