package com.parseable.android import com.parseable.android.data.model.AboutInfo import com.parseable.android.data.model.ApiResult import com.parseable.android.data.repository.ParseableRepository import com.parseable.android.data.repository.SettingsRepository import com.parseable.android.ui.screens.login.LoginViewModel import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.* import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class LoginViewModelTest { private lateinit var repository: ParseableRepository private lateinit var settingsRepository: SettingsRepository private lateinit var viewModel: LoginViewModel private val testDispatcher = UnconfinedTestDispatcher() @Before fun setup() { Dispatchers.setMain(testDispatcher) repository = mockk(relaxed = true) settingsRepository = mockk(relaxed = true) coEvery { settingsRepository.serverConfig } returns flowOf(null) viewModel = LoginViewModel(repository, settingsRepository) } @After fun tearDown() { Dispatchers.resetMain() } /** * Start collecting the one-shot login-success events into a list (on the eager * UnconfinedTestDispatcher) before the action under test, so the success emission is observed. * The collector lives in backgroundScope, which runTest auto-cancels. */ private fun TestScope.collectSuccessEvents(): MutableList { val events = mutableListOf() backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) } return events } @Test fun `onLogin validates required fields`() { viewModel.onLogin() val state = viewModel.state.value assertNotNull(state.serverUrlError) assertNotNull(state.usernameError) assertNotNull(state.passwordError) assertFalse(state.isLoading) } @Test fun `onLogin validates URL only when others filled`() { viewModel.onServerUrlChange("") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("password") viewModel.onLogin() val state = viewModel.state.value assertNotNull(state.serverUrlError) assertNull(state.usernameError) assertNull(state.passwordError) } @Test fun `onLogin succeeds with valid credentials`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0")) // Reach the success branch deterministically: the login-success event only fires when the // credentials were durably saved. coEvery { settingsRepository.saveServerConfig(any()) } returns true val events = collectSuccessEvents() viewModel.onServerUrlChange("https://test.parseable.com") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("password") viewModel.onLogin() val state = viewModel.state.value assertEquals(1, events.size) assertFalse(state.isLoading) coVerify { settingsRepository.saveServerConfig(any()) } } @Test fun `onLogin fails when connection fails`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0) val events = collectSuccessEvents() viewModel.onServerUrlChange("https://bad.server") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("password") viewModel.onLogin() val state = viewModel.state.value assertEquals(0, events.size) assertNotNull(state.error) assertTrue(state.error!!.contains("Connection failed")) } @Test fun `onLogin fails when about check fails`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404) val events = collectSuccessEvents() viewModel.onServerUrlChange("https://test.com") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("password") viewModel.onLogin() val state = viewModel.state.value assertEquals(0, events.size) assertNotNull(state.error) assertTrue(state.error!!.contains("doesn't appear to be Parseable")) } @Test fun `onLogin shows invalid credentials on 401`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401) val events = collectSuccessEvents() viewModel.onServerUrlChange("https://test.parseable.com") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("wrongpass") viewModel.onLogin() val state = viewModel.state.value assertEquals(0, events.size) assertNotNull(state.error) assertTrue(state.error!!.contains("Invalid credentials")) assertFalse(state.error!!.contains("doesn't appear to be Parseable")) } @Test fun `onServerUrlChange clears error`() { viewModel.onLogin() // triggers error assertNotNull(viewModel.state.value.serverUrlError) viewModel.onServerUrlChange("test") assertNull(viewModel.state.value.serverUrlError) assertNull(viewModel.state.value.error) } @Test fun `onAllowInsecureChange updates state`() { assertFalse(viewModel.state.value.allowInsecure) viewModel.onAllowInsecureChange(true) assertTrue(viewModel.state.value.allowInsecure) } @Test fun `does not auto-populate password from saved config`() = runTest { val config = com.parseable.android.data.model.ServerConfig( serverUrl = "https://test.com", username = "admin", password = "secret", useTls = true, ) coEvery { settingsRepository.serverConfig } returns flowOf(config) viewModel = LoginViewModel(repository, settingsRepository) val state = viewModel.state.value assertEquals("https://test.com", state.serverUrl) assertEquals("admin", state.username) assertEquals("", state.password) // Password should NOT be auto-populated assertTrue(state.hasSavedCredentials) } @Test fun `onLogin prepends https when not insecure`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo()) viewModel.onServerUrlChange("test.parseable.com") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("pass") viewModel.onLogin() coVerify { repository.configure(match { it.serverUrl == "https://test.parseable.com" }) } } @Test fun `onLogin prepends http when insecure`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo()) viewModel.onServerUrlChange("test.parseable.com") viewModel.onAllowInsecureChange(true) viewModel.onUsernameChange("admin") viewModel.onPasswordChange("pass") viewModel.onLogin() coVerify { repository.configure(match { it.serverUrl == "http://test.parseable.com" }) } } @Test fun `onLogin handles uppercase URL scheme`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo()) viewModel.onServerUrlChange("HTTPS://test.parseable.com") viewModel.onUsernameChange("admin") viewModel.onPasswordChange("pass") viewModel.onLogin() coVerify { repository.configure(match { it.serverUrl == "HTTPS://test.parseable.com" && it.useTls }) } } @Test fun `onLogin handles mixed case URL scheme`() = runTest { coEvery { repository.testConnection() } returns ApiResult.Success("ok") coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo()) viewModel.onServerUrlChange("Http://test.parseable.com") viewModel.onAllowInsecureChange(true) viewModel.onUsernameChange("admin") viewModel.onPasswordChange("pass") viewModel.onLogin() coVerify { repository.configure(match { it.serverUrl == "Http://test.parseable.com" && !it.useTls }) } } }