diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt index f033a3e6cd..df58c94f3e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt @@ -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 + + @POST("/accounts/password-hint") + suspend fun passwordHintRequest( + @Body body: PasswordHintRequestJson, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintRequestJson.kt new file mode 100644 index 0000000000..e725bdd34f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintRequestJson.kt @@ -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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintResponseJson.kt new file mode 100644 index 0000000000..a4c1ed6ec2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/PasswordHintResponseJson.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index 34ee2da7dc..4d850c4a7d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -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 + + /** + * Request a password hint. + */ + suspend fun requestPasswordHint(email: String): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index 6d0d97e4ac..85dbfaf5e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -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 = + accountsApi + .passwordHintRequest(PasswordHintRequestJson(email)) + .map { PasswordHintResponseJson.Success } + .recoverCatching { throwable -> + throwable + .toBitwardenError() + .parseErrorBodyOrNull( + code = 429, + json = json, + ) + ?: throw throwable + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 4c9d781a35..5da0632066 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 6303f3c1d3..d2a70f8b4f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PasswordHintResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PasswordHintResult.kt new file mode 100644 index 0000000000..6f7aca6dcf --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PasswordHintResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt index da3d466295..82058035af 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt @@ -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) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt index 482f36c78a..8e6b56d7f3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt @@ -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( 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() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index d1bc3e27ec..330c792488 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index f9c07b02f7..73aff0fd0b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt index 2f2fd7a421..bcc71bfe65 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt @@ -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) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt index 76e6833e86..b9384902fb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt @@ -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, ) }