mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
Add tests for the EditItemScreen and EditItemViewModel (#5348)
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user