Merge pull request #6954 from thundernest/add_account_setup_incoming_config_validation

Add account setup incoming config validation
This commit is contained in:
Wolf-Martell Montwé 2023-06-07 16:08:41 +02:00 committed by GitHub
commit 8015392d70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 316 additions and 84 deletions

View file

@ -1,6 +1,8 @@
package app.k9mail.feature.account.setup
import app.k9mail.feature.account.setup.ui.AccountSetupViewModel
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigValidator
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsValidator
@ -13,11 +15,16 @@ import org.koin.core.module.Module
import org.koin.dsl.module
val featureAccountSetupModule: Module = module {
factory<AccountIncomingConfigContract.Validator> { AccountIncomingConfigValidator() }
factory<AccountOutgoingConfigContract.Validator> { AccountOutgoingConfigValidator() }
factory<AccountOptionsContract.Validator> { AccountOptionsValidator() }
viewModel { AccountSetupViewModel() }
viewModel { AccountIncomingConfigViewModel() }
viewModel {
AccountIncomingConfigViewModel(
validator = get(),
)
}
viewModel {
AccountOutgoingConfigViewModel(
validator = get(),

View file

@ -68,4 +68,8 @@ class NumberInputField(
result = 31 * result + isValid.hashCode()
return result
}
override fun toString(): String {
return "NumberInputField(value=$value, error=$error, isValid=$isValid)"
}
}

View file

@ -68,4 +68,8 @@ class StringInputField(
result = 31 * result + isValid.hashCode()
return result
}
override fun toString(): String {
return "StringInputField(value='$value', error=$error, isValid=$isValid)"
}
}

View file

@ -0,0 +1,21 @@
package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.common.domain.usecase.validation.ValidationUseCase
class ValidateImapPrefix : ValidationUseCase<String> {
override fun execute(input: String): ValidationResult {
return when {
input.isEmpty() -> ValidationResult.Success
input.isBlank() -> ValidationResult.Failure(ValidateImapPrefixError.BlankImapPrefix)
else -> ValidationResult.Success
}
}
sealed interface ValidateImapPrefixError : ValidationError {
object BlankImapPrefix : ValidateImapPrefixError
}
}

View file

@ -1,8 +1,14 @@
package app.k9mail.feature.account.setup.ui
import android.content.res.Resources
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.usecase.ValidateImapPrefix
import app.k9mail.feature.account.setup.domain.usecase.ValidatePassword
import app.k9mail.feature.account.setup.domain.usecase.ValidatePort
import app.k9mail.feature.account.setup.domain.usecase.ValidateServer
import app.k9mail.feature.account.setup.domain.usecase.ValidateUsername
internal fun ConnectionSecurity.toResourceString(resources: Resources): String {
return when (this) {
@ -11,3 +17,58 @@ internal fun ConnectionSecurity.toResourceString(resources: Resources): String {
ConnectionSecurity.TLS -> resources.getString(R.string.account_setup_connection_security_ssl)
}
}
internal fun ValidationError.toResourceString(resources: Resources): String {
return when (this) {
is ValidateServer.ValidateServerError -> toServerErrorString(resources)
is ValidatePort.ValidatePortError -> toPortErrorString(resources)
is ValidateUsername.ValidateUsernameError -> toUsernameErrorString(resources)
is ValidatePassword.ValidatePasswordError -> toPasswordErrorString(resources)
is ValidateImapPrefix.ValidateImapPrefixError -> toImapPrefixErrorString(resources)
else -> throw IllegalArgumentException("Unknown error: $this")
}
}
private fun ValidateServer.ValidateServerError.toServerErrorString(resources: Resources): String {
return when (this) {
is ValidateServer.ValidateServerError.EmptyServer -> resources.getString(
R.string.account_setup_validation_error_server_required,
)
}
}
private fun ValidatePort.ValidatePortError.toPortErrorString(resources: Resources): String {
return when (this) {
is ValidatePort.ValidatePortError.EmptyPort -> resources.getString(
R.string.account_setup_validation_error_port_required,
)
is ValidatePort.ValidatePortError.InvalidPort -> resources.getString(
R.string.account_setup_validation_error_port_invalid,
)
}
}
private fun ValidateUsername.ValidateUsernameError.toUsernameErrorString(resources: Resources): String {
return when (this) {
ValidateUsername.ValidateUsernameError.EmptyUsername -> resources.getString(
R.string.account_setup_validation_error_username_required,
)
}
}
private fun ValidatePassword.ValidatePasswordError.toPasswordErrorString(resources: Resources): String {
return when (this) {
ValidatePassword.ValidatePasswordError.EmptyPassword -> resources.getString(
R.string.account_setup_validation_error_password_required,
)
}
}
private fun ValidateImapPrefix.ValidateImapPrefixError.toImapPrefixErrorString(resources: Resources): String {
return when (this) {
ValidateImapPrefix.ValidateImapPrefixError.BlankImapPrefix -> resources.getString(
R.string.account_setup_validation_error_imap_prefix_blank,
)
}
}

View file

@ -75,6 +75,7 @@ internal fun AccountIncomingConfigContent(
item {
TextInput(
text = state.server.value,
errorMessage = state.server.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ServerChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_server_label),
contentPadding = defaultItemPadding(),
@ -95,6 +96,7 @@ internal fun AccountIncomingConfigContent(
item {
NumberInput(
value = state.port.value,
errorMessage = state.port.error?.toResourceString(resources),
onValueChange = { onEvent(Event.PortChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_port_label),
contentPadding = defaultItemPadding(),
@ -104,6 +106,7 @@ internal fun AccountIncomingConfigContent(
item {
TextInput(
text = state.username.value,
errorMessage = state.username.error?.toResourceString(resources),
onTextChange = { onEvent(Event.UsernameChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_username_label),
contentPadding = defaultItemPadding(),
@ -113,6 +116,7 @@ internal fun AccountIncomingConfigContent(
item {
PasswordInput(
password = state.password.value,
errorMessage = state.password.error?.toResourceString(resources),
onPasswordChange = { onEvent(Event.PasswordChanged(it)) },
contentPadding = defaultItemPadding(),
)
@ -149,6 +153,7 @@ internal fun AccountIncomingConfigContent(
item {
TextInput(
text = state.imapPrefix.value,
errorMessage = state.imapPrefix.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ImapPrefixChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_imap_prefix_label),
contentPadding = defaultItemPadding(),

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
@ -48,4 +49,12 @@ interface AccountIncomingConfigContract {
object NavigateNext : Effect()
object NavigateBack : Effect()
}
interface Validator {
fun validateServer(server: String): ValidationResult
fun validatePort(port: Long?): ValidationResult
fun validateUsername(username: String): ValidationResult
fun validatePassword(password: String): ValidationResult
fun validateImapPrefix(imapPrefix: String): ValidationResult
}
}

View file

@ -60,7 +60,9 @@ internal fun AccountIncomingConfigScreenK9Preview() {
AccountIncomingConfigScreen(
onNext = {},
onBack = {},
viewModel = AccountIncomingConfigViewModel(),
viewModel = AccountIncomingConfigViewModel(
validator = AccountIncomingConfigValidator(),
),
)
}
}
@ -72,7 +74,9 @@ internal fun AccountIncomingConfigScreenThunderbirdPreview() {
AccountIncomingConfigScreen(
onNext = {},
onBack = {},
viewModel = AccountIncomingConfigViewModel(),
viewModel = AccountIncomingConfigViewModel(
validator = AccountIncomingConfigValidator(),
),
)
}
}

View file

@ -0,0 +1,36 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.feature.account.setup.domain.usecase.ValidateImapPrefix
import app.k9mail.feature.account.setup.domain.usecase.ValidatePassword
import app.k9mail.feature.account.setup.domain.usecase.ValidatePort
import app.k9mail.feature.account.setup.domain.usecase.ValidateServer
import app.k9mail.feature.account.setup.domain.usecase.ValidateUsername
class AccountIncomingConfigValidator(
private val serverValidator: ValidateServer = ValidateServer(),
private val portValidator: ValidatePort = ValidatePort(),
private val usernameValidator: ValidateUsername = ValidateUsername(),
private val passwordValidator: ValidatePassword = ValidatePassword(),
private val imapPrefixValidator: ValidateImapPrefix = ValidateImapPrefix(),
) : AccountIncomingConfigContract.Validator {
override fun validateServer(server: String): ValidationResult {
return serverValidator.execute(server)
}
override fun validatePort(port: Long?): ValidationResult {
return portValidator.execute(port)
}
override fun validateUsername(username: String): ValidationResult {
return usernameValidator.execute(username)
}
override fun validatePassword(password: String): ValidationResult {
return passwordValidator.execute(password)
}
override fun validateImapPrefix(imapPrefix: String): ValidationResult {
return imapPrefixValidator.execute(imapPrefix)
}
}

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
@ -20,10 +21,12 @@ import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContrac
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UseCompressionChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UsernameChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Validator
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel
class AccountIncomingConfigViewModel(
initialState: State = State(),
private val validator: Validator,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
@ -70,8 +73,29 @@ class AccountIncomingConfigViewModel(
}
}
private fun submit() {
navigateNext()
private fun submit() = with(state.value) {
val serverResult = validator.validateServer(server.value)
val portResult = validator.validatePort(port.value)
val usernameResult = validator.validateUsername(username.value)
val passwordResult = validator.validatePassword(password.value)
val imapPrefixResult = validator.validateImapPrefix(imapPrefix.value)
val hasError = listOf(serverResult, portResult, usernameResult, passwordResult, imapPrefixResult)
.any { it is ValidationResult.Failure }
updateState {
it.copy(
server = it.server.updateFromValidationResult(serverResult),
port = it.port.updateFromValidationResult(portResult),
username = it.username.updateFromValidationResult(usernameResult),
password = it.password.updateFromValidationResult(passwordResult),
imapPrefix = it.imapPrefix.updateFromValidationResult(imapPrefixResult),
)
}
if (!hasError) {
navigateNext()
}
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)

View file

@ -44,7 +44,6 @@ interface AccountOutgoingConfigContract {
interface Validator {
fun validateServer(server: String): ValidationResult
fun validatePort(port: Long?): ValidationResult
fun validateUsername(username: String): ValidationResult
fun validatePassword(password: String): ValidationResult

View file

@ -1,55 +0,0 @@
package app.k9mail.feature.account.setup.ui.outgoing
import android.content.res.Resources
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.domain.usecase.ValidatePassword.ValidatePasswordError
import app.k9mail.feature.account.setup.domain.usecase.ValidatePort.ValidatePortError
import app.k9mail.feature.account.setup.domain.usecase.ValidateServer.ValidateServerError
import app.k9mail.feature.account.setup.domain.usecase.ValidateUsername.ValidateUsernameError
internal fun ValidationError.toResourceString(resources: Resources): String {
return when (this) {
is ValidateServerError -> toServerErrorString(resources)
is ValidatePortError -> toPortErrorString(resources)
is ValidateUsernameError -> toUsernameErrorString(resources)
is ValidatePasswordError -> toPasswordErrorString(resources)
else -> throw IllegalArgumentException("Unknown error: $this")
}
}
private fun ValidateServerError.toServerErrorString(resources: Resources): String {
return when (this) {
is ValidateServerError.EmptyServer -> resources.getString(
R.string.account_setup_outgoing_config_server_error_required,
)
}
}
private fun ValidatePortError.toPortErrorString(resources: Resources): String {
return when (this) {
is ValidatePortError.EmptyPort -> resources.getString(
R.string.account_setup_outgoing_config_port_error_required,
)
is ValidatePortError.InvalidPort -> resources.getString(
R.string.account_setup_outgoing_config_port_error_invalid,
)
}
}
private fun ValidateUsernameError.toUsernameErrorString(resources: Resources): String {
return when (this) {
ValidateUsernameError.EmptyUsername -> resources.getString(
R.string.account_setup_outgoing_config_username_error_required,
)
}
}
private fun ValidatePasswordError.toPasswordErrorString(resources: Resources): String {
return when (this) {
ValidatePasswordError.EmptyPassword -> resources.getString(
R.string.account_setup_outgoing_config_password_error_required,
)
}
}

View file

@ -9,6 +9,13 @@
<string name="account_setup_connection_security_start_tls">StartTLS</string>
<string name="account_setup_client_certificate_none_available">None available</string>
<string name="account_setup_validation_error_server_required">Server name is required.</string>
<string name="account_setup_validation_error_port_required">Port is required.</string>
<string name="account_setup_validation_error_port_invalid">Port is invalid (must be 165535).</string>
<string name="account_setup_validation_error_username_required">Username is required.</string>
<string name="account_setup_validation_error_password_required">Password is required.</string>
<string name="account_setup_validation_error_imap_prefix_blank">Imap prefix can\'t be blank.</string>
<string name="account_setup_auto_config_title">K-9 Mail</string>
<string name="account_setup_incoming_config_top_bar_title">Incoming server settings</string>
@ -21,14 +28,9 @@
<string name="account_setup_outgoing_config_top_bar_title">Outgoing server settings</string>
<string name="account_setup_outgoing_config_server_label">Server</string>
<string name="account_setup_outgoing_config_server_error_required">Server name is required.</string>
<string name="account_setup_outgoing_config_security_label">Security</string>
<string name="account_setup_outgoing_config_port_label">Port</string>
<string name="account_setup_outgoing_config_port_error_required">Port is required.</string>
<string name="account_setup_outgoing_config_port_error_invalid">Port is invalid (must be 165535).</string>
<string name="account_setup_outgoing_config_username_label">Username</string>
<string name="account_setup_outgoing_config_username_error_required">Username is required.</string>
<string name="account_setup_outgoing_config_password_error_required">Password is required.</string>
<string name="account_setup_outgoing_config_client_certificate_label">Client certificate</string>
<string name="account_setup_outgoing_config_imap_namespace_label">Auto-detect IMAP namespace</string>
<string name="account_setup_outgoing_config_compression_label">Use compression</string>

View file

@ -0,0 +1,39 @@
package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import assertk.assertThat
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import org.junit.Test
class ValidateImapPrefixTest {
@Test
fun `should success when imap prefix is set`() {
val useCase = ValidateImapPrefix()
val result = useCase.execute("imap")
assertThat(result).isInstanceOf(ValidationResult.Success::class)
}
@Test
fun `should succeed when imap prefix is empty`() {
val useCase = ValidateImapPrefix()
val result = useCase.execute("")
assertThat(result).isInstanceOf(ValidationResult.Success::class)
}
@Test
fun `should fail when imap prefix is blank`() {
val useCase = ValidateImapPrefix()
val result = useCase.execute(" ")
assertThat(result).isInstanceOf(ValidationResult.Failure::class)
.prop(ValidationResult.Failure::error)
.isInstanceOf(ValidateImapPrefix.ValidateImapPrefixError.BlankImapPrefix::class)
}
}

View file

@ -2,6 +2,8 @@ package app.k9mail.feature.account.setup.ui.incoming
import androidx.lifecycle.viewmodel.compose.viewModel
import app.cash.turbine.testIn
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
@ -13,6 +15,7 @@ import app.k9mail.feature.account.setup.testing.eventStateTest
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -26,7 +29,9 @@ class AccountIncomingConfigViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = AccountIncomingConfigViewModel()
private val testSubject = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(),
)
@Test
fun `should change protocol, security and port when ProtocolTypeChanged event is received`() = runTest {
@ -152,28 +157,76 @@ class AccountIncomingConfigViewModelTest {
}
@Test
fun `should emit NavigateNext effect when OnNextClicked event received`() = runTest {
val viewModel = testSubject
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
fun `should change state and emit NavigateNext effect when OnNextClicked event is received and input is valid`() =
runTest {
val viewModel = testSubject
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnNextClicked)
assertThat(stateTurbine.awaitItem()).isEqualTo(
State(
server = StringInputField(value = "", isValid = true),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
),
)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
viewModel.event(Event.OnNextClicked)
@Test
fun `should change state and not emit NavigateNext effect when OnNextClicked event received and input invalid`() =
runTest {
val viewModel = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(
serverAnswer = ValidationResult.Failure(TestError),
),
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnNextClicked)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(
State(
server = StringInputField(value = "", error = TestError, isValid = false),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
),
)
}
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
@ -198,4 +251,6 @@ class AccountIncomingConfigViewModelTest {
isEqualTo(Effect.NavigateBack)
}
}
private object TestError : ValidationError
}

View file

@ -0,0 +1,17 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
class FakeAccountIncomingConfigValidator(
private val serverAnswer: ValidationResult = ValidationResult.Success,
private val portAnswer: ValidationResult = ValidationResult.Success,
private val usernameAnswer: ValidationResult = ValidationResult.Success,
private val passwordAnswer: ValidationResult = ValidationResult.Success,
private val imapPrefixAnswer: ValidationResult = ValidationResult.Success,
) : AccountIncomingConfigContract.Validator {
override fun validateServer(server: String): ValidationResult = serverAnswer
override fun validatePort(port: Long?): ValidationResult = portAnswer
override fun validateUsername(username: String): ValidationResult = usernameAnswer
override fun validatePassword(password: String): ValidationResult = passwordAnswer
override fun validateImapPrefix(imapPrefix: String): ValidationResult = imapPrefixAnswer
}