diff --git a/chat-engine b/chat-engine index 8139eaa..cdf3e1b 160000 --- a/chat-engine +++ b/chat-engine @@ -1 +1 @@ -Subproject commit 8139eaaf57cee4ce9d0616d617e8aff7eb1480e3 +Subproject commit cdf3e1bffba4b69dd8f752c6cc7588b0e89a17af diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 6d31a40..9cdd68f 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -4,7 +4,7 @@ import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.state.DirectoryState import app.dapk.st.domain.StoreModule import app.dapk.st.engine.ChatEngine -import app.dapk.st.login.LoginViewModel +import app.dapk.st.login.state.LoginState import app.dapk.st.profile.state.ProfileState class HomeModule( @@ -13,7 +13,7 @@ class HomeModule( val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, ) : ProvidableModule { - internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel { + internal fun homeViewModel(directory: DirectoryState, login: LoginState, profile: ProfileState): HomeViewModel { return HomeViewModel( chatEngine, directory, diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index aa3e247..30174fe 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -7,9 +7,7 @@ import app.dapk.st.directory.state.DirectoryState import app.dapk.st.domain.StoreCleaner import app.dapk.st.engine.ChatEngine import app.dapk.st.home.HomeScreenState.* -import app.dapk.st.login.LoginViewModel -import app.dapk.st.matrix.common.CredentialsStore -import app.dapk.st.matrix.common.isSignedIn +import app.dapk.st.login.state.LoginState import app.dapk.st.profile.state.ProfileAction import app.dapk.st.profile.state.ProfileState import app.dapk.st.viewmodel.DapkViewModel @@ -22,7 +20,7 @@ import kotlinx.coroutines.launch internal class HomeViewModel( private val chatEngine: ChatEngine, private val directoryState: DirectoryState, - private val loginViewModel: LoginViewModel, + private val loginState: LoginState, private val profileState: ProfileState, private val cacheCleaner: StoreCleaner, private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, @@ -33,7 +31,7 @@ internal class HomeViewModel( private var listenForInvitesJob: Job? = null fun directory() = directoryState - fun login() = loginViewModel + fun login() = loginState fun profile() = profileState fun start() { diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index 7bcb3e9..fb41b05 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.onEach class MainActivity : DapkActivity() { - private val directoryViewModel by state { module().directoryState() } - private val loginViewModel by viewModel { module().loginViewModel() } - private val profileViewModel by state { module().profileState() } - private val homeViewModel by viewModel { module().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) } + private val directoryState by state { module().directoryState() } + private val loginState by state { module().loginState() } + private val profileState by state { module().profileState() } + private val homeViewModel by viewModel { module().homeViewModel(directoryState, loginState, profileState) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/features/login/build.gradle b/features/login/build.gradle index 098fa20..e1d5d90 100644 --- a/features/login/build.gradle +++ b/features/login/build.gradle @@ -8,9 +8,17 @@ android { dependencies { implementation "chat-engine:chat-engine" + implementation 'screen-state:screen-android' + implementation project(":domains:android:compose-core") implementation project(":domains:android:push") implementation project(":domains:android:viewmodel") 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 c745f9f..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 @@ -3,7 +3,11 @@ package app.dapk.st.login 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 class LoginModule( private val chatEngine: ChatEngine, @@ -11,7 +15,10 @@ class LoginModule( private val errorTracker: ErrorTracker, ) : ProvidableModule { - fun loginViewModel(): LoginViewModel { - return LoginViewModel(chatEngine, pushModule.pushTokenRegistrar(), errorTracker) + fun loginState(): LoginState { + return createStateViewModel { + 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/LoginScreen.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt index fd56d73..643df37 100644 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt +++ b/features/login/src/main/kotlin/app/dapk/st/login/LoginScreen.kt @@ -33,15 +33,18 @@ import androidx.compose.ui.unit.sp import app.dapk.st.core.StartObserving import app.dapk.st.core.components.CenteredLoading import app.dapk.st.design.components.GenericError -import app.dapk.st.login.LoginEvent.LoginComplete -import app.dapk.st.login.LoginScreenState.* +import app.dapk.st.login.state.LoginAction +import app.dapk.st.login.state.LoginEvent.LoginComplete +import app.dapk.st.login.state.LoginEvent.WellKnownMissing +import app.dapk.st.login.state.LoginScreenState +import app.dapk.st.login.state.LoginState @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable -fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { - loginViewModel.ObserveEvents(onLoggedIn) +fun LoginScreen(loginState: LoginState, onLoggedIn: () -> Unit) { + loginState.ObserveEvents(onLoggedIn) LaunchedEffect(true) { - loginViewModel.start() + loginState.dispatch(LoginAction.ComponentLifecycle.Visible) } var userName by rememberSaveable { mutableStateOf("") } @@ -49,11 +52,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { var serverUrl by rememberSaveable { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current - when (val state = loginViewModel.state) { - is Error -> GenericError(cause = state.cause, action = { loginViewModel.start() }) - Loading -> CenteredLoading() + when (val content = loginState.current.content) { + is LoginScreenState.Content.Error -> GenericError(cause = content.cause, action = { loginState.dispatch(LoginAction.ComponentLifecycle.Visible) }) + LoginScreenState.Content.Loading -> CenteredLoading() - is Content -> + is LoginScreenState.Content.Idle -> { + val showServerUrl = loginState.current.showServerUrl Row { Spacer(modifier = Modifier.weight(0.1f)) Column( @@ -88,7 +92,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Email, imeAction = ImeAction.Next) ) - val canDoLoginAttempt = if (state.showServerUrl) { + val canDoLoginAttempt = if (showServerUrl) { userName.isNotEmpty() && password.isNotEmpty() && serverUrl.isNotEmpty() } else { userName.isNotEmpty() && password.isNotEmpty() @@ -106,12 +110,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { Icon(imageVector = Icons.Outlined.Lock, contentDescription = null) }, keyboardActions = KeyboardActions( - onDone = { loginViewModel.login(userName, password, serverUrl) }, + onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) }, onNext = { focusManager.moveFocus(FocusDirection.Down) }, ), keyboardOptions = KeyboardOptions( autoCorrect = false, - imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { state.showServerUrl } ?: ImeAction.None, + imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { showServerUrl } ?: ImeAction.None, keyboardType = KeyboardType.Password ), visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(), @@ -123,7 +127,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { } ) - if (state.showServerUrl) { + if (showServerUrl) { TextField( modifier = Modifier.fillMaxWidth(), value = serverUrl, @@ -133,7 +137,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { leadingIcon = { Icon(imageVector = Icons.Default.Web, contentDescription = null) }, - keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password, serverUrl) }), + keyboardActions = KeyboardActions(onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) }), keyboardOptions = KeyboardOptions( autoCorrect = false, imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None, @@ -148,7 +152,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { modifier = Modifier.fillMaxWidth(), onClick = { keyboardController?.hide() - loginViewModel.login(userName, password, serverUrl) + loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) }, enabled = canDoLoginAttempt ) { @@ -157,6 +161,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) { } Spacer(modifier = Modifier.weight(0.1f)) } + } } } @@ -183,13 +188,13 @@ private fun Modifier.autofill( } @Composable -private fun LoginViewModel.ObserveEvents(onLoggedIn: () -> Unit) { +private fun LoginState.ObserveEvents(onLoggedIn: () -> Unit) { val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { when (it) { LoginComplete -> onLoggedIn() - LoginEvent.WellKnownMissing -> { + WellKnownMissing -> { Toast.makeText(context, "Couldn't find the homeserver, please enter the server URL", Toast.LENGTH_LONG).show() } } diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt deleted file mode 100644 index f4d0d24..0000000 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.dapk.st.login - -sealed interface LoginScreenState { - - data class Content(val showServerUrl: Boolean) : LoginScreenState - object Loading : LoginScreenState - data class Error(val cause: Throwable) : LoginScreenState -} - -sealed interface LoginEvent { - object LoginComplete : LoginEvent - object WellKnownMissing : LoginEvent -} - diff --git a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt b/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt deleted file mode 100644 index c8efb13..0000000 --- a/features/login/src/main/kotlin/app/dapk/st/login/LoginViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -package app.dapk.st.login - -import androidx.lifecycle.viewModelScope -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.login.LoginEvent.LoginComplete -import app.dapk.st.login.LoginScreenState.* -import app.dapk.st.push.PushTokenRegistrar -import app.dapk.st.viewmodel.DapkViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch - -class LoginViewModel( - private val chatEngine: ChatEngine, - private val pushTokenRegistrar: PushTokenRegistrar, - private val errorTracker: ErrorTracker, -) : DapkViewModel( - initialState = Content(showServerUrl = false) -) { - - private var previousState: LoginScreenState? = null - - fun login(userName: String, password: String, serverUrl: String?) { - state = Loading - viewModelScope.launch { - logP("login") { - when (val result = chatEngine.login(LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) { - is LoginResult.Success -> { - runCatching { - listOf( - async { pushTokenRegistrar.registerCurrentToken() }, - async { preloadMe() }, - ).awaitAll() - } - _events.tryEmit(LoginComplete) - } - - is LoginResult.Error -> { - errorTracker.track(result.cause) - state = Error(result.cause) - } - - LoginResult.MissingWellKnown -> { - _events.tryEmit(LoginEvent.WellKnownMissing) - state = Content(showServerUrl = true) - } - } - } - } - } - - private suspend fun preloadMe() = chatEngine.me(forceRefresh = false) - - fun start() { - val showServerUrl = previousState?.let { it is Content && it.showServerUrl } ?: false - state = Content(showServerUrl = showServerUrl) - } -} - - -private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() } \ No newline at end of file diff --git a/features/login/src/main/kotlin/app/dapk/st/login/state/LoginAction.kt b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginAction.kt new file mode 100644 index 0000000..3689c66 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginAction.kt @@ -0,0 +1,15 @@ +package app.dapk.st.login.state + +import app.dapk.state.Action + +sealed interface LoginAction : Action { + + sealed interface ComponentLifecycle : LoginAction { + object Visible : ComponentLifecycle + } + + data class Login(val userName: String, val password: String, val serverUrl: String?) : LoginAction + + data class UpdateContent(val content: LoginScreenState.Content) : LoginAction + data class UpdateState(val state: LoginScreenState) : LoginAction +} \ 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 new file mode 100644 index 0000000..375fa54 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginReducer.kt @@ -0,0 +1,46 @@ +package app.dapk.st.login.state + +import app.dapk.st.core.logP +import app.dapk.st.engine.LoginRequest +import app.dapk.st.engine.LoginResult +import app.dapk.state.async +import app.dapk.state.change +import app.dapk.state.createReducer +import kotlinx.coroutines.launch + +fun loginReducer( + loginUseCase: LoginUseCase, + eventEmitter: suspend (LoginEvent) -> Unit, +) = createReducer( + initialState = LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle), + + change(LoginAction.ComponentLifecycle.Visible::class) { _, state -> + LoginScreenState(state.showServerUrl, content = LoginScreenState.Content.Idle) + }, + + change(LoginAction.UpdateContent::class) { action, state -> state.copy(content = action.content) }, + + 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 = loginUseCase.login(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 fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() } diff --git a/features/login/src/main/kotlin/app/dapk/st/login/state/LoginState.kt b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginState.kt new file mode 100644 index 0000000..47a6163 --- /dev/null +++ b/features/login/src/main/kotlin/app/dapk/st/login/state/LoginState.kt @@ -0,0 +1,22 @@ +package app.dapk.st.login.state + +import app.dapk.st.state.State + +typealias LoginState = State + +data class LoginScreenState( + val showServerUrl: Boolean, + val content: Content, +) { + + sealed interface Content { + object Idle : Content + object Loading : Content + data class Error(val cause: Throwable) : Content + } +} + +sealed interface LoginEvent { + object LoginComplete : LoginEvent + object WellKnownMissing : LoginEvent +} 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..8445683 --- /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 login(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/LoginUseCaseTest.kt b/features/login/src/test/kotlin/app/dapk/st/login/state/LoginUseCaseTest.kt new file mode 100644 index 0000000..a4ddf7f --- /dev/null +++ b/features/login/src/test/kotlin/app/dapk/st/login/state/LoginUseCaseTest.kt @@ -0,0 +1,72 @@ +package app.dapk.st.login.state + +import app.dapk.st.engine.LoginRequest +import app.dapk.st.engine.LoginResult +import app.dapk.st.matrix.common.DeviceId +import app.dapk.st.matrix.common.HomeServerUrl +import app.dapk.st.matrix.common.UserCredentials +import app.dapk.st.push.PushTokenRegistrar +import fake.FakeChatEngine +import fake.FakeErrorTracker +import fixture.aUserId +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.expect + +private val A_LOGIN_ERROR = LoginResult.Error(RuntimeException()) +private val A_LOGIN_SUCCESS = LoginResult.Success(aUserCredentials()) + +private val A_LOGIN_REQUEST = LoginRequest( + userName = "a-username", + password = "a-password", + serverUrl = "a-server-url", +) + +class LoginUseCaseTest { + + private val fakeChatEngine = FakeChatEngine() + private val fakePushTokenRegistrar = FakePushTokenRegistrar() + private val fakeErrorTracker = FakeErrorTracker() + + private val useCase = LoginUseCase( + fakeChatEngine, + fakePushTokenRegistrar, + fakeErrorTracker, + ) + + @Test + fun `when logging in succeeds, then registers push token and preload me`() = runTest { + fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(A_LOGIN_SUCCESS) + fakePushTokenRegistrar.expect { it.registerCurrentToken() } + fakeChatEngine.expect { it.me(forceRefresh = false) } + + val result = useCase.login(A_LOGIN_REQUEST) + + result shouldBeEqualTo A_LOGIN_SUCCESS + } + + @Test + fun `when logging in fails with MissingWellKnown, then does nothing`() = runTest { + fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(LoginResult.MissingWellKnown) + + val result = useCase.login(A_LOGIN_REQUEST) + + result shouldBeEqualTo LoginResult.MissingWellKnown + } + + @Test + fun `when logging in errors, then tracks cause`() = runTest { + fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(A_LOGIN_ERROR) + fakeErrorTracker.expect { it.track(A_LOGIN_ERROR.cause) } + + val result = useCase.login(A_LOGIN_REQUEST) + + result shouldBeEqualTo A_LOGIN_ERROR + } +} + +class FakePushTokenRegistrar : PushTokenRegistrar by mockk() + +private fun aUserCredentials() = UserCredentials("ignored", HomeServerUrl("ignored"), aUserId(), DeviceId("ignored")) 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..eab9708 --- /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.login(loginRequest) }.delegateReturn() + +} \ No newline at end of file