Merge pull request #7091 from thundernest/add_config_validation_screen

Add config validation screen
This commit is contained in:
Wolf-Martell Montwé 2023-07-27 18:20:52 +00:00 committed by GitHub
commit 6b3c7a8f57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 832 additions and 29 deletions

View file

@ -7,17 +7,17 @@ import assertk.Assert
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
/**
* The `turbines` extension function creates a MviTurbines instance for the given MVI ViewModel.
*/
inline fun <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT, EFFECT>.turbines(
coroutineScope: CoroutineScope,
inline fun <reified STATE, EVENT, EFFECT> TestScope.turbines(
viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
): MviTurbines<STATE, EFFECT> {
return MviTurbines(
stateTurbine = state.testIn(coroutineScope),
effectTurbine = effect.testIn(coroutineScope),
stateTurbine = viewModel.state.testIn(backgroundScope),
effectTurbine = viewModel.effect.testIn(backgroundScope),
)
}
@ -25,15 +25,11 @@ inline fun <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT,
* The `turbinesWithInitialStateCheck` extension function creates a MviTurbines instance for the given MVI ViewModel
* and ensures that the initial state is emitted.
*/
suspend inline fun <reified STATE, EVENT, EFFECT>
UnidirectionalViewModel<STATE, EVENT, EFFECT>.turbinesWithInitialStateCheck(
coroutineScope: CoroutineScope,
initialState: STATE,
): MviTurbines<STATE, EFFECT> {
val turbines = MviTurbines(
stateTurbine = state.testIn(coroutineScope),
effectTurbine = effect.testIn(coroutineScope),
)
suspend inline fun <reified STATE, EVENT, EFFECT> TestScope.turbinesWithInitialStateCheck(
viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
initialState: STATE,
): MviTurbines<STATE, EFFECT> {
val turbines = turbines(viewModel)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),

View file

@ -8,6 +8,7 @@ import app.k9mail.feature.account.setup.domain.usecase.CheckIncomingServerConfig
import app.k9mail.feature.account.setup.domain.usecase.CheckOutgoingServerConfig
import app.k9mail.feature.account.setup.domain.usecase.CreateAccount
import app.k9mail.feature.account.setup.domain.usecase.GetAutoDiscovery
import app.k9mail.feature.account.setup.domain.usecase.ValidateServerSettings
import app.k9mail.feature.account.setup.ui.AccountSetupViewModel
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryValidator
@ -21,6 +22,7 @@ import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigValidator
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigViewModel
import app.k9mail.feature.account.setup.ui.validation.AccountValidationViewModel
import com.fsck.k9.mail.store.imap.ImapServerSettingsValidator
import com.fsck.k9.mail.store.pop3.Pop3ServerSettingsValidator
import com.fsck.k9.mail.transport.smtp.SmtpServerSettingsValidator
@ -49,6 +51,23 @@ val featureAccountSetupModule: Module = module {
)
}
factory<DomainContract.UseCase.ValidateServerSettings> {
ValidateServerSettings(
imapValidator = ImapServerSettingsValidator(
trustedSocketFactory = get(),
oAuth2TokenProvider = null, // TODO
clientIdAppName = "null",
),
pop3Validator = Pop3ServerSettingsValidator(
trustedSocketFactory = get(),
),
smtpValidator = SmtpServerSettingsValidator(
trustedSocketFactory = get(),
oAuth2TokenProvider = null, // TODO
),
)
}
factory<DomainContract.UseCase.CheckIncomingServerConfig> {
CheckIncomingServerConfig(
imapValidator = ImapServerSettingsValidator(
@ -91,6 +110,11 @@ val featureAccountSetupModule: Module = module {
getAutoDiscovery = get(),
)
}
viewModel {
AccountValidationViewModel(
validateServerSettings = get(),
)
}
viewModel {
AccountIncomingConfigViewModel(
validator = get(),

View file

@ -7,23 +7,23 @@ import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContrac
internal fun Error.toResourceString(resources: Resources): String {
return when (this) {
is Error.AuthenticationError -> resources.getString(
R.string.account_setup_check_config_error_authentication,
R.string.account_setup_settings_validation_error_authentication,
)
is Error.CertificateError -> resources.getString(
R.string.account_setup_check_config_error_certificate,
R.string.account_setup_settings_validation_error_certificate,
)
is Error.NetworkError -> resources.getString(
R.string.account_setup_check_config_error_network,
R.string.account_setup_settings_validation_error_network,
)
is Error.ServerError -> resources.getString(
R.string.account_setup_check_config_error_server,
R.string.account_setup_settings_validation_error_server,
)
is Error.UnknownError -> resources.getString(
R.string.account_setup_check_config_error_unknown,
R.string.account_setup_settings_validation_error_unknown,
)
}
}

View file

@ -7,23 +7,23 @@ import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContrac
internal fun Error.toResourceString(resources: Resources): String {
return when (this) {
is Error.AuthenticationError -> resources.getString(
R.string.account_setup_check_config_error_authentication,
R.string.account_setup_settings_validation_error_authentication,
)
is Error.CertificateError -> resources.getString(
R.string.account_setup_check_config_error_certificate,
R.string.account_setup_settings_validation_error_certificate,
)
is Error.NetworkError -> resources.getString(
R.string.account_setup_check_config_error_network,
R.string.account_setup_settings_validation_error_network,
)
is Error.ServerError -> resources.getString(
R.string.account_setup_check_config_error_server,
R.string.account_setup_settings_validation_error_server,
)
is Error.UnknownError -> resources.getString(
R.string.account_setup_check_config_error_unknown,
R.string.account_setup_settings_validation_error_unknown,
)
}
}

View file

@ -0,0 +1,109 @@
package app.k9mail.feature.account.setup.ui.validation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.common.DevicePreviews
import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.item.ErrorItem
import app.k9mail.feature.account.common.ui.item.LoadingItem
import app.k9mail.feature.account.common.ui.item.SuccessItem
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
@Suppress("LongMethod")
@Composable
internal fun AccountValidationContent(
state: State,
onEvent: (Event) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
ResponsiveWidthContainer(
modifier = Modifier
.testTag("AccountIncomingConfigContent")
.padding(contentPadding)
.fillMaxWidth()
.then(modifier),
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.Start,
verticalArrangement = if (state.isLoading || state.error != null || state.isSuccess) {
Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically)
} else {
Arrangement.spacedBy(MainTheme.spacings.default)
},
) {
if (state.isLoading) {
item(key = "loading") {
LoadingItem(
message = stringResource(id = R.string.account_setup_incoming_config_loading_message),
)
}
} else if (state.error != null) {
item(key = "error") {
// TODO add raw error message
ErrorItem(
title = stringResource(id = R.string.account_setup_incoming_config_loading_error),
message = state.error.toResourceString(resources),
onRetry = { onEvent(Event.OnRetryClicked) },
)
}
} else if (state.isSuccess) {
item(key = "success") {
SuccessItem(
message = stringResource(id = R.string.account_setup_incoming_config_success),
)
}
} else {
item {
TextSubtitle1(text = "Should not happen")
}
}
}
}
}
@Composable
@DevicePreviews
internal fun AccountIncomingConfigContentK9Preview() {
K9Theme {
AccountValidationContent(
onEvent = { },
state = State(),
contentPadding = PaddingValues(),
)
}
}
@Composable
@DevicePreviews
internal fun AccountIncomingConfigContentThunderbirdPreview() {
ThunderbirdTheme {
AccountValidationContent(
onEvent = { },
state = State(),
contentPadding = PaddingValues(),
)
}
}

View file

@ -0,0 +1,46 @@
package app.k9mail.feature.account.setup.ui.validation
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import com.fsck.k9.mail.ServerSettings
import java.io.IOException
import java.security.cert.X509Certificate
interface AccountValidationContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
fun initState(state: State)
}
data class State(
val serverSettings: ServerSettings? = null,
val authorizationState: AuthorizationState? = null,
val wizardNavigationBarState: WizardNavigationBarState = WizardNavigationBarState(
showNext = false,
),
val isSuccess: Boolean = false,
val error: Error? = null,
val isLoading: Boolean = false,
)
sealed interface Event {
object ValidateServerSettings : Event
object OnNextClicked : Event
object OnBackClicked : Event
object OnRetryClicked : Event
}
sealed interface Effect {
object NavigateNext : Effect
object NavigateBack : Effect
}
sealed interface Error {
data class NetworkError(val exception: IOException) : Error
data class CertificateError(val certificateChain: List<X509Certificate>) : Error
data class AuthenticationError(val serverMessage: String?) : Error
data class ServerError(val serverMessage: String?) : Error
data class UnknownError(val message: String) : Error
}
}

View file

@ -0,0 +1,95 @@
package app.k9mail.feature.account.setup.ui.validation
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.common.DevicePreviews
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.AppTitleTopHeader
import app.k9mail.feature.account.common.ui.WizardNavigationBar
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Effect
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.ViewModel
import com.fsck.k9.mail.server.ServerSettingsValidationResult
@Composable
internal fun AccountValidationScreen(
onNext: () -> Unit,
onBack: () -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.NavigateNext -> onNext()
is Effect.NavigateBack -> onBack()
}
}
LaunchedEffect(key1 = Unit) {
dispatch(Event.ValidateServerSettings)
}
BackHandler {
dispatch(Event.OnBackClicked)
}
Scaffold(
topBar = {
AppTitleTopHeader(title = stringResource(id = R.string.account_setup_title))
},
bottomBar = {
WizardNavigationBar(
nextButtonText = stringResource(id = R.string.account_setup_button_next),
backButtonText = stringResource(id = R.string.account_setup_button_back),
onNextClick = { },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
modifier = modifier,
) { innerPadding ->
AccountValidationContent(
onEvent = { dispatch(it) },
state = state.value,
contentPadding = innerPadding,
)
}
}
@Composable
@DevicePreviews
internal fun AccountIncomingConfigScreenK9Preview() {
K9Theme {
AccountValidationScreen(
onNext = {},
onBack = {},
viewModel = AccountValidationViewModel(
validateServerSettings = {
ServerSettingsValidationResult.Success
},
),
)
}
}
@Composable
@DevicePreviews
internal fun AccountIncomingConfigScreenThunderbirdPreview() {
ThunderbirdTheme {
AccountValidationScreen(
onNext = {},
onBack = {},
viewModel = AccountValidationViewModel(
validateServerSettings = {
ServerSettingsValidationResult.Success
},
),
)
}
}

View file

@ -0,0 +1,29 @@
package app.k9mail.feature.account.setup.ui.validation
import android.content.res.Resources
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Error
internal fun Error.toResourceString(resources: Resources): String {
return when (this) {
is Error.AuthenticationError -> resources.getString(
R.string.account_setup_settings_validation_error_authentication,
)
is Error.CertificateError -> resources.getString(
R.string.account_setup_settings_validation_error_certificate,
)
is Error.NetworkError -> resources.getString(
R.string.account_setup_settings_validation_error_network,
)
is Error.ServerError -> resources.getString(
R.string.account_setup_settings_validation_error_server,
)
is Error.UnknownError -> resources.getString(
R.string.account_setup_settings_validation_error_unknown,
)
}
}

View file

@ -0,0 +1,143 @@
package app.k9mail.feature.account.setup.ui.validation
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.domain.DomainContract
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Effect
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Error
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.ViewModel
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val CONTINUE_NEXT_DELAY = 1000L
internal class AccountValidationViewModel(
initialState: State = State(),
private val validateServerSettings: DomainContract.UseCase.ValidateServerSettings,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
updateState {
state.copy(
isLoading = false,
error = null,
isSuccess = false,
)
}
}
override fun event(event: Event) {
when (event) {
Event.ValidateServerSettings -> onValidateConfig()
Event.OnNextClicked -> TODO()
Event.OnBackClicked -> onBack()
Event.OnRetryClicked -> onRetry()
}
}
private fun onValidateConfig() {
if (state.value.isSuccess) {
navigateNext()
} else {
validateServerSettings()
}
}
private fun validateServerSettings() {
viewModelScope.launch {
val serverSettings = state.value.serverSettings
if (serverSettings == null) {
updateError(Error.UnknownError("Server settings not set"))
return@launch
}
updateState {
it.copy(isLoading = true)
}
when (val result = validateServerSettings.execute(serverSettings)) {
ServerSettingsValidationResult.Success -> updateSuccess()
is ServerSettingsValidationResult.AuthenticationError -> updateError(
Error.AuthenticationError(result.serverMessage),
)
is ServerSettingsValidationResult.CertificateError -> updateError(
Error.CertificateError(result.certificateChain),
)
is ServerSettingsValidationResult.NetworkError -> updateError(
Error.NetworkError(result.exception),
)
is ServerSettingsValidationResult.ServerError -> updateError(
Error.ServerError(result.serverMessage),
)
is ServerSettingsValidationResult.UnknownError -> updateError(
Error.UnknownError(result.exception.message ?: "Unknown error"),
)
}
}
}
private fun updateSuccess() {
updateState {
it.copy(
isLoading = false,
isSuccess = true,
)
}
viewModelScope.launch {
delay(CONTINUE_NEXT_DELAY)
navigateNext()
}
}
private fun updateError(error: Error) {
updateState {
it.copy(
error = error,
isLoading = false,
isSuccess = false,
)
}
}
private fun onBack() {
if (state.value.isSuccess) {
updateState {
it.copy(
isSuccess = false,
)
}
} else if (state.value.error != null) {
updateState {
it.copy(
error = null,
)
}
} else {
navigateBack()
}
}
private fun onRetry() {
updateState {
it.copy(
error = null,
)
}
onValidateConfig()
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() {
emitEffect(Effect.NavigateNext)
}
}

View file

@ -27,11 +27,11 @@
<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_check_config_error_authentication">Authentication error</string>
<string name="account_setup_check_config_error_certificate">Certificate error</string>
<string name="account_setup_check_config_error_network">Network error</string>
<string name="account_setup_check_config_error_server">Server error</string>
<string name="account_setup_check_config_error_unknown">Unknown error</string>
<string name="account_setup_settings_validation_error_authentication">Authentication error</string>
<string name="account_setup_settings_validation_error_certificate">Certificate error</string>
<string name="account_setup_settings_validation_error_network">Network error</string>
<string name="account_setup_settings_validation_error_server">Server error</string>
<string name="account_setup_settings_validation_error_unknown">Unknown error</string>
<string name="account_setup_auto_config_loading_message">Finding email details</string>
<string name="account_setup_auto_config_loading_error">Failed to load email configuration</string>

View file

@ -8,6 +8,7 @@ import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryCon
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import okhttp3.OkHttpClient
import org.junit.Test
@ -44,6 +45,7 @@ class AccountSetupModuleKtTest : KoinTest {
extraTypes = listOf(
AccountSetupContract.State::class,
AccountAutoDiscoveryContract.State::class,
AccountValidationContract.State::class,
AccountIncomingConfigContract.State::class,
AccountOutgoingConfigContract.State::class,
AccountOptionsContract.State::class,

View file

@ -0,0 +1,45 @@
package app.k9mail.feature.account.setup.ui.validation
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Effect
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AccountValidationScreenKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountValidationViewModel(initialState)
var onNextCounter = 0
var onBackCounter = 0
setContent {
ThunderbirdTheme {
AccountValidationScreen(
onNext = { onNextCounter++ },
onBack = { onBackCounter++ },
viewModel = viewModel,
)
}
}
assertThat(onNextCounter).isEqualTo(0)
assertThat(onBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateNext)
assertThat(onNextCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateBack)
assertThat(onNextCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(1)
}
}

View file

@ -0,0 +1,28 @@
package app.k9mail.feature.account.setup.ui.validation
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class AccountValidationStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).isEqualTo(
State(
serverSettings = null,
authorizationState = null,
wizardNavigationBarState = WizardNavigationBarState(
showNext = false,
),
isSuccess = false,
error = null,
isLoading = false,
),
)
}
}

