From cd860e9de3cff6cfaacf4d1bb867efa6d318735c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Jun 2023 15:18:23 +0200 Subject: [PATCH] Add test for ChangeAccountProviderPresenter and other presenters. --- .../ChangeAccountProviderFormPresenter.kt | 1 + .../ChangeAccountProviderStateProvider.kt | 8 + .../form/DefaultHomeserverResolver.kt | 120 +++++++ .../form/HomeserverData.kt | 2 +- .../form/HomeserverResolver.kt | 97 +----- .../ChangeAccountProviderFormPresenterTest.kt | 87 +++++ .../form/FakeHomeServerResolver.kt | 49 +++ .../LoginPasswordPresenterTest.kt | 161 +++++++++ .../login/impl/root/LoginRootPresenterTest.kt | 308 ------------------ .../components/form/TextFieldLocalState.kt | 2 +- 10 files changed, 432 insertions(+), 403 deletions(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/DefaultHomeserverResolver.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenterTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/FakeHomeServerResolver.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenterTest.kt delete mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenter.kt index 8262c35a27..ed8c98bb33 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenter.kt @@ -43,6 +43,7 @@ class ChangeAccountProviderFormPresenter @Inject constructor( fun handleEvents(event: ChangeAccountProviderFormEvents) { when (event) { is ChangeAccountProviderFormEvents.UserInput -> { + userInput.value = event.input localCoroutineScope.userInput(event.input) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderStateProvider.kt index c61e03cca3..6cc00dd0ee 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderStateProvider.kt @@ -51,3 +51,11 @@ fun aHomeserverDataList(): List { ) ) } + +fun aHomeserverData(): HomeserverData { + return HomeserverData( + userInput = "matrix", + homeserverUrl = "https://matrix.org", + isWellknownValid = true, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/DefaultHomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/DefaultHomeserverResolver.kt new file mode 100644 index 0000000000..812f98f323 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/DefaultHomeserverResolver.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.changeaccountprovider.form + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.impl.changeaccountprovider.form.network.WellknownRequest +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.core.uri.isValidUrl +import io.element.android.libraries.di.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Resolve homeserver base on search terms + */ +@ContributesBinding(AppScope::class) +class DefaultHomeserverResolver @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val wellknownRequest: WellknownRequest, +): HomeserverResolver { + private val mutableFlow: MutableStateFlow>> = MutableStateFlow(Async.Uninitialized) + + override fun flow(): StateFlow>> = mutableFlow + + private var currentJob: Job? = null + + override suspend fun accept(userInput: String) { + currentJob?.cancel() + val cleanedUpUserInput = userInput.trim() + mutableFlow.tryEmit(Async.Uninitialized) + if (cleanedUpUserInput.length > 3) { + delay(300) + mutableFlow.tryEmit(Async.Loading()) + withContext(dispatchers.io) { + val list = getUrlCandidate(cleanedUpUserInput) + currentJob = resolveList(userInput, list) + } + } + } + + private fun CoroutineScope.resolveList(userInput: String, list: List): Job { + val currentList = mutableListOf() + return launch { + list.map { + async { + val isValid = tryOrNull { wellknownRequest.execute(it) }.orFalse() + if (isValid) { + // Emit the list as soon as possible + currentList.add(HomeserverData(userInput, it, true)) + mutableFlow.tryEmit(Async.Success(currentList)) + } + } + }.joinAll() + .also { + // If list is empty, and the user as entered an URL, do not block the user. + if (currentList.isEmpty()) { + if (userInput.isValidUrl()) { + mutableFlow.tryEmit( + Async.Success( + listOf( + HomeserverData( + userInput = userInput, + homeserverUrl = userInput, + isWellknownValid = false + ) + ) + ) + ) + } else { + mutableFlow.tryEmit(Async.Uninitialized) + } + } + } + } + } + + private fun getUrlCandidate(data: String): List { + return buildList { + val s = data.ensureProtocol() + .removeSuffix("/") + + // Always try what the user has entered + add(s) + + if (s.contains(".")) { + // TLD detected? + } else { + add("$s.org") + add("$s.com") + add("$s.io") + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverData.kt index 6d254e3738..44dd583f00 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverData.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverData.kt @@ -16,7 +16,7 @@ package io.element.android.features.login.impl.changeaccountprovider.form -data class HomeserverData( +data class HomeserverData constructor( // What the user has entered val userInput: String, // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverResolver.kt index d80b20f1fd..a7abb444f6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverResolver.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/HomeserverResolver.kt @@ -16,102 +16,13 @@ package io.element.android.features.login.impl.changeaccountprovider.form -import io.element.android.features.login.impl.changeaccountprovider.form.network.WellknownRequest import io.element.android.libraries.architecture.Async -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.core.uri.isValidUrl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject /** - * Resolve homeserver base on search terms + * Resolve homeserver base on search terms. */ -class HomeserverResolver @Inject constructor( - private val dispatchers: CoroutineDispatchers, - private val wellknownRequest: WellknownRequest, -) { - private val mutableFlow: MutableStateFlow>> = MutableStateFlow(Async.Uninitialized) - - fun flow(): StateFlow>> = mutableFlow - - private var currentJob: Job? = null - - suspend fun accept(userInput: String) { - currentJob?.cancel() - val cleanedUpUserInput = userInput.trim() - mutableFlow.tryEmit(Async.Uninitialized) - if (cleanedUpUserInput.length > 3) { - delay(300) - mutableFlow.tryEmit(Async.Loading()) - withContext(dispatchers.io) { - val list = getUrlCandidate(cleanedUpUserInput) - currentJob = resolveList(userInput, list) - } - } - } - - private fun CoroutineScope.resolveList(userInput: String, list: List): Job { - val currentList = mutableListOf() - return launch { - list.map { - async { - val isValid = tryOrNull { wellknownRequest.execute(it) }.orFalse() - if (isValid) { - // Emit the list as soon as possible - currentList.add(HomeserverData(userInput, it, true)) - mutableFlow.tryEmit(Async.Success(currentList)) - } - } - }.joinAll() - .also { - // If list is empty, and the user as entered an URL, do not block the user. - if (currentList.isEmpty()) { - if (userInput.isValidUrl()) { - mutableFlow.tryEmit( - Async.Success( - listOf( - HomeserverData( - userInput = userInput, - homeserverUrl = userInput, - isWellknownValid = false - ) - ) - ) - ) - } else { - mutableFlow.tryEmit(Async.Uninitialized) - } - } - } - } - } - - private fun getUrlCandidate(data: String): List { - return buildList { - val s = data.ensureProtocol() - .removeSuffix("/") - - // Always try what the user has entered - add(s) - - if (s.contains(".")) { - // TLD detected? - } else { - add("$s.org") - add("$s.com") - add("$s.io") - } - } - } +interface HomeserverResolver { + fun flow(): StateFlow>> + suspend fun accept(userInput: String) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenterTest.kt new file mode 100644 index 0000000000..e2b2a8f546 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/ChangeAccountProviderFormPresenterTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.changeaccountprovider.form + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.Async +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeAccountProviderFormPresenterTest { + @Test + fun `present - initial state`() = runTest { + val homeServerResolver = FakeHomeServerResolver() + val presenter = ChangeAccountProviderFormPresenter( + homeServerResolver + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userInput).isEmpty() + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter text no result`() = runTest { + val homeServerResolver = FakeHomeServerResolver() + val presenter = ChangeAccountProviderFormPresenter( + homeServerResolver + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ChangeAccountProviderFormEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter text one then two results`() = runTest { + val homeServerResolver = FakeHomeServerResolver() + homeServerResolver.givenResult( + listOf( + listOf(aHomeserverData()), + listOf(aHomeserverData(), aHomeserverData()), + ) + ) + val presenter = ChangeAccountProviderFormPresenter( + homeServerResolver + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ChangeAccountProviderFormEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo(Async.Success(listOf(aHomeserverData()))) + assertThat(awaitItem().userInputResult).isEqualTo(Async.Success(listOf(aHomeserverData(), aHomeserverData()))) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/FakeHomeServerResolver.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/FakeHomeServerResolver.kt new file mode 100644 index 0000000000..db86e09903 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeaccountprovider/form/FakeHomeServerResolver.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.changeaccountprovider.form + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeHomeServerResolver : HomeserverResolver { + private var pendingResult: List> = emptyList() + fun givenResult(result: List>) { + pendingResult = result + } + + private val mutableFlow: MutableStateFlow>> = MutableStateFlow(Async.Uninitialized) + + override fun flow(): StateFlow>> = mutableFlow + + override suspend fun accept(userInput: String) { + mutableFlow.tryEmit(Async.Uninitialized) + delay(FAKE_DELAY_IN_MS) + mutableFlow.tryEmit(Async.Loading()) + // Sending the pending result + if (pendingResult.isEmpty()) { + mutableFlow.tryEmit(Async.Uninitialized) + } else { + pendingResult.forEach { + delay(FAKE_DELAY_IN_MS) + mutableFlow.tryEmit(Async.Success(it)) + } + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenterTest.kt new file mode 100644 index 0000000000..996f0b88bf --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenterTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.loginpassword + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.datasource.AccountProviderDataSource +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_PASSWORD +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoginPasswordPresenterTest { + @Test + fun `present - initial state`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider) + assertThat(initialState.formState).isEqualTo(LoginFormState.Default) + assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - enter login and password`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + val loginState = awaitItem() + assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) + assertThat(loginState.submitEnabled).isFalse() + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + val loginAndPasswordState = awaitItem() + assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) + assertThat(loginAndPasswordState.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID)) + } + } + + @Test + fun `present - submit with error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE)) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + // Check an error was returned + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE)) + // Assert the error is then cleared + loggedInState.eventSink(LoginPasswordEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt deleted file mode 100644 index 0dee8d47c0..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.login.impl.root - -import app.cash.molecule.RecompositionClock -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.api.oidc.OidcAction -import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow -import io.element.android.features.login.impl.util.LoginConstants -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails -import io.element.android.libraries.matrix.test.A_HOMESERVER -import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC -import io.element.android.libraries.matrix.test.A_PASSWORD -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class LoginRootPresenterTest { - @Test - fun `present - initial state`() = runTest { - val presenter = LoginRootPresenter( - FakeAuthenticationService(), - DefaultOidcActionFlow(), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - initial state server load`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - val loadingState = awaitItem() - assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading()) - authenticationService.givenHomeserver(A_HOMESERVER) - skipItems(1) - val loadedState = awaitItem() - assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER)) - } - } - - @Test - fun `present - initial state server load error and retry`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - val loadingState = awaitItem() - assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading()) - val aThrowable = Throwable("Error") - authenticationService.givenChangeServerError(aThrowable) - val errorState = awaitItem() - assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure(aThrowable)) - // Retry - errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo) - val loadingState2 = awaitItem() - assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading()) - authenticationService.givenChangeServerError(null) - authenticationService.givenHomeserver(A_HOMESERVER) - skipItems(1) - val loadedState = awaitItem() - assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER)) - } - } - - @Test - fun `present - enter login and password`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - val loginState = awaitItem() - assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) - assertThat(loginState.submitEnabled).isFalse() - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - val loginAndPasswordState = awaitItem() - assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) - assertThat(loginAndPasswordState.submitEnabled).isTrue() - } - } - - @Test - fun `present - oidc login`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA)) - } - } - - @Test - fun `present - oidc login error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - authenticationService.givenOidcError(A_THROWABLE) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - } - } - - @Test - fun `present - oidc custom tab login`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA)) - // Oidc cancel, sdk error - authenticationService.givenOidcCancelError(A_THROWABLE) - oidcActionFlow.post(OidcAction.GoBack) - val stateCancelSdkError = awaitItem() - assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - // Oidc cancel, sdk OK - authenticationService.givenOidcCancelError(null) - oidcActionFlow.post(OidcAction.GoBack) - val stateCancel = awaitItem() - assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - // Oidc success, sdk error - authenticationService.givenLoginError(A_THROWABLE) - oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url)) - val stateSuccessSdkErrorLoading = awaitItem() - assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val stateSuccessSdkError = awaitItem() - assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - // Oidc success - authenticationService.givenLoginError(null) - oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url)) - val stateSuccess = awaitItem() - assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val stateSuccessLoggedIn = awaitItem() - assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID)) - } - } - - @Test - fun `present - submit`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - skipItems(1) - val loginAndPasswordState = awaitItem() - loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) - val submitState = awaitItem() - assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID)) - } - } - - @Test - fun `present - submit with error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - skipItems(1) - val loginAndPasswordState = awaitItem() - authenticationService.givenLoginError(A_THROWABLE) - loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) - val submitState = awaitItem() - assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - } - } - - @Test - fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - // Submit will return an error - authenticationService.givenLoginError(A_THROWABLE) - initialState.eventSink(LoginRootEvents.Submit) - awaitItem() // Skip LoggingIn state - - // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - - // Assert the error is then cleared - submittedState.eventSink(LoginRootEvents.ClearError) - val clearedState = awaitItem() - assertThat(clearedState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt index c7366dcfb7..0de4dbba78 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt @@ -22,5 +22,5 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @Composable -public fun textFieldState(stateValue: String): MutableState = +fun textFieldState(stateValue: String): MutableState = remember(stateValue) { mutableStateOf(stateValue) }