From 40cae1dd3d226ef7ebf3feb5db576371bcaa2d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 13:47:27 +0900 Subject: [PATCH] fix: connect password change after setting Nav3 foundation --- .../SettingImplementedCoverageAndroidTest.kt | 15 ++ .../repoimpl/account/AccountRepositoryImpl.kt | 2 +- .../account/AccountRepositoryImplTest.kt | 94 ++++++- .../repository/account/AccountRepository.kt | 10 + .../presentation/home/SettingScreen.kt | 5 +- .../navigation/SettingLocalNavActions.kt | 4 + .../navigation/SettingNavActions.kt | 2 + .../presentation/navigation/SettingNavHost.kt | 9 + .../presentation/navigation/SettingRoute.kt | 3 + .../password/PasswordChangeContent.kt | 139 ++++++++++ .../password/PasswordChangeFailureMessage.kt | 25 ++ .../password/PasswordChangeScreen.kt | 30 +++ .../password/PasswordChangeUiState.kt | 81 ++++++ .../password/PasswordChangeViewModel.kt | 96 +++++++ .../password/SettingPasswordRule.kt | 30 +++ .../src/main/res/values/strings.xml | 11 + .../setting/presentation/SettingFlowTest.kt | 25 +- .../navigation/SettingLocalNavActionsTest.kt | 3 +- .../SettingRouteSerializationTest.kt | 1 + .../password/PasswordChangeContentTest.kt | 142 ++++++++++ .../password/PasswordChangeViewModelTest.kt | 252 ++++++++++++++++++ 21 files changed, 974 insertions(+), 5 deletions(-) create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt index 2ef49200a4..98eeb6aaee 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt @@ -137,6 +137,21 @@ class SettingImplementedCoverageAndroidTest { composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) } + @Test + fun settingNavHost_passwordChangeEntryAndBackKeepSettingsHost() { + launchHost() + waitForSettingHomeContent() + + composeRule.onNodeWithText("비밀번호 변경").performScrollTo().performClick() + composeRule.onNodeWithText("현재 비밀번호").assertIsDisplayed() + composeRule.onNodeWithText("새 비밀번호").assertIsDisplayed() + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + + waitForSettingHomeContent() + composeRule.onNodeWithText("프로필 수정").assertIsDisplayed() + assertEquals(0, composeRule.runOnIdle { exits }) + } + @Test fun settingNavHost_receiverManageListRowNavigatesToEditWithExactReceiverId() { launchHost() diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt index 96fc4adcfc..7108fce92e 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt @@ -114,5 +114,5 @@ internal class AccountRepositoryImpl newPassword, ), ).requireStatus() - } + }.mapAccountFailure() } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt index 004c1fff40..bbeff858bd 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt @@ -26,6 +26,10 @@ import org.junit.Test * 번역해 Presentation 이 타입으로 분기(인라인 표시)할 수 있게 하고, 그 외 실패 * (네트워크·서버 오류 등)는 원본 예외를 유지해 기존 스낵바 경로로 흐르게 한다. * + * 설정의 비밀번호 변경(#564)도 같은 규칙을 따른다 — 이 메서드만 번역이 빠져 있어 1206·1702 가 + * 원본 `ApiException` 으로 새고 있었다. 화면이 타입으로만 분기하므로 번역이 끊기면 조용히 폴백 + * 문구가 나간다. + * * 비밀번호 찾기(#457)가 더한 두 코드도 같은 규칙을 따른다 — 소셜 가입 계정(1702)은 화면이 * 차단 팝업으로, 새 비밀번호가 기존과 같음(1206)은 스낵바 전용 문구로 가른다. 둘 다 타입으로만 * 갈리므로 여기서 번역이 끊기면 화면이 조용히 폴백 문구를 낸다. @@ -182,8 +186,93 @@ class AccountRepositoryImplTest { assertEquals(PasswordFindRequestDto(EMAIL, CODE, NEW_PASSWORD, "Confirm1!"), sent) } + @Test + fun `passwordChange - 기존과 같은 비밀번호(1206)는 PasswordUnchanged 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 400, + code = 1206, + serverMessage = "새 비밀번호와 같습니다.", + fallbackMessage = "새 비밀번호와 같습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.PasswordUnchanged) + } + + @Test + fun `passwordChange - 소셜 가입 계정(1702)은 SocialSignUpAccount 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 400, + code = 1702, + serverMessage = "소셜 로그인으로 가입한 계정입니다. 소셜 로그인을 이용해주세요.", + fallbackMessage = "소셜 로그인으로 가입한 계정입니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.SocialSignUpAccount) + } + + @Test + fun `passwordChange - 그 외 code 실패는 원본 ApiException 유지 (폴백 문구 경로)`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 401, + code = 1202, + serverMessage = null, + fallbackMessage = "아이디 또는 비밀번호가 일치하지 않습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + val error = result.exceptionOrNull() + assertTrue(error is ApiException) + assertEquals(1202, (error as ApiException).code) + } + + @Test + fun `passwordChange - 두 필드를 서버 요청 그대로 싣는다`() { + var sent: PasswordChangeRequestDto? = null + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { body -> + sent = body + success(Unit) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.isSuccess) + assertEquals(PasswordChangeRequestDto(CURRENT_PASSWORD, NEW_PASSWORD), sent) + } + private companion object { const val EMAIL = "user@example.com" + const val CURRENT_PASSWORD = "OldPass1!" const val CODE = "123456" const val NEW_PASSWORD = "NewPass1!" } @@ -202,6 +291,9 @@ private class FakeAccountApiService( private val onFindPassword: (PasswordFindRequestDto) -> BaseResponse = { error("findPassword 는 이 시나리오에서 호출되면 안 됨") }, + private val onPasswordChange: (PasswordChangeRequestDto) -> BaseResponse = { + error("passwordChange 는 이 시나리오에서 호출되면 안 됨") + }, ) : AccountApiService { override suspend fun sendEmailCode(body: SendEmailCodeRequestDto): BaseResponse = error("sendEmailCode 는 이 시나리오에서 호출되면 안 됨") @@ -216,5 +308,5 @@ private class FakeAccountApiService( override suspend fun signUp(body: SignUpRequestDto): BaseResponse = error("signUp 은 이 시나리오에서 호출되면 안 됨") - override suspend fun passwordChange(body: PasswordChangeRequestDto): BaseResponse = error("passwordChange 는 이 시나리오에서 호출되면 안 됨") + override suspend fun passwordChange(body: PasswordChangeRequestDto): BaseResponse = onPasswordChange(body) } diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt index 8fbfd58787..c25d81bec1 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt @@ -49,6 +49,16 @@ interface AccountRepository { confirmPassword: String, ): Result + /** + * 로그인한 사용자의 비밀번호 변경(서버 계약명은 `auth/password/change`). + * + * [resetPassword] 와 달리 인증번호를 쓰지 않고 현재 비밀번호로 본인을 확인한다. 확인이 끝난 + * 계정이라 `confirmPassword` 계약 필드도 없다(BE `PasswordChangeRequest` 는 두 필드뿐이다). + * + * 사유가 확인되는 실패는 [com.afternote.core.domain.error.CoreAuthFailure] 로 번역된다 — + * 새 비밀번호가 기존과 같음(1206)은 `PasswordUnchanged`, 비밀번호가 없는 소셜 가입 계정(1702)은 + * `SocialSignUpAccount`. **현재 비밀번호 불일치(1202)는 아직 번역되지 않아** 원본 예외로 온다. + */ suspend fun passwordChange( currentPassword: String, newPassword: String, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index c959821ef2..8cd0df53d0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -41,6 +41,7 @@ internal fun SettingScreen( onBackClick: () -> Unit, onLogoutSuccess: () -> Unit, onProfileEditClick: () -> Unit, + onPasswordChangeClick: () -> Unit, onLinkedAccountClick: () -> Unit, onNotificationClick: () -> Unit, onRecipientListClick: () -> Unit, @@ -96,6 +97,7 @@ internal fun SettingScreen( } }, onProfileEditClick = onProfileEditClick, + onPasswordChangeClick = onPasswordChangeClick, onLinkedAccountClick = onLinkedAccountClick, onNotificationClick = onNotificationClick, onRecipientListClick = onRecipientListClick, @@ -116,6 +118,7 @@ private fun SettingScreenContent( onLogoutClick: () -> Unit, onUnavailableClick: () -> Unit, onProfileEditClick: () -> Unit, + onPasswordChangeClick: () -> Unit, onLinkedAccountClick: () -> Unit, onNotificationClick: () -> Unit, onRecipientListClick: () -> Unit, @@ -174,7 +177,7 @@ private fun SettingScreenContent( ) SettingMenuItem( label = stringResource(R.string.setting_account_password_change), - onClick = onUnavailableClick, + onClick = onPasswordChangeClick, ) SettingMenuItem( label = stringResource(R.string.setting_account_linked_account), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt index f1ce88894a..c06870a8d7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt @@ -33,6 +33,10 @@ internal class SettingLocalNavActions( backStack.add(SettingRoute.ProfileEditRoute) } + override fun onPasswordChangeClick() { + backStack.add(SettingRoute.PasswordChangeRoute) + } + override fun onLinkedAccountClick() { backStack.add(SettingRoute.LinkedAccountRoute) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt index bdaac40c75..c36d150267 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt @@ -20,6 +20,8 @@ internal interface SettingNavActions { fun onProfileEditClick() + fun onPasswordChangeClick() + fun onLinkedAccountClick() fun onNotificationClick() diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt index 93435b6750..8aa752b3a9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt @@ -29,6 +29,7 @@ import com.afternote.feature.setting.presentation.passkey.PassKeyMakingScreen import com.afternote.feature.setting.presentation.passkey.PassKeyPasswordScreen import com.afternote.feature.setting.presentation.passkey.PassKeyScreen import com.afternote.feature.setting.presentation.passkey.PassKeyViewModel +import com.afternote.feature.setting.presentation.password.PasswordChangeScreen import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel @@ -97,6 +98,7 @@ public fun SettingNavHost( onBackClick = actions::popBack, onLogoutSuccess = actions::onLogoutSuccess, onProfileEditClick = actions::onProfileEditClick, + onPasswordChangeClick = actions::onPasswordChangeClick, onLinkedAccountClick = actions::onLinkedAccountClick, onNotificationClick = actions::onNotificationClick, onRecipientListClick = actions::onRecipientListClick, @@ -138,6 +140,13 @@ public fun SettingNavHost( ) } + entry { + PasswordChangeScreen( + onBackClick = actions::popBack, + onChanged = actions::popBack, + ) + } + entry { ConnectedAccountsScreen( onBack = actions::popBack, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt index a2f4edfdf6..6ba65b6eb9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt @@ -22,6 +22,9 @@ public sealed interface SettingRoute : NavKey { @Serializable public data object ProfileEditRoute : SettingRoute + @Serializable + public data object PasswordChangeRoute : SettingRoute + @Serializable public data object LinkedAccountRoute : SettingRoute diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt new file mode 100644 index 0000000000..24b699a0ec --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt @@ -0,0 +1,139 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.setting.presentation.R + +/** + * 비밀번호 변경 화면의 상태 없는 본문. + * + * 시안 대조는 하지 않았다 — 이 화면 전용 시안을 열 수단이 없었다. 배치는 같은 저장소의 새 비밀번호 + * 입력 선례(회원가입 3단계·비밀번호 찾기)를 따랐고, 문구와 간격은 시안이 확인되면 그때 맞춘다. + */ +@Composable +internal fun PasswordChangeContent( + uiState: PasswordChangeUiState, + onIntent: (PasswordChangeIntent) -> Unit, + onBackClick: () -> Unit, + onChanged: () -> Unit, + modifier: Modifier = Modifier, +) { + val currentPasswordState = rememberTextFieldState() + val newPasswordState = rememberTextFieldState() + // LaunchedEffect 는 키가 그대로면 최초 람다를 계속 들고 있다. 재구성으로 새로 온 핸들러를 쓰도록 + // 최신 값을 읽는다 — 키에 핸들러를 넣으면 매 재구성마다 수집이 끊겼다 다시 붙는다. + val currentOnIntent by rememberUpdatedState(onIntent) + + LaunchedEffect(currentPasswordState) { + snapshotFlow { currentPasswordState.text.toString() }.collect { value -> + currentOnIntent(PasswordChangeIntent.UpdateCurrentPassword(value)) + } + } + LaunchedEffect(newPasswordState) { + snapshotFlow { newPasswordState.text.toString() }.collect { value -> + currentOnIntent(PasswordChangeIntent.UpdateNewPassword(value)) + } + } + + ObserveSignal( + signal = uiState.changed, + consumed = PasswordChangeIntent.ConsumeChanged, + onIntent = onIntent, + ) { + onChanged() + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.setting_account_password_change), + actionButtonText = stringResource(R.string.setting_password_change_submit), + onBackClick = onBackClick, + onActionClick = { onIntent(PasswordChangeIntent.Submit) }, + modifier = modifier, + isActionEnabled = uiState.isSubmitEnabled, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(modifier = Modifier.height(35.dp)) + + PasswordFieldLabel(text = stringResource(R.string.setting_password_change_current_label)) + AfternoteTextField( + state = currentPasswordState, + placeholder = stringResource(R.string.setting_password_change_current_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PasswordFieldLabel(text = stringResource(R.string.setting_password_change_new_label)) + AfternoteTextField( + state = newPasswordState, + placeholder = stringResource(R.string.setting_password_change_new_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { onIntent(PasswordChangeIntent.Submit) }, + ) + + Text( + text = stringResource(R.string.setting_password_change_rule), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeB, + color = + if (uiState.isNewPasswordRuleSatisfied) { + AfternoteDesign.colors.b1 + } else { + AfternoteDesign.colors.gray5 + }, + ) + + uiState.errorMessage?.let { message -> + Text( + text = message.asString(), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } + } + } +} + +@Composable +private fun PasswordFieldLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt new file mode 100644 index 0000000000..c57d74dab5 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt @@ -0,0 +1,25 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R + +/** + * 비밀번호 변경 실패를 표시 문구로 옮긴다 — **타입만** 보고 고르고 서버 `message` 는 쓰지 않는다 + * (BE#92 — 서버 문구가 사용자 노출용이라는 규정이 없다). 같은 규칙의 선례가 + * [com.afternote.feature.setting.presentation.receiver.toReceiverFailureMessage] 다. + * + * 사유가 확인된 것만 전용 문구를 갖고 나머지는 폴백으로 내려앉는다. 폴백으로 내려앉는 것 중 가장 + * 흔한 것은 **현재 비밀번호 불일치**(BE `PASSWORD_MISMATCH`, code 1202)다 — 이 코드는 + * `mapAccountFailure` 가 아직 번역하지 않아 원본 예외로 도착한다. 이 화면은 현재의 실패 계약을 + * 그대로 소비하며, 1202에 대한 전용 번역은 추가하지 않는다. + */ +internal fun Throwable.toPasswordChangeMessage(): UiText = + UiText.Resource( + when (this) { + is CoreAuthFailure.NetworkUnavailable -> R.string.setting_password_change_network_error + is CoreAuthFailure.PasswordUnchanged -> R.string.setting_password_change_unchanged + is CoreAuthFailure.SocialSignUpAccount -> R.string.setting_password_change_social_blocked + else -> R.string.setting_password_change_failed + }, + ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt new file mode 100644 index 0000000000..741684ea9d --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt @@ -0,0 +1,30 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * 설정 > 계정 > 비밀번호 변경 (#564). + * + * ViewModel 은 여기서 들고, 그리는 일은 [PasswordChangeContent] 가 한다. + */ +@Composable +internal fun PasswordChangeScreen( + onBackClick: () -> Unit, + onChanged: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PasswordChangeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + PasswordChangeContent( + uiState = uiState, + onIntent = viewModel::onIntent, + onBackClick = onBackClick, + onChanged = onChanged, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt new file mode 100644 index 0000000000..5810bed96c --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt @@ -0,0 +1,81 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +/** + * 설정 > 비밀번호 변경 화면 상태. + * + * 입력 칸이 둘인 것은 서버 계약 그대로다 — BE `PasswordChangeRequest` 는 `currentPassword`· + * `newPassword` 두 필드뿐이다. 「새 비밀번호 확인」 칸은 비밀번호 **찾기**(`auth/password/find`)의 + * `confirmPassword` 계약에만 있고 변경에는 없어 두지 않는다. + */ +internal data class PasswordChangeUiState( + val currentPassword: String = "", + val newPassword: String = "", + val isSubmitting: Boolean = false, + /** + * 입력 아래에 그대로 남는 실패 안내. 다음 입력·다음 제출이 지운다. + * + * 일회성 신호가 아니다 — 어느 칸을 고쳐야 하는지 읽는 동안 남아 있어야 한다. + */ + val errorMessage: UiText? = null, + /** 변경 완료 신호. 화면이 한 번 소비하고 [PasswordChangeIntent.ConsumeChanged] 로 되돌린다(#228). */ + val changed: PasswordChanged? = null, +) : UiState { + val isNewPasswordRuleSatisfied: Boolean get() = SettingPasswordRule.isSatisfied(newPassword) + + /** + * 제출 가능 여부. + * + * 현재 비밀번호는 비어 있지만 않으면 된다 — 규칙 검사를 걸면 옛 규칙으로 만든 비밀번호를 쓰는 + * 계정이 변경 자체를 못 한다([SettingPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만 + * 판정할 수 있다(BE `NEWPASSWORD_MATCH`) — 클라는 현재 비밀번호의 평문을 비교 근거로 삼지 않는다. + */ + val isSubmitEnabled: Boolean + get() = !isSubmitting && currentPassword.isNotEmpty() && isNewPasswordRuleSatisfied +} + +/** + * [PasswordChangeUiState.changed] 가 나르는 완료 신호. + * + * `Boolean` 대신 전용 타입인 것은 소비 관용구 `ObserveSignal` 이 non-null 값을 키로 쓰기 때문이다 — + * `false` 도 값이라 플래그로 두면 화면 진입 즉시 한 번 소비된다. + */ +internal data object PasswordChanged + +internal sealed interface PasswordChangeIntent : MviIntent { + data class UpdateCurrentPassword( + val value: String, + ) : PasswordChangeIntent + + data class UpdateNewPassword( + val value: String, + ) : PasswordChangeIntent + + data object Submit : PasswordChangeIntent + + data object ConsumeChanged : PasswordChangeIntent +} + +internal sealed interface PasswordChangeReducerEvent : ReducerEvent { + data class CurrentPasswordChanged( + val value: String, + ) : PasswordChangeReducerEvent + + data class NewPasswordChanged( + val value: String, + ) : PasswordChangeReducerEvent + + data object SubmitStarted : PasswordChangeReducerEvent + + data object Succeeded : PasswordChangeReducerEvent + + data class Failed( + val message: UiText, + ) : PasswordChangeReducerEvent + + data object ChangedConsumed : PasswordChangeReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt new file mode 100644 index 0000000000..aa6d6c88be --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt @@ -0,0 +1,96 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.lifecycle.viewModelScope +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.ui.mvi.MviViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 설정 > 비밀번호 변경. + * + * 서버·네트워크·데이터·도메인 계약은 이미 있고(`AccountRepository.passwordChange` → + * `POST auth/password/change`) 여기가 그 첫 소비처다. + */ +@HiltViewModel +internal class PasswordChangeViewModel + @Inject + constructor( + private val accountRepository: AccountRepository, + ) : MviViewModel( + PasswordChangeUiState(), + ) { + override fun onIntent(intent: PasswordChangeIntent) { + when (intent) { + is PasswordChangeIntent.UpdateCurrentPassword -> { + dispatch(PasswordChangeReducerEvent.CurrentPasswordChanged(intent.value)) + } + + is PasswordChangeIntent.UpdateNewPassword -> { + dispatch(PasswordChangeReducerEvent.NewPasswordChanged(intent.value)) + } + + PasswordChangeIntent.Submit -> { + submit() + } + + PasswordChangeIntent.ConsumeChanged -> { + dispatch(PasswordChangeReducerEvent.ChangedConsumed) + } + } + } + + override fun reduce( + state: PasswordChangeUiState, + event: PasswordChangeReducerEvent, + ): PasswordChangeUiState = + when (event) { + // 입력이 바뀌면 직전 실패 안내는 지운다 — 고친 값에 대고 옛 사유를 읽히지 않는다. + is PasswordChangeReducerEvent.CurrentPasswordChanged -> { + state.copy(currentPassword = event.value, errorMessage = null) + } + + is PasswordChangeReducerEvent.NewPasswordChanged -> { + state.copy(newPassword = event.value, errorMessage = null) + } + + PasswordChangeReducerEvent.SubmitStarted -> { + state.copy(isSubmitting = true, errorMessage = null) + } + + PasswordChangeReducerEvent.Succeeded -> { + state.copy(isSubmitting = false, errorMessage = null, changed = PasswordChanged) + } + + is PasswordChangeReducerEvent.Failed -> { + state.copy(isSubmitting = false, errorMessage = event.message) + } + + PasswordChangeReducerEvent.ChangedConsumed -> { + state.copy(changed = null) + } + } + + /** + * 버튼이 비활성이어도 다시 막는다 — IME 의 완료 액션처럼 버튼을 거치지 않는 제출 경로가 + * 같은 진입점으로 들어온다. + */ + private fun submit() { + val state = currentState + if (!state.isSubmitEnabled) return + + dispatch(PasswordChangeReducerEvent.SubmitStarted) + viewModelScope.launch { + accountRepository + .passwordChange( + currentPassword = state.currentPassword, + newPassword = state.newPassword, + ).onSuccess { + dispatch(PasswordChangeReducerEvent.Succeeded) + }.onFailure { error -> + dispatch(PasswordChangeReducerEvent.Failed(error.toPasswordChangeMessage())) + } + } + } + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt new file mode 100644 index 0000000000..9603577ca6 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt @@ -0,0 +1,30 @@ +package com.afternote.feature.setting.presentation.password + +/** + * 설정의 비밀번호 변경이 **새 비밀번호** 에 요구하는 조합 규칙. + * + * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다(2026-09-13 + * Afternote-BE main 실코드 확인) — 영문·숫자·허용 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 + * 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 8~15자여야 한다. 서버는 이 상수 하나를 + * `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` 세 곳에 `@Pattern` 으로 걸고 + * 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 표시를 다 받고 제출에서 400 을 맞는다. + * + * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다. + * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628). + * + * ### 온보딩의 `OnboardingPasswordRule` 과 사본이 둘이다 + * #1644 는 「규칙 사본은 한 곳」을 목표로 두었고 그때 설정의 비밀번호 변경은 화면·호출부가 없어 + * 대상이 아니었다. 지금 그 소비처가 생겼지만 온보딩 쪽 객체는 `feature:onboarding:presentation` + * 의 `internal` 이라 모듈 밖에서 쓸 수 없고, 공용 자리로 옮기는 일은 남의 모듈 프로덕션을 고치는 + * 일이라 이 변경의 범위 밖이다. 값을 고칠 땐 **BE 실코드를 정본으로** 두 사본을 함께 고친다. + * + * 현재 비밀번호에는 이 규칙을 걸지 않는다 — 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수 있어 + * 클라가 막으면 변경 자체가 불가능해진다(로그인에 규칙을 걸지 않는 것과 같은 판단). + */ +internal object SettingPasswordRule { + /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */ + private val REGEX = + Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$") + + fun isSatisfied(password: String): Boolean = REGEX.matches(password) +} diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index c71046fece..1bcff3c581 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -170,4 +170,15 @@ 수신자 정보를 불러오지 못했습니다. 현재 이 메뉴는 이용할 수 없습니다. 확인 + + 현재 비밀번호 + 현재 비밀번호를 입력해주세요 + 새 비밀번호 + 새 비밀번호를 입력해주세요 + 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. + 변경하기 + 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. + 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. + 네트워크 연결을 확인해주세요. + 비밀번호를 변경하지 못했습니다. 입력하신 내용을 확인해 주세요. diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 4d7774a65f..5056e0c639 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -113,6 +113,28 @@ class SettingFlowTest { assertEquals(listOf(Triple(false, null, null)), notification.pushSettingUpdates) } + /** + * 비밀번호 변경은 목적지가 생겼다 (#564) — 이용 불가 안내가 아니라 실제 배선을 탄다. + * + * 아래 [unavailableMenus_showFeedbackAndKeepSettingsUsable] 의 목록에서 이 항목을 뺀 것과 한 쌍이다. + */ + @Test + fun passwordChangeMenu_opensDestinationInsteadOfUnavailableFeedback() { + val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true)) + var passwordChangeOpened = false + setSettingContent(viewModel = viewModel, onPasswordChange = { passwordChangeOpened = true }) + val resources = ApplicationProvider.getApplicationContext().resources + + composeRule + .onNode( + hasText(resources.getString(R.string.setting_account_password_change)) and hasClickAction(), + ).performScrollTo() + .performClick() + + assertEquals(true, passwordChangeOpened) + composeRule.onNodeWithText("현재 이 메뉴는 이용할 수 없습니다.").assertDoesNotExist() + } + @Test fun unavailableMenus_showFeedbackAndKeepSettingsUsable() { val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true)) @@ -121,7 +143,6 @@ class SettingFlowTest { val resources = ApplicationProvider.getApplicationContext().resources val menuIds = listOf( - R.string.setting_account_password_change, R.string.setting_support_faq, R.string.setting_support_inquiry, R.string.setting_support_terms, @@ -146,6 +167,7 @@ class SettingFlowTest { viewModel: SettingViewModel, onLogoutSuccess: () -> Unit = {}, onProfileEdit: () -> Unit = {}, + onPasswordChange: () -> Unit = {}, onAppLock: () -> Unit = {}, ) { composeRule.setContent { @@ -154,6 +176,7 @@ class SettingFlowTest { onBackClick = {}, onLogoutSuccess = onLogoutSuccess, onProfileEditClick = onProfileEdit, + onPasswordChangeClick = onPasswordChange, onLinkedAccountClick = {}, onNotificationClick = {}, onRecipientListClick = {}, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt index 9bff21cde8..a8ac0bef14 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt @@ -155,11 +155,12 @@ class SettingLocalNavActionsTest { fun `클릭 하나마다 목적지 하나가 쌓인다`() { val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + actions.onPasswordChangeClick() actions.onLinkedAccountClick() actions.onNoticeClick() assertEquals( - listOf("SettingHomeRoute", "LinkedAccountRoute", "NoticeRoute"), + listOf("SettingHomeRoute", "PasswordChangeRoute", "LinkedAccountRoute", "NoticeRoute"), backStack.names(), ) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt index 001b2f4af5..da79fd041a 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt @@ -28,6 +28,7 @@ class SettingRouteSerializationTest { SettingRoute.WithdrawGuideRoute, SettingRoute.WithdrawConfirmRoute, SettingRoute.ProfileEditRoute, + SettingRoute.PasswordChangeRoute, SettingRoute.LinkedAccountRoute, SettingRoute.NotificationRoute, SettingRoute.PushNotificationRoute, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt new file mode 100644 index 0000000000..36ff2ce959 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt @@ -0,0 +1,142 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.ui.UiText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 비밀번호 변경 화면의 배선 회귀 가드 (#564). + * + * 화면 쪽에서만 깨질 수 있는 것을 잡는다 — 입력이 Intent 로 나가는지, 제출 버튼이 규칙 미충족에서 + * 실제로 눌리지 않는지(색만 바뀌는 비활성은 「눌러도 아무 일 없는 버튼」이다), 완료 신호가 소비 + * Intent 와 짝을 이뤄 한 번만 나가는지. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class PasswordChangeContentTest { + @get:Rule + val composeRule = createComposeRule() + + private val resources get() = ApplicationProvider.getApplicationContext().resources + + /** + * 두 칸이 각자의 Intent 로 나가는지 본다 — 같은 Intent 에 둘을 배선하면 새 비밀번호가 현재 + * 비밀번호 칸을 덮어써도 컴파일은 통과한다. + * + * 최초 빈 문자열 방출은 `snapshotFlow` 의 첫 값이라 정상이다. 마지막 값으로 단언한다. + */ + @Test + fun typedPasswords_areForwardedAsIntents() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(), intents) + + typeInto(R.string.setting_password_change_current_placeholder, "OldPass1!") + typeInto(R.string.setting_password_change_new_placeholder, "NewPass1!") + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + intents.filterIsInstance().lastOrNull()?.value == "NewPass1!" + } + assertEquals( + "OldPass1!", + intents.filterIsInstance().last().value, + ) + } + + @Test + fun submitButton_isDisabledUntilStateAllowsIt() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(currentPassword = "OldPass1!", newPassword = "weakpassword"), intents) + + val submit = resources.getString(R.string.setting_password_change_submit) + composeRule.onNode(hasText(submit) and hasClickAction()).assertIsNotEnabled().performClick() + + assertEquals(emptyList(), intents.filterIsInstance()) + } + + @Test + fun submitButton_sendsSubmitIntentWhenStateAllowsIt() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(currentPassword = "OldPass1!", newPassword = "NewPass1!"), intents) + + val submit = resources.getString(R.string.setting_password_change_submit) + composeRule.onNode(hasText(submit) and hasClickAction()).assertIsEnabled().performClick() + + assertEquals(listOf(PasswordChangeIntent.Submit), intents.filterIsInstance()) + } + + @Test + fun errorMessage_isRenderedFromResourceNotServerText() { + setContent( + PasswordChangeUiState(errorMessage = UiText.Resource(R.string.setting_password_change_unchanged)), + mutableListOf(), + ) + + composeRule + .onNodeWithText(resources.getString(R.string.setting_password_change_unchanged)) + .assertIsDisplayed() + } + + @Test + fun changedSignal_notifiesOnceAndIsConsumed() { + val intents = mutableListOf() + var changedCount = 0 + setContent(PasswordChangeUiState(changed = PasswordChanged), intents, onChanged = { changedCount++ }) + + composeRule.waitForIdle() + + assertEquals(1, changedCount) + assertEquals( + listOf(PasswordChangeIntent.ConsumeChanged), + intents.filterIsInstance(), + ) + } + + private fun typeInto( + placeholderRes: Int, + text: String, + ) { + composeRule + .onNode(hasText(resources.getString(placeholderRes)) and hasSetTextAction()) + .performTextInput(text) + } + + private fun setContent( + uiState: PasswordChangeUiState, + intents: MutableList, + onChanged: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + PasswordChangeContent( + uiState = uiState, + onIntent = { intents += it }, + onBackClick = {}, + onChanged = onChanged, + ) + } + } + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt new file mode 100644 index 0000000000..569e0b0345 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt @@ -0,0 +1,252 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 설정 > 비밀번호 변경의 제출 계약 회귀 가드 (#564). + * + * 지키는 것 셋 — + * 1. 규칙을 만족하지 않는 입력은 **서버를 치기 전에** 막는다(서버 `@Pattern` 400 을 왕복해서 알지 않는다). + * 2. 두 필드를 서버 계약 그대로 싣는다. + * 3. 사유가 확인된 실패는 타입별 전용 문구로 갈리고, 확인되지 않은 실패만 폴백으로 내려앉는다. + * + * 저장소 가짜는 Java `Proxy` 가 아니라 실제 구현체다 — `Result` 를 돌려주는 suspend 함수는 + * Proxy 에서 이름이 맹글링돼 분기가 조용히 빗나간다(PR #1828). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PasswordChangeViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `새 비밀번호가 규칙을 어기면 제출이 저장소를 치지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword("weakpassword")) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertFalse(viewModel.uiState.value.isNewPasswordRuleSatisfied) + assertFalse(viewModel.uiState.value.isSubmitEnabled) + assertEquals(emptyList>(), repository.passwordChangeCalls) + } + + @Test + fun `현재 비밀번호가 비어 있으면 제출이 저장소를 치지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertEquals(emptyList>(), repository.passwordChangeCalls) + } + + @Test + fun `현재 비밀번호는 규칙 검사를 받지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + // 옛 규칙으로 만든 비밀번호 — 지금 규칙(특수문자 8종)에는 걸린다. + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword("oldpassword")) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertEquals(listOf("oldpassword" to NEW_PASSWORD), repository.passwordChangeCalls) + } + + @Test + fun `성공하면 두 필드를 그대로 싣고 완료 신호를 세운다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals(listOf(CURRENT_PASSWORD to NEW_PASSWORD), repository.passwordChangeCalls) + assertEquals(PasswordChanged, viewModel.uiState.value.changed) + assertFalse(viewModel.uiState.value.isSubmitting) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun `완료 신호는 소비하면 지워진다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + viewModel.onIntent(PasswordChangeIntent.ConsumeChanged) + + assertNull(viewModel.uiState.value.changed) + } + + @Test + fun `기존과 같은 비밀번호(1206)는 전용 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.PasswordUnchanged(Exception("1206")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_unchanged), + viewModel.uiState.value.errorMessage, + ) + assertFalse(viewModel.uiState.value.isSubmitting) + assertNull(viewModel.uiState.value.changed) + } + + @Test + fun `소셜 가입 계정(1702)은 전용 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.SocialSignUpAccount(Exception("1702")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_social_blocked), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `전송 실패는 네트워크 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.NetworkUnavailable(IOException("offline")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_network_error), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `사유가 확인되지 않은 실패는 폴백 문구로 내려앉는다`() { + val repository = FakeAccountRepository(result = Result.failure(IllegalStateException("1202"))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_failed), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `입력을 고치면 직전 실패 안내가 지워진다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.PasswordUnchanged(Exception("1206")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword("NewPass2!")) + + assertNull(viewModel.uiState.value.errorMessage) + } + + /** + * 유니코드 숫자는 **허용 목록 밖 문자라서** 거절된다 — 서버와 같은 판정이다. + * + * `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다(실측). 허용 목록 + * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력 자체가 + * 없고, JVM 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지 + * 못한다. `\d` 를 쓰지 않는 이유는 허용 목록이 넓어지는 날 갈리기 때문이고, 그 근거는 + * [SettingPasswordRule] KDoc 이 갖는다. + */ + @Test + fun `규칙은 서버와 같다 - 유니코드 숫자를 숫자로 받지 않는다`() { + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg١!")) + assertTrue(SettingPasswordRule.isSatisfied("Abcdefg1!")) + } + + @Test + fun `규칙은 서버와 같다 - 허용 목록 밖 문자와 길이를 막는다`() { + assertFalse(SettingPasswordRule.isSatisfied("Abcde1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefghijklmn1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1_")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1 !")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefgh!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg12")) + assertTrue(SettingPasswordRule.isSatisfied("Abcdefghijklm1!")) + } + + private fun submit(viewModel: PasswordChangeViewModel) { + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + } + + private companion object { + const val CURRENT_PASSWORD = "OldPass1!" + const val NEW_PASSWORD = "NewPass1!" + } +} + +private class FakeAccountRepository( + private val result: Result = Result.success(Unit), +) : AccountRepository { + val passwordChangeCalls = mutableListOf>() + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result { + passwordChangeCalls += currentPassword to newPassword + return result + } + + override suspend fun sendEmailCode(email: String): Result = error("sendEmailCode 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("verifyEmail 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun sendFindCode(email: String): Result = error("sendFindCode 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("findAccount 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("signUp 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("resetPassword 는 이 시나리오에서 호출되면 안 됨") +}