mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-71: Adding logic for password hint implementation (#748)
This commit is contained in:
parent
96201fd34c
commit
5279f1a4ba
14 changed files with 479 additions and 3 deletions
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.api
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
|
@ -17,4 +18,9 @@ interface AccountsApi {
|
|||
|
||||
@POST("/accounts/register")
|
||||
suspend fun register(@Body body: RegisterRequestJson): Result<RegisterResponseJson.Success>
|
||||
|
||||
@POST("/accounts/password-hint")
|
||||
suspend fun passwordHintRequest(
|
||||
@Body body: PasswordHintRequestJson,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for password hint.
|
||||
*/
|
||||
@Serializable
|
||||
data class PasswordHintRequestJson(
|
||||
@SerialName("email")
|
||||
val email: String,
|
||||
)
|
|
@ -0,0 +1,26 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models response bodies from password hint response.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class PasswordHintResponseJson {
|
||||
|
||||
/**
|
||||
* The success body of the password hint response
|
||||
*/
|
||||
@Serializable
|
||||
data object Success : PasswordHintResponseJson()
|
||||
|
||||
/**
|
||||
* The error body of an invalid request containing a message.
|
||||
*/
|
||||
@Serializable
|
||||
data class Error(
|
||||
@SerialName("message")
|
||||
val errorMessage: String?,
|
||||
) : PasswordHintResponseJson()
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
|
@ -23,4 +24,9 @@ interface AccountsService {
|
|||
* Register a new account to Bitwarden.
|
||||
*/
|
||||
suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson>
|
||||
|
||||
/**
|
||||
* Request a password hint.
|
||||
*/
|
||||
suspend fun requestPasswordHint(email: String): Result<PasswordHintResponseJson>
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
|
@ -40,4 +42,20 @@ class AccountsServiceImpl constructor(
|
|||
json = json,
|
||||
) ?: throw throwable
|
||||
}
|
||||
|
||||
override suspend fun requestPasswordHint(
|
||||
email: String,
|
||||
): Result<PasswordHintResponseJson> =
|
||||
accountsApi
|
||||
.passwordHintRequest(PasswordHintRequestJson(email))
|
||||
.map { PasswordHintResponseJson.Success }
|
||||
.recoverCatching { throwable ->
|
||||
throwable
|
||||
.toBitwardenError()
|
||||
.parseErrorBodyOrNull<PasswordHintResponseJson.Error>(
|
||||
code = 429,
|
||||
json = json,
|
||||
)
|
||||
?: throw throwable
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
|
@ -98,6 +99,13 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
shouldCheckDataBreaches: Boolean,
|
||||
): RegisterResult
|
||||
|
||||
/**
|
||||
* Attempt to request a password hint.
|
||||
*/
|
||||
suspend fun passwordHintRequest(
|
||||
email: String,
|
||||
): PasswordHintResult
|
||||
|
||||
/**
|
||||
* Set the value of [captchaTokenResultFlow].
|
||||
*/
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
|
@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
|
@ -385,6 +387,20 @@ class AuthRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun passwordHintRequest(email: String): PasswordHintResult {
|
||||
return accountsService.requestPasswordHint(email).fold(
|
||||
onSuccess = {
|
||||
when (it) {
|
||||
is PasswordHintResponseJson.Error -> {
|
||||
PasswordHintResult.Error(it.errorMessage)
|
||||
}
|
||||
PasswordHintResponseJson.Success -> PasswordHintResult.Success
|
||||
}
|
||||
},
|
||||
onFailure = { PasswordHintResult.Error(null) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
|
||||
mutableCaptchaTokenFlow.tryEmit(tokenResult)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of password hint request.
|
||||
*/
|
||||
sealed class PasswordHintResult {
|
||||
|
||||
/**
|
||||
* Password hint request success
|
||||
*/
|
||||
data object Success : PasswordHintResult()
|
||||
|
||||
/**
|
||||
* There was an error.
|
||||
*/
|
||||
data class Error(val message: String?) : PasswordHintResult()
|
||||
}
|
|
@ -25,10 +25,12 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
|
||||
/**
|
||||
* The top level composable for the Login screen.
|
||||
|
@ -60,10 +62,18 @@ fun MasterPasswordHintScreen(
|
|||
)
|
||||
}
|
||||
|
||||
is MasterPasswordHintState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(
|
||||
text = dialogState.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is MasterPasswordHintState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = R.string.password_hint.asText(),
|
||||
title = dialogState.title ?: R.string.an_error_has_occurred.asText(),
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = remember(viewModel) {
|
||||
|
|
|
@ -3,12 +3,18 @@ package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
|
|||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -19,7 +25,9 @@ private const val KEY_STATE = "state"
|
|||
*/
|
||||
@HiltViewModel
|
||||
class MasterPasswordHintViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val networkConnectionManager: NetworkConnectionManager,
|
||||
) : BaseViewModel<MasterPasswordHintState, MasterPasswordHintEvent, MasterPasswordHintAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: MasterPasswordHintState(
|
||||
|
@ -40,6 +48,9 @@ class MasterPasswordHintViewModel @Inject constructor(
|
|||
MasterPasswordHintAction.SubmitClick -> handleSubmitClick()
|
||||
is MasterPasswordHintAction.EmailInputChange -> handleEmailInputUpdated(action)
|
||||
MasterPasswordHintAction.DismissDialog -> handleDismissDialog()
|
||||
is MasterPasswordHintAction.Internal.PasswordHintResultReceive -> {
|
||||
handlePasswordHintResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,8 +60,86 @@ class MasterPasswordHintViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ReturnCount")
|
||||
private fun handleSubmitClick() {
|
||||
// TODO (BIT-71): Implement master password hint
|
||||
val email = stateFlow.value.emailInput
|
||||
|
||||
if (!networkConnectionManager.isNetworkConnected) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.internet_connection_required_title.asText(),
|
||||
message = R.string.internet_connection_required_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (email.isBlank()) {
|
||||
val errorMessage =
|
||||
R.string.validation_field_required.asText(R.string.email_address.asText())
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = errorMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!email.contains("@")) {
|
||||
val errorMessage = R.string.invalid_email.asText()
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = errorMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MasterPasswordHintState.DialogState.Loading(
|
||||
R.string.submitting.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.passwordHintRequest(email)
|
||||
sendAction(MasterPasswordHintAction.Internal.PasswordHintResultReceive(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordHintResult(
|
||||
action: MasterPasswordHintAction.Internal.PasswordHintResultReceive,
|
||||
) {
|
||||
when (action.result) {
|
||||
is PasswordHintResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = MasterPasswordHintState.DialogState.PasswordHintSent)
|
||||
}
|
||||
}
|
||||
is PasswordHintResult.Error -> {
|
||||
val errorMessage = action.result.message?.asText()
|
||||
?: R.string.generic_error_message.asText()
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = errorMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEmailInputUpdated(action: MasterPasswordHintAction.EmailInputChange) {
|
||||
|
@ -87,11 +176,20 @@ data class MasterPasswordHintState(
|
|||
@Parcelize
|
||||
data object PasswordHintSent : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a loading dialog with the given [message].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents an error dialog with the given [message].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
|
@ -132,4 +230,16 @@ sealed class MasterPasswordHintAction {
|
|||
* User dismissed the currently displayed dialog.
|
||||
*/
|
||||
data object DismissDialog : MasterPasswordHintAction()
|
||||
|
||||
/**
|
||||
* Actions for internal use by the ViewModel.
|
||||
*/
|
||||
sealed class Internal : MasterPasswordHintAction() {
|
||||
/**
|
||||
* Indicates that the password hint result was received.
|
||||
*/
|
||||
data class PasswordHintResultReceive(
|
||||
val result: PasswordHintResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
|
@ -198,6 +199,18 @@ class AccountsServiceTest : BaseServiceTest() {
|
|||
assertEquals(Result.success(expectedResponse), service.register(registerRequestBody))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestPasswordHint success should return Success`() = runTest {
|
||||
val email = "test@example.com"
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
server.enqueue(response)
|
||||
|
||||
val result = service.requestPasswordHint(email)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(PasswordHintResponseJson.Success, result.getOrNull())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EMAIL = "email"
|
||||
private val registerRequestBody = RegisterRequestJson(
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
|||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
|
||||
|
@ -32,6 +33,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
|
@ -998,6 +1000,43 @@ class AuthRepositoryTest {
|
|||
assertEquals(RegisterResult.Error(errorMessage = "message"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passwordHintRequest with valid email should return Success`() = runTest {
|
||||
val email = "valid@example.com"
|
||||
coEvery {
|
||||
accountsService.requestPasswordHint(email)
|
||||
} returns Result.success(PasswordHintResponseJson.Success)
|
||||
|
||||
val result = repository.passwordHintRequest(email)
|
||||
|
||||
assertEquals(PasswordHintResult.Success, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passwordHintRequest with error response should return Error`() = runTest {
|
||||
val email = "error@example.com"
|
||||
val errorMessage = "Error message"
|
||||
coEvery {
|
||||
accountsService.requestPasswordHint(email)
|
||||
} returns Result.success(PasswordHintResponseJson.Error(errorMessage))
|
||||
|
||||
val result = repository.passwordHintRequest(email)
|
||||
|
||||
assertEquals(PasswordHintResult.Error(errorMessage), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passwordHintRequest with failure should return Error with null message`() = runTest {
|
||||
val email = "failure@example.com"
|
||||
coEvery {
|
||||
accountsService.requestPasswordHint(email)
|
||||
} returns Result.failure(RuntimeException("Network error"))
|
||||
|
||||
val result = repository.passwordHintRequest(email)
|
||||
|
||||
assertEquals(PasswordHintResult.Error(null), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setCaptchaCallbackToken should change the value of captchaTokenFlow`() = runTest {
|
||||
repository.captchaTokenResultFlow.test {
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
|
@ -41,6 +45,60 @@ class MasterPasswordHintScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show success dialog when PasswordHintSent state is set`() {
|
||||
mutableStateFlow.value = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.PasswordHintSent,
|
||||
emailInput = "test@example.com",
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("We've sent you an email with your master password hint.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show error dialog when Error state is set`() {
|
||||
val errorMessage = "Error occurred"
|
||||
mutableStateFlow.value = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(message = errorMessage.asText()),
|
||||
emailInput = "test@example.com",
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(errorMessage)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show loading dialog when Loading state is set`() {
|
||||
val loadingMessage = "Submitting"
|
||||
mutableStateFlow.value = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Loading(message = loadingMessage.asText()),
|
||||
emailInput = "test@example.com",
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(loadingMessage)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking ok in dialog should send DismissDialog action`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
mutableStateFlow.value = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(message = "".asText()),
|
||||
emailInput = "test@example.com",
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Ok")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(MasterPasswordHintAction.DismissDialog) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(MasterPasswordHintEvent.NavigateBack)
|
||||
|
|
|
@ -2,13 +2,24 @@ package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MasterPasswordHintViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val authRepository: AuthRepository = mockk()
|
||||
private val networkConnectionManager: NetworkConnectionManager = mockk()
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
|
@ -18,6 +29,129 @@ class MasterPasswordHintViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `SubmitClick with valid email should show success dialog`() = runTest {
|
||||
val validEmail = "test@example.com"
|
||||
every { networkConnectionManager.isNetworkConnected } returns true
|
||||
coEvery {
|
||||
authRepository.passwordHintRequest(validEmail)
|
||||
} returns PasswordHintResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(validEmail))
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.SubmitClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedSuccessState = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.PasswordHintSent,
|
||||
emailInput = validEmail,
|
||||
)
|
||||
assertEquals(expectedSuccessState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick with no network connection should show error dialog`() = runTest {
|
||||
val email = "test@example.com"
|
||||
every { networkConnectionManager.isNetworkConnected } returns false
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val expectedErrorState = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.internet_connection_required_title.asText(),
|
||||
message = R.string.internet_connection_required_message.asText(),
|
||||
),
|
||||
emailInput = email,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(email))
|
||||
viewModel.trySendAction(MasterPasswordHintAction.SubmitClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(expectedErrorState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick with empty email field should show error dialog`() = runTest {
|
||||
val emptyEmail = ""
|
||||
every { networkConnectionManager.isNetworkConnected } returns true
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val expectedErrorState = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.validation_field_required.asText(
|
||||
R.string.email_address.asText(),
|
||||
),
|
||||
),
|
||||
emailInput = emptyEmail,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(emptyEmail))
|
||||
viewModel.trySendAction(MasterPasswordHintAction.SubmitClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(expectedErrorState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick with invalid email should show error dialog`() = runTest {
|
||||
val invalidEmail = "invalidemail"
|
||||
every { networkConnectionManager.isNetworkConnected } returns true
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val expectedErrorState = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.invalid_email.asText(),
|
||||
),
|
||||
emailInput = invalidEmail,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(invalidEmail))
|
||||
viewModel.trySendAction(MasterPasswordHintAction.SubmitClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(expectedErrorState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DismissDialog should update state to remove dialog`() = runTest {
|
||||
val initialState = MasterPasswordHintState(
|
||||
dialog = MasterPasswordHintState.DialogState.Error(message = "Some error".asText()),
|
||||
emailInput = "test@example.com",
|
||||
)
|
||||
val viewModel = createViewModel(initialState)
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.DismissDialog)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedState = initialState.copy(dialog = null)
|
||||
assertEquals(expectedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EmailInputChange should update emailInput in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val newEmail = "new@example.com"
|
||||
|
||||
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(newEmail))
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedState = MasterPasswordHintState(
|
||||
dialog = null,
|
||||
emailInput = newEmail,
|
||||
)
|
||||
assertEquals(expectedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -31,6 +165,8 @@ class MasterPasswordHintViewModelTest : BaseViewModelTest() {
|
|||
state: MasterPasswordHintState? = DEFAULT_STATE,
|
||||
): MasterPasswordHintViewModel = MasterPasswordHintViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", state) },
|
||||
authRepository = authRepository,
|
||||
networkConnectionManager = networkConnectionManager,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue