git.iko.soy/parseable-android
on master help sign in
253 lines 9.1 kB history raw
1package com.parseable.android
3import com.parseable.android.data.model.AboutInfo
4import com.parseable.android.data.model.ApiResult
5import com.parseable.android.data.repository.ParseableRepository
6import com.parseable.android.data.repository.SettingsRepository
7import com.parseable.android.ui.screens.login.LoginViewModel
8import io.mockk.*
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.flow.flowOf
12import kotlinx.coroutines.flow.toList
13import kotlinx.coroutines.launch
14import kotlinx.coroutines.test.*
15import org.junit.After
16import org.junit.Assert.*
17import org.junit.Before
18import org.junit.Test
20@OptIn(ExperimentalCoroutinesApi::class)
21class LoginViewModelTest {
23 private lateinit var repository: ParseableRepository
24 private lateinit var settingsRepository: SettingsRepository
25 private lateinit var viewModel: LoginViewModel
26 private val testDispatcher = UnconfinedTestDispatcher()
28 @Before
29 fun setup() {
30 Dispatchers.setMain(testDispatcher)
31 repository = mockk(relaxed = true)
32 settingsRepository = mockk(relaxed = true)
33 coEvery { settingsRepository.serverConfig } returns flowOf(null)
34 viewModel = LoginViewModel(repository, settingsRepository)
35 }
37 @After
38 fun tearDown() {
39 Dispatchers.resetMain()
40 }
42 /**
43 * Start collecting the one-shot login-success events into a list (on the eager
44 * UnconfinedTestDispatcher) before the action under test, so the success emission is observed.
45 * The collector lives in backgroundScope, which runTest auto-cancels.
46 */
47 private fun TestScope.collectSuccessEvents(): MutableList<Unit> {
48 val events = mutableListOf<Unit>()
49 backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) }
50 return events
51 }
53 @Test
54 fun `onLogin validates required fields`() {
55 viewModel.onLogin()
57 val state = viewModel.state.value
58 assertNotNull(state.serverUrlError)
59 assertNotNull(state.usernameError)
60 assertNotNull(state.passwordError)
61 assertFalse(state.isLoading)
62 }
64 @Test
65 fun `onLogin validates URL only when others filled`() {
66 viewModel.onServerUrlChange("")
67 viewModel.onUsernameChange("admin")
68 viewModel.onPasswordChange("password")
69 viewModel.onLogin()
71 val state = viewModel.state.value
72 assertNotNull(state.serverUrlError)
73 assertNull(state.usernameError)
74 assertNull(state.passwordError)
75 }
77 @Test
78 fun `onLogin succeeds with valid credentials`() = runTest {
79 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
80 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81 // Reach the success branch deterministically: the login-success event only fires when the
82 // credentials were durably saved.
83 coEvery { settingsRepository.saveServerConfig(any()) } returns true
85 val events = collectSuccessEvents()
87 viewModel.onServerUrlChange("https://test.parseable.com")
88 viewModel.onUsernameChange("admin")
89 viewModel.onPasswordChange("password")
90 viewModel.onLogin()
92 val state = viewModel.state.value
93 assertEquals(1, events.size)
94 assertFalse(state.isLoading)
95 coVerify { settingsRepository.saveServerConfig(any()) }
96 }
98 @Test
99 fun `onLogin fails when connection fails`() = runTest {
100 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
102 val events = collectSuccessEvents()
104 viewModel.onServerUrlChange("https://bad.server")
105 viewModel.onUsernameChange("admin")
106 viewModel.onPasswordChange("password")
107 viewModel.onLogin()
109 val state = viewModel.state.value
110 assertEquals(0, events.size)
111 assertNotNull(state.error)
112 assertTrue(state.error!!.contains("Connection failed"))
113 }
115 @Test
116 fun `onLogin fails when about check fails`() = runTest {
117 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
118 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
120 val events = collectSuccessEvents()
122 viewModel.onServerUrlChange("https://test.com")
123 viewModel.onUsernameChange("admin")
124 viewModel.onPasswordChange("password")
125 viewModel.onLogin()
127 val state = viewModel.state.value
128 assertEquals(0, events.size)
129 assertNotNull(state.error)
130 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
131 }
133 @Test
134 fun `onLogin shows invalid credentials on 401`() = runTest {
135 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
138 val events = collectSuccessEvents()
140 viewModel.onServerUrlChange("https://test.parseable.com")
141 viewModel.onUsernameChange("admin")
142 viewModel.onPasswordChange("wrongpass")
143 viewModel.onLogin()
145 val state = viewModel.state.value
146 assertEquals(0, events.size)
147 assertNotNull(state.error)
148 assertTrue(state.error!!.contains("Invalid credentials"))
149 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
150 }
152 @Test
153 fun `onServerUrlChange clears error`() {
154 viewModel.onLogin() // triggers error
155 assertNotNull(viewModel.state.value.serverUrlError)
157 viewModel.onServerUrlChange("test")
158 assertNull(viewModel.state.value.serverUrlError)
159 assertNull(viewModel.state.value.error)
160 }
162 @Test
163 fun `onAllowInsecureChange updates state`() {
164 assertFalse(viewModel.state.value.allowInsecure)
165 viewModel.onAllowInsecureChange(true)
166 assertTrue(viewModel.state.value.allowInsecure)
167 }
169 @Test
170 fun `does not auto-populate password from saved config`() = runTest {
171 val config = com.parseable.android.data.model.ServerConfig(
172 serverUrl = "https://test.com",
173 username = "admin",
174 password = "secret",
175 useTls = true,
176 )
177 coEvery { settingsRepository.serverConfig } returns flowOf(config)
179 viewModel = LoginViewModel(repository, settingsRepository)
181 val state = viewModel.state.value
182 assertEquals("https://test.com", state.serverUrl)
183 assertEquals("admin", state.username)
184 assertEquals("", state.password) // Password should NOT be auto-populated
185 assertTrue(state.hasSavedCredentials)
186 }
188 @Test
189 fun `onLogin prepends https when not insecure`() = runTest {
190 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
191 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
193 viewModel.onServerUrlChange("test.parseable.com")
194 viewModel.onUsernameChange("admin")
195 viewModel.onPasswordChange("pass")
196 viewModel.onLogin()
198 coVerify {
199 repository.configure(match { it.serverUrl == "https://test.parseable.com" })
200 }
201 }
203 @Test
204 fun `onLogin prepends http when insecure`() = runTest {
205 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
206 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
208 viewModel.onServerUrlChange("test.parseable.com")
209 viewModel.onAllowInsecureChange(true)
210 viewModel.onUsernameChange("admin")
211 viewModel.onPasswordChange("pass")
212 viewModel.onLogin()
214 coVerify {
215 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
216 }
217 }
219 @Test
220 fun `onLogin handles uppercase URL scheme`() = runTest {
221 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
222 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
224 viewModel.onServerUrlChange("HTTPS://test.parseable.com")
225 viewModel.onUsernameChange("admin")
226 viewModel.onPasswordChange("pass")
227 viewModel.onLogin()
229 coVerify {
230 repository.configure(match {
231 it.serverUrl == "HTTPS://test.parseable.com" && it.useTls
232 })
233 }
234 }
236 @Test
237 fun `onLogin handles mixed case URL scheme`() = runTest {
238 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
239 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
241 viewModel.onServerUrlChange("Http://test.parseable.com")
242 viewModel.onAllowInsecureChange(true)
243 viewModel.onUsernameChange("admin")
244 viewModel.onPasswordChange("pass")
245 viewModel.onLogin()
247 coVerify {
248 repository.configure(match {
249 it.serverUrl == "Http://test.parseable.com" && !it.useTls
250 })
251 }
252 }