test(settings): ChangePasswordViewModelTest — validation + typed exception mapping

This commit is contained in:
null 2026-07-01 02:19:31 -05:00
parent e585856228
commit cbffaeca9f
1 changed files with 132 additions and 0 deletions

View File

@ -0,0 +1,132 @@
package app.closer.ui.settings
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.ChangePasswordException
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Covers [ChangePasswordViewModel]: client-side validation and the typed-exception user-copy mapping
* for the reauth-gated password change.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ChangePasswordViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val authRepository: AuthRepository = mockk()
@Before fun setUp() = Dispatchers.setMain(dispatcher)
@After fun tearDown() = Dispatchers.resetMain()
private fun vm() = ChangePasswordViewModel(authRepository)
private fun ChangePasswordViewModel.enter(current: String, new: String, confirm: String) {
updateCurrent(current); updateNew(new); updateConfirm(confirm)
}
// ---- validation (runs before any repository call) ----
@Test fun `blank current password is rejected`() {
val vm = vm(); vm.enter("", "newpass1", "newpass1"); vm.submit()
assertEquals("Enter your current password.", vm.uiState.value.error)
}
@Test fun `short new password is rejected`() {
val vm = vm(); vm.enter("oldpass1", "ab1", "ab1"); vm.submit()
assertEquals("New password must be at least 8 characters.", vm.uiState.value.error)
}
@Test fun `new password without letters and numbers is rejected`() {
val vm = vm(); vm.enter("oldpass1", "abcdefgh", "abcdefgh"); vm.submit()
assertEquals("New password must include both letters and numbers.", vm.uiState.value.error)
}
@Test fun `mismatched confirmation is rejected`() {
val vm = vm(); vm.enter("oldpass1", "newpass1", "different1"); vm.submit()
assertEquals("New passwords don't match.", vm.uiState.value.error)
}
@Test fun `new password equal to current is rejected`() {
val vm = vm(); vm.enter("samepass1", "samepass1", "samepass1"); vm.submit()
assertEquals("New password must be different from your current one.", vm.uiState.value.error)
}
@Test fun `invalid input never calls the repository`() {
val vm = vm(); vm.enter("", "x", "y"); vm.submit()
coVerify(exactly = 0) { authRepository.changePassword(any(), any()) }
}
// ---- outcome mapping ----
private fun submitValid(result: Result<Unit>): ChangePasswordViewModel {
coEvery { authRepository.changePassword("oldpass1", "newpass1") } returns result
val vm = vm(); vm.enter("oldpass1", "newpass1", "newpass1"); vm.submit()
dispatcher.scheduler.advanceUntilIdle()
return vm
}
@Test fun `success sets done and clears the entered passwords`() = runTest(dispatcher) {
val vm = submitValid(Result.success(Unit))
val s = vm.uiState.value
assertTrue(s.done)
assertFalse(s.isLoading)
assertEquals("", s.currentPassword)
assertEquals("", s.newPassword)
assertEquals("", s.confirmPassword)
}
@Test fun `wrong current password maps to a clear message`() = runTest(dispatcher) {
assertEquals(
"Current password is incorrect.",
submitValid(Result.failure(ChangePasswordException.WrongCurrentPassword())).uiState.value.error
)
}
@Test fun `weak new password maps to guidance`() = runTest(dispatcher) {
assertEquals(
"That password is too weak — use at least 8 characters with letters and numbers.",
submitValid(Result.failure(ChangePasswordException.WeakNewPassword())).uiState.value.error
)
}
@Test fun `too many attempts maps to a back-off message`() = runTest(dispatcher) {
assertEquals(
"Too many attempts. Please wait a bit and try again.",
submitValid(Result.failure(ChangePasswordException.TooManyAttempts())).uiState.value.error
)
}
@Test fun `reauth required maps to a sign-in-again message`() = runTest(dispatcher) {
assertEquals(
"For your security, please sign out and back in, then change your password.",
submitValid(Result.failure(ChangePasswordException.ReauthRequired())).uiState.value.error
)
}
@Test fun `google-only account maps to the Google message`() = runTest(dispatcher) {
assertEquals(
"This account signs in with Google — there's no password to change.",
submitValid(Result.failure(ChangePasswordException.NoPassword())).uiState.value.error
)
}
@Test fun `unknown failure falls back to its own message`() = runTest(dispatcher) {
assertEquals(
"Boom",
submitValid(Result.failure(RuntimeException("Boom"))).uiState.value.error
)
}
}