From f9744a048f2f65adae880e9145d101750ae9fa80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:28:40 +0900 Subject: [PATCH] fix(setting): validate profile contact before saving (#2223) --- .../presentation/profile/ProfileEditScreen.kt | 18 ++- .../profile/ProfileEditViewModel.kt | 3 + .../profile/ProfileEditPhoneTest.kt | 147 ++++++++++++++++++ .../profile/ProfileEditViewModelTest.kt | 62 ++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index d840cff69..7100002ce 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -30,6 +30,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,6 +42,9 @@ import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation +import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone @Composable internal fun ProfileEditScreen( @@ -147,6 +152,7 @@ private fun ProfileEditForm( val nameState = rememberTextFieldState(initialText = state.name) val phoneState = rememberTextFieldState(initialText = state.phone) val emailState = rememberTextFieldState(initialText = state.email) + val isPhoneInvalid = phoneState.text.toString().validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID LazyColumn( modifier = modifier.fillMaxSize(), @@ -199,8 +205,18 @@ private fun ProfileEditForm( AfternoteTextField( state = phoneState, placeholder = "연락처를 지정해주세요", + keyboardType = KeyboardType.Phone, + isError = isPhoneInvalid, modifier = Modifier.fillMaxWidth(), ) + if (isPhoneInvalid) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.setting_receiver_phone_invalid), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } } } item { @@ -225,7 +241,7 @@ private fun ProfileEditForm( AfternoteButton( text = "수정하기", onClick = { onUpdateClick(nameState.text.toString(), phoneState.text.toString()) }, - type = if (state.isUpdating) AfternoteButtonType.Un else AfternoteButtonType.Default, + type = if (state.isUpdating || isPhoneInvalid) AfternoteButtonType.Un else AfternoteButtonType.Default, modifier = Modifier .padding(horizontal = 20.dp) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index cd197676b..bc71680f7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -5,6 +5,8 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation +import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -94,6 +96,7 @@ internal class ProfileEditViewModel ) { val current = currentState as? ProfileEditUiState.Success ?: return if (current.isUpdating) return + if (phone.validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID) return dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt new file mode 100644 index 000000000..89dcd7b15 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt @@ -0,0 +1,147 @@ +package com.afternote.feature.setting.presentation.profile + +import android.text.InputType +import android.view.inputmethod.EditorInfo +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasScrollToNodeAction +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.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextReplacement +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +import kotlinx.coroutines.awaitCancellation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalComposeUiApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditPhoneTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `연락처를 누르면 전화번호 키보드를 요청한다`() { + val viewModel = viewModel(FakeMyProfileRepository(profile = PROFILE)) + var inputType: Int? = null + composeRule.setContent { + AfternoteTheme { + InterceptPlatformTextInput( + interceptor = { request, _ -> + val editorInfo = EditorInfo() + request.createInputConnection(editorInfo) + inputType = editorInfo.inputType + awaitCancellation() + }, + ) { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + } + awaitSuccess(viewModel) + + phoneField(PHONE).performScrollTo().performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { inputType != null } + composeRule.runOnIdle { assertEquals(InputType.TYPE_CLASS_PHONE, inputType!! and InputType.TYPE_MASK_CLASS) } + } + + @Test + fun `서버의 잘못된 연락처에도 오류가 보이고 저장이 막힌다`() { + val repository = FakeMyProfileRepository(profile = PROFILE.copy(phone = INVALID_PHONE)) + val viewModel = viewModel(repository) + setContent(viewModel) + awaitSuccess(viewModel) + + scrollToText(PHONE_ERROR) + composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed() + scrollToText("수정하기") + composeRule + .onNodeWithText("수정하기") + .assertIsNotEnabled() + .performClick() + + composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) } + } + + @Test + fun `붙여넣은 잘못된 연락처를 고치면 오류가 해제되고 저장된다`() { + val repository = FakeMyProfileRepository(profile = PROFILE) + val viewModel = viewModel(repository) + var navigatedBack = 0 + setContent(viewModel) { navigatedBack++ } + awaitSuccess(viewModel) + + phoneField(PHONE).performScrollTo().performTextReplacement(INVALID_PHONE) + scrollToText(PHONE_ERROR) + composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed() + scrollToText("수정하기") + composeRule.onNodeWithText("수정하기").assertIsNotEnabled() + composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) } + + scrollToText(INVALID_PHONE) + phoneField(INVALID_PHONE).performTextReplacement("010-5555-6666") + composeRule.onNodeWithText(PHONE_ERROR).assertDoesNotExist() + scrollToText("수정하기") + composeRule + .onNodeWithText("수정하기") + .assertIsEnabled() + .performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { navigatedBack == 1 } + composeRule.runOnIdle { + assertEquals( + listOf(FakeMyProfileRepository.ProfileUpdateCall("기존 이름", "010-5555-6666", null)), + repository.profileUpdateCalls, + ) + } + } + + private fun scrollToText(value: String) { + composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(value)) + } + + private fun phoneField(value: String) = composeRule.onNode(hasSetTextAction() and hasText(value)) + + private fun setContent( + viewModel: ProfileEditViewModel, + onBackClick: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = onBackClick, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + } + + private fun viewModel(repository: FakeMyProfileRepository) = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + + private fun awaitSuccess(viewModel: ProfileEditViewModel) { + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } + composeRule.waitForIdle() + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + const val PHONE = "01012345678" + const val INVALID_PHONE = "01055556666ggyyy" + const val PHONE_ERROR = "올바른 연락처를 입력해주세요." + val PROFILE = User("기존 이름", "qa@afternote.local", PHONE, null) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt index 706442676..7e4b4f668 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt @@ -42,6 +42,68 @@ class ProfileEditViewModelTest { Dispatchers.resetMain() } + @Test + fun `잘못된 연락처는 사진 업로드와 프로필 수정 전에 차단한다`() = + runTest(dispatcher) { + for (phone in listOf("01055556666ggyyy", "0101234567", "02012345678", "010--1234-5678")) { + val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = phone)) + val uploadRepository = FakePhotoUploadRepository.strict() + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone)) + runCurrent() + + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertTrue(uploadRepository.uploads.isEmpty()) + assertFalse(viewModel.success().isUpdating) + assertNull(viewModel.success().pendingEvent) + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + } + } + + @Test + fun `빈 연락처와 유효한 휴대폰의 기존 저장 값을 보존한다`() = + runTest(dispatcher) { + for (phone in listOf("", " ", "01012345678", "010-1234-5678", "011-123-4567")) { + val profileRepository = FakeMyProfileRepository(profile = SAVED) + val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict()) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone)) + runCurrent() + + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = phone.takeIf { it.isNotBlank() }, profileImageUrl = null)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + } + + @Test + fun `잘못된 연락처를 고치면 보관한 사진과 함께 저장할 수 있다`() = + runTest(dispatcher) { + val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = "01055556666ggyyy")) + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666ggyyy")) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666")) + runCurrent() + + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = "01055556666", profileImageUrl = UPLOADED_KEY)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + @Test fun `서버 프로필 사진을 싣고 고른 사진은 저장 전까지 그 위에만 보인다`() = runTest(dispatcher) {