Add tests for the EditItemScreen and EditItemViewModel (#5348)

This commit is contained in:
David Perez
2025-06-12 09:30:00 -05:00
committed by GitHub
parent 3474e0b608
commit 3c86bb425b
4 changed files with 919 additions and 4 deletions

View File

@@ -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),
)
}
}
}
}

View File

@@ -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,

View File

@@ -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<EditItemEvent>()
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,
)

View File

@@ -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<AuthenticatorItemEntity?>>(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,
)