From cbffaeca9f15d9ef46a5291a5f78be6eaa062556 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 1 Jul 2026 02:19:31 -0500 Subject: [PATCH] =?UTF-8?q?test(settings):=20ChangePasswordViewModelTest?= =?UTF-8?q?=20=E2=80=94=20validation=20+=20typed=20exception=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/ChangePasswordViewModelTest.kt | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 app/src/test/java/app/closer/ui/settings/ChangePasswordViewModelTest.kt diff --git a/app/src/test/java/app/closer/ui/settings/ChangePasswordViewModelTest.kt b/app/src/test/java/app/closer/ui/settings/ChangePasswordViewModelTest.kt new file mode 100644 index 00000000..057947f1 --- /dev/null +++ b/app/src/test/java/app/closer/ui/settings/ChangePasswordViewModelTest.kt @@ -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): 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 + ) + } +}