diff --git a/features/login/build.gradle b/features/login/build.gradle index 982b13f..e1d5d90 100644 --- a/features/login/build.gradle +++ b/features/login/build.gradle @@ -16,6 +16,9 @@ dependencies { implementation project(":design-library") implementation project(":core") + kotlinTest(it) + testImplementation 'screen-state:state-test' testImplementation 'chat-engine:chat-engine-test' + androidImportFixturesWorkaround(project, project(":core")) } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt index 3aa14c1..34251e7 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginModule.kt @@ -4,6 +4,7 @@ import app.dapk.st.core.ProvidableModule import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.engine.ChatEngine import app.dapk.st.login.state.LoginState +import app.dapk.st.login.state.LoginUseCase import app.dapk.st.login.state.loginReducer import app.dapk.st.push.PushModule import app.dapk.st.state.createStateViewModel @@ -16,7 +17,8 @@ class LoginModule( fun loginState(): LoginState { return createStateViewModel { - loginReducer(chatEngine, pushModule.pushTokenRegistrar(), errorTracker, it) + val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker) + loginReducer(loginUseCase, it) } } } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt index f9a4e7c..9adad2d 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt @@ -1,22 +1,15 @@ package app.dapk.st.login.state -import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.logP -import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.LoginRequest import app.dapk.st.engine.LoginResult -import app.dapk.st.push.PushTokenRegistrar import app.dapk.state.async import app.dapk.state.change import app.dapk.state.createReducer -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch fun loginReducer( - chatEngine: ChatEngine, - pushTokenRegistrar: PushTokenRegistrar, - errorTracker: ErrorTracker, + loginUseCase: LoginUseCase, eventEmitter: suspend (LoginEvent) -> Unit, ) = createReducer( initialState = LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle), @@ -25,45 +18,29 @@ fun loginReducer( LoginScreenState(state.showServerUrl, content = LoginScreenState.Content.Idle) }, - change(LoginAction.UpdateContent::class) { action, state -> - state.copy(content = action.content) - }, + change(LoginAction.UpdateContent::class) { action, state -> state.copy(content = action.content) }, - change(LoginAction.UpdateState::class) { action, _ -> - action.state - }, + change(LoginAction.UpdateState::class) { action, _ -> action.state }, async(LoginAction.Login::class) { action -> coroutineScope.launch { logP("login") { dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Loading)) val request = LoginRequest(action.userName, action.password, action.serverUrl.takeIfNotEmpty()) - when (val result = chatEngine.login(request)) { - is LoginResult.Success -> { - runCatching { - listOf( - async { pushTokenRegistrar.registerCurrentToken() }, - async { chatEngine.preloadMe() }, - ).awaitAll() - } - eventEmitter.invoke(LoginEvent.LoginComplete) - } - is LoginResult.Error -> { - errorTracker.track(result.cause) - dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Error(result.cause))) - } + when (val result = loginUseCase.run(request)) { + is LoginResult.Error -> dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Error(result.cause))) LoginResult.MissingWellKnown -> { eventEmitter.invoke(LoginEvent.WellKnownMissing) dispatch(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, content = LoginScreenState.Content.Idle))) } + + is LoginResult.Success -> eventEmitter.invoke(LoginEvent.LoginComplete) } } } }, ) -private suspend fun ChatEngine.preloadMe() = this.me(forceRefresh = false) - private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() } diff --git a/features/login/src/main/kotlin/app/dapk/st/login/state/LoginUseCase.kt b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginUseCase.kt new file mode 100644 index 0000000..66db1e8 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginUseCase.kt @@ -0,0 +1,44 @@ +package app.dapk.st.login.state + +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.core.logP +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.LoginRequest +import app.dapk.st.engine.LoginResult +import app.dapk.st.push.PushTokenRegistrar +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class LoginUseCase( + private val chatEngine: ChatEngine, + private val pushTokenRegistrar: PushTokenRegistrar, + private val errorTracker: ErrorTracker, +) { + suspend fun run(request: LoginRequest): LoginResult { + return logP("login") { + when (val result = chatEngine.login(request)) { + is LoginResult.Success -> { + coroutineScope { + runCatching { + listOf( + async { pushTokenRegistrar.registerCurrentToken() }, + async { chatEngine.preloadMe() }, + ).awaitAll() + } + result + } + } + + is LoginResult.Error -> { + errorTracker.track(result.cause) + result + } + + LoginResult.MissingWellKnown -> result + } + } + } + + private suspend fun ChatEngine.preloadMe() = this.me(forceRefresh = false) +} \ No newline at end of file diff --git a/features/login/src/test/kotlin/app/dapk/st/login/state/LoginReducerTest.kt b/features/login/src/test/kotlin/app/dapk/st/login/state/LoginReducerTest.kt new file mode 100644 index 0000000..8940a67 --- /dev/null +++ b/features/login/src/test/kotlin/app/dapk/st/login/state/LoginReducerTest.kt @@ -0,0 +1,103 @@ +package app.dapk.st.login.state + +import app.dapk.st.engine.LoginRequest +import app.dapk.st.engine.LoginResult +import app.dapk.st.login.state.fakes.FakeLoginUseCase +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserCredentials +import fixture.aUserId +import org.junit.Test +import test.assertDispatches +import test.assertEvents +import test.assertOnlyDispatches +import test.testReducer + +private val A_LOGIN_ACTION = LoginAction.Login( + userName = "a-username", + password = "a-password", + serverUrl = "a-server-url", +) + +private val AN_ERROR_CAUSE = RuntimeException() + +class LoginReducerTest { + + private val fakeLoginUseCase = FakeLoginUseCase() + + private val runReducerTest = testReducer { events: (LoginEvent) -> Unit -> + loginReducer(fakeLoginUseCase.instance, events) + } + + @Test + fun `initial state is idle without server url`() = runReducerTest { + assertInitialState(LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle)) + } + + @Test + fun `given non initial state, when Visible, then updates state to Idle with previous showServerUrl`() = runReducerTest { + setState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading)) + + reduce(LoginAction.ComponentLifecycle.Visible) + + assertOnlyStateChange(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Idle)) + } + + @Test + fun `when UpdateContent, then only updates content state`() = runReducerTest { + reduce(LoginAction.UpdateContent(LoginScreenState.Content.Loading)) + + assertOnlyStateChange { + it.copy(content = LoginScreenState.Content.Loading) + } + } + + @Test + fun `when UpdateState, then only updates state`() = runReducerTest { + reduce(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading))) + + assertOnlyStateChange(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading)) + } + + @Test + fun `given login errors, when Login, then updates content with loading and error`() = runReducerTest { + fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.Error(AN_ERROR_CAUSE)) + + reduce(A_LOGIN_ACTION) + + assertOnlyDispatches( + LoginAction.UpdateContent(LoginScreenState.Content.Loading), + LoginAction.UpdateContent(LoginScreenState.Content.Error(AN_ERROR_CAUSE)), + ) + } + + @Test + fun `given login fails with WellKnownMissing, when Login, then emits WellKnownMissing event and updates content with loading and Idle showing server url`() = + runReducerTest { + fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.MissingWellKnown) + + reduce(A_LOGIN_ACTION) + + assertDispatches( + LoginAction.UpdateContent(LoginScreenState.Content.Loading), + LoginAction.UpdateState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Idle)), + ) + assertEvents(LoginEvent.WellKnownMissing) + assertNoStateChange() + } + + @Test + fun `given login success, when Login, then emits LoginComplete event`() = runReducerTest { + fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.Success(aUserCredentials())) + + reduce(A_LOGIN_ACTION) + + assertDispatches(LoginAction.UpdateContent(LoginScreenState.Content.Loading)) + assertEvents(LoginEvent.LoginComplete) + assertNoStateChange() + } +} + +private fun LoginAction.Login.toRequest() = LoginRequest(this.userName, this.password, this.serverUrl) + +private fun aUserCredentials() = UserCredentials("ignored", HomeServerUrl("ignored"), aUserId(), DeviceId("ignored")) \ No newline at end of file diff --git a/features/login/src/test/kotlin/app/dapk/st/login/state/fakes/FakeLoginUseCase.kt b/features/login/src/test/kotlin/app/dapk/st/login/state/fakes/FakeLoginUseCase.kt new file mode 100644 index 0000000..6f4d991 --- /dev/null +++ b/features/login/src/test/kotlin/app/dapk/st/login/state/fakes/FakeLoginUseCase.kt @@ -0,0 +1,15 @@ +package app.dapk.st.login.state.fakes + +import app.dapk.st.engine.LoginRequest +import app.dapk.st.login.state.LoginUseCase +import io.mockk.coEvery +import io.mockk.mockk +import test.delegateReturn + +class FakeLoginUseCase { + + val instance = mockk() + + fun given(loginRequest: LoginRequest) = coEvery { instance.run(loginRequest) }.delegateReturn() + +} \ No newline at end of file