View file

@ -0,0 +1,260 @@
package app.k9mail.feature.account.setup.ui.validation
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed
import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Effect
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Error
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AccountValidationViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `should reset state when InitState event received`() = runTest {
val testSubject = createTestSubject()
val turbines = turbinesWithInitialStateCheck(testSubject, State())
val newState = State(
serverSettings = IMAP_SERVER_SETTINGS,
isLoading = true,
error = Error.ServerError("server error"),
isSuccess = true,
)
val expectedState = newState.copy(
isLoading = false,
error = null,
isSuccess = false,
)
testSubject.initState(newState)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(expectedState)
}
}
@Test
fun `should fail when ValidateServerSettings event received and server settings null`() = runTest {
val initialState = State()
val testSubject = createTestSubject()
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.ValidateServerSettings)
val errorState = initialState.copy(
error = Error.UnknownError("Server settings not set"),
)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(errorState)
}
}
@Test
fun `should validate server settings when ValidateServerSettings event received`() = runTest {
val initialState = State(
serverSettings = IMAP_SERVER_SETTINGS,
)
val testSubject = createTestSubject(
serverSettingsValidationResult = ServerSettingsValidationResult.Success,
initialState = initialState,
)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.ValidateServerSettings)
val loadingState = initialState.copy(isLoading = true)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState)
val successState = loadingState.copy(
isLoading = false,
isSuccess = true,
)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(successState)
assertThatAndMviTurbinesConsumed(
actual = turbines.effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
@Test
fun `should set error state when ValidateServerSettings received and check settings failed`() = runTest {
val initialState = State(
serverSettings = IMAP_SERVER_SETTINGS,
)
val testSubject = createTestSubject(
serverSettingsValidationResult = ServerSettingsValidationResult.ServerError("server error"),
initialState = initialState,
)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.ValidateServerSettings)
val loadingState = initialState.copy(isLoading = true)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState)
val failureState = loadingState.copy(
isLoading = false,
error = Error.ServerError("server error"),
)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should emit effect NavigateNext when ValidateConfig is successful`() = runTest {
val initialState = State(
serverSettings = IMAP_SERVER_SETTINGS,
isSuccess = true,
)
val testSubject = createTestSubject(
initialState = initialState,
)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.ValidateServerSettings)
assertThatAndMviTurbinesConsumed(
actual = turbines.effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
val testSubject = createTestSubject()
val turbines = turbinesWithInitialStateCheck(testSubject, State())
testSubject.event(Event.OnBackClicked)
assertThatAndMviTurbinesConsumed(
actual = turbines.effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateBack)
}
}
@Test
fun `should clear isSuccess when OnBackClicked event received when in success state`() = runTest {
val initialState = State(isSuccess = true)
val testSubject = createTestSubject(initialState = initialState)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.OnBackClicked)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState.copy(isSuccess = false))
}
}
@Test
fun `should clear error when OnBackClicked event received when in error state`() = runTest {
val initialState = State(error = Error.ServerError("server error"))
val testSubject = createTestSubject(initialState = initialState)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.OnBackClicked)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState.copy(error = null))
}
}
@Test
fun `should clear error and trigger check settings when OnRetryClicked event received`() = runTest {
val initialState = State(
serverSettings = IMAP_SERVER_SETTINGS,
error = Error.ServerError("server error"),
)
var checkSettingsCalled = false
val testSubject = AccountValidationViewModel(
validateServerSettings = {
delay(50)
checkSettingsCalled = true
ServerSettingsValidationResult.Success
},
initialState = initialState,
)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
testSubject.event(Event.OnRetryClicked)
val stateWithoutError = initialState.copy(error = null)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(stateWithoutError)
val loadingState = stateWithoutError.copy(isLoading = true)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState)
val successState = loadingState.copy(isLoading = false, isSuccess = true)
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(successState)
assertThat(checkSettingsCalled).isTrue()
assertThatAndMviTurbinesConsumed(
actual = turbines.effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
private companion object {
fun createTestSubject(
serverSettingsValidationResult: ServerSettingsValidationResult = ServerSettingsValidationResult.Success,
initialState: State = State(),
): AccountValidationViewModel {
return AccountValidationViewModel(
validateServerSettings = {
delay(50)
serverSettingsValidationResult
},
initialState = initialState,
)
}
val IMAP_SERVER_SETTINGS = ServerSettings(
type = "imap",
host = "imap.example.com",
port = 465,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "username",
password = "password",
clientCertificateAlias = null,
)
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.setup.ui.validation
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Effect
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.ViewModel
class FakeAccountValidationViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
val events = mutableListOf<Event>()
override fun initState(state: State) {
updateState { state }
}
override fun event(event: Event) {
events.add(event)
}
fun effect(effect: Effect) {
emitEffect(effect)
}
}