From 7612fab118beb4ac7c19345ba5d0e9df5c88fb2e Mon Sep 17 00:00:00 2001 From: kyungmin Date: Mon, 31 Aug 2026 22:07:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(setting):=20=EC=88=98=EC=8B=A0=EC=9D=B8?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 위로 재구성: 흩어진 develop 병합 커밋(19개)과 머지 커밋 속에 있던 실제 변경분(a1e25a1e1, 461d80820)을 하나로 합쳐 develop 최신 상태 위에 재적용. 1hyok 리뷰(8/27) 반영 — ReceiverEditScreen 을 실제로 렌더링해 전화번호 필수 wiring 을 검증하는 화면 레벨 회귀 테스트, 등록 화면에서 빈 전화번호가 허용되는 반대 시나리오 테스트 추가. --- .../core/data/repoimpl/UserRepositoryImpl.kt | 74 ++++++++------ .../repoimpl/ReceiverRequestFailureTest.kt | 78 +++++++++++++++ .../error/ReceiverRequestRejectedException.kt | 7 ++ .../presentation/screen/ReceiverEditScreen.kt | 4 +- .../screen/ReceiverRegisterScreen.kt | 66 +++++++++++-- .../viewmodel/ReceiverEditUiState.kt | 3 +- .../viewmodel/ReceiverEditViewModel.kt | 32 +++++-- .../viewmodel/ReceiverFailureMessage.kt | 11 +++ .../viewmodel/ReceiverPhoneValidator.kt | 30 ++++++ .../viewmodel/ReceiverRegisterUiState.kt | 4 +- .../viewmodel/ReceiverRegisterViewModel.kt | 26 ++++- .../src/main/res/values/strings.xml | 8 ++ .../SettingAccountSecurityTest.kt | 25 ++--- .../presentation/SettingCompletionTest.kt | 89 ++++++++++++++++- .../viewmodel/ReceiverPhoneValidatorTest.kt | 50 ++++++++++ .../ReceiverRegisterViewModelTest.kt | 96 +++++++++++++++++++ 16 files changed, 531 insertions(+), 72 deletions(-) create mode 100644 core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt create mode 100644 core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt index ea14fa3ad0..e50c225a0e 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.data.mapper.delivery.toRequestDto import com.afternote.core.data.mapper.user.toDomain +import com.afternote.core.domain.error.ReceiverRequestRejectedException import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.delivery.DeliveryConditionItem @@ -114,17 +115,19 @@ class UserRepositoryImpl message: String?, ): ReceiverCreated { val result = - userApiService - .createReceiver( - UserCreateReceiverRequestDto( - name = name, - relation = relation, - phone = phone, - email = email, - message = message, - ), - ).requireData() - .toDomain() + mapReceiverRequestFailure { + userApiService + .createReceiver( + UserCreateReceiverRequestDto( + name = name, + relation = relation, + phone = phone, + email = email, + message = message, + ), + ).requireData() + .toDomain() + } receiverRefreshRevision.update { it + 1 } return result } @@ -142,28 +145,32 @@ class UserRepositoryImpl relation: String, email: String, ): Receiver = - userApiService - .updateReceiver( - receiverId = receiverId, - request = - UserPatchReceiverRequestDto( - name = name, - phone = phone, - relation = relation, - email = email, - ), - ).requireData() - .toDomain() + mapReceiverRequestFailure { + userApiService + .updateReceiver( + receiverId = receiverId, + request = + UserPatchReceiverRequestDto( + name = name, + phone = phone, + relation = relation, + email = email, + ), + ).requireData() + .toDomain() + } override suspend fun updateReceiverMessage( receiverId: Long, message: String, ) { - userApiService - .updateReceiverMessage( - receiverId = receiverId, - request = UserUpdateReceiverMessageRequestDto(message = message), - ).requireStatus() + mapReceiverRequestFailure { + userApiService + .updateReceiverMessage( + receiverId = receiverId, + request = UserUpdateReceiverMessageRequestDto(message = message), + ).requireStatus() + } } override suspend fun getMyProfile(): User = @@ -277,6 +284,17 @@ class UserRepositoryImpl } } +internal suspend inline fun mapReceiverRequestFailure(request: suspend () -> T): T = + try { + request() + } catch (error: ApiException) { + val serverMessage = error.serverMessage + if (error.status in setOf(400, 409) && !serverMessage.isNullOrBlank()) { + throw ReceiverRequestRejectedException(serverMessage, error) + } + throw error + } + private const val KEY_ACCOUNT_STAGE = "account_stage" private const val ACCOUNT_STAGE_DELETE_SESSION_CLEANUP = "delete_session_cleanup" private const val KEY_STAGE = "stage" diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt new file mode 100644 index 0000000000..d6d9b02dc1 --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt @@ -0,0 +1,78 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.network.model.ApiException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverRequestFailureTest { + @Test + fun `400 server message becomes user-facing domain error`() = + runBlocking { + val apiError = + ApiException( + status = 400, + code = 400, + serverMessage = "수신자 이메일은 필수입니다.", + fallbackMessage = "수신자 이메일은 필수입니다.", + ) + + val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + + assertTrue(result is ReceiverRequestRejectedException) + val domainError = result as ReceiverRequestRejectedException + assertEquals("수신자 이메일은 필수입니다.", domainError.userMessage) + assertSame(apiError, domainError.cause) + } + + @Test + fun `409 server message becomes user-facing domain error`() = + runBlocking { + val apiError = + ApiException( + status = 409, + code = 409, + serverMessage = "이미 등록된 수신자입니다.", + fallbackMessage = "이미 등록된 수신자입니다.", + ) + + val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + + assertTrue(result is ReceiverRequestRejectedException) + } + + @Test + fun `other 4xx server message is not converted to input error`() = + runBlocking { + val apiError = + ApiException( + status = 404, + code = 404, + serverMessage = "존재하지 않는 엔드포인트입니다.", + fallbackMessage = "존재하지 않는 엔드포인트입니다.", + ) + + val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + + assertSame(apiError, result) + } + + @Test + fun `5xx server message is not converted to user-facing error`() = + runBlocking { + val apiError = + ApiException( + status = 500, + code = 500, + serverMessage = "internal SQL details", + fallbackMessage = "internal SQL details", + ) + + val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + + assertSame(apiError, result) + } +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt new file mode 100644 index 0000000000..8924769955 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt @@ -0,0 +1,7 @@ +package com.afternote.core.domain.error + +/** 사용자가 입력을 수정해 해결할 수 있는 수신자 등록·수정 요청 오류. */ +class ReceiverRequestRejectedException( + val userMessage: String, + cause: Throwable, +) : Exception(userMessage, cause) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt index 45d64d2f0b..edd8e7e507 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditEvent @@ -42,6 +43,7 @@ fun ReceiverEditScreen( ReceiverRegisterContent( title = "수신자 수정", actionText = "수정", + isPhoneRequired = true, isLoading = uiState.isSaving, errorMessage = uiState.errorMessage, onBackClick = onBackClick, @@ -74,7 +76,7 @@ fun ReceiverEditScreen( CircularProgressIndicator(color = AfternoteDesign.colors.gray9) } else { Text( - text = uiState.errorMessage.orEmpty(), + text = uiState.errorMessage?.asString().orEmpty(), style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.error, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt index 7d7918a810..ba71a0204c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt @@ -36,17 +36,25 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.PhoneNumberInputTransformation +import com.afternote.core.ui.PhoneNumberVisualTransformation +import com.afternote.core.ui.UiText +import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.viewmodel.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEvent import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel +import com.afternote.feature.setting.presentation.viewmodel.isValidReceiverEmail +import com.afternote.feature.setting.presentation.viewmodel.validateReceiverPhone import com.afternote.core.ui.R as CoreR private const val CUSTOM_RELATION_OPTION = "직접 추가하기" @@ -73,6 +81,7 @@ fun ReceiverRegisterScreen( ReceiverRegisterContent( title = "수신자 등록", actionText = "등록", + isPhoneRequired = false, isLoading = uiState.isLoading, errorMessage = uiState.errorMessage, onBackClick = onBackClick, @@ -85,8 +94,9 @@ fun ReceiverRegisterScreen( internal fun ReceiverRegisterContent( title: String, actionText: String, + isPhoneRequired: Boolean, isLoading: Boolean, - errorMessage: String?, + errorMessage: UiText?, onBackClick: () -> Unit, onRegister: (name: String, relation: String, phone: String, email: String, message: String) -> Unit, modifier: Modifier = Modifier, @@ -98,7 +108,7 @@ internal fun ReceiverRegisterContent( ) { val isPresetRelation = initialRelation in relationOptions val nameState = rememberTextFieldState(initialText = initialName) - val phoneState = rememberTextFieldState(initialText = initialPhone) + val phoneState = rememberTextFieldState(initialText = initialPhone.filter(Char::isDigit)) val emailState = rememberTextFieldState(initialText = initialEmail) val messageState = rememberTextFieldState(initialText = initialMessage) val customRelationState = rememberTextFieldState(initialText = initialRelation.takeUnless { isPresetRelation }.orEmpty()) @@ -119,7 +129,15 @@ internal fun ReceiverRegisterContent( CUSTOM_RELATION_OPTION -> customRelationState.text.toString().trim() else -> selectedRelation.orEmpty() } - val isFormValid = nameState.text.isNotBlank() && relation.isNotBlank() + val phone = phoneState.text.toString() + val phoneValidation = phone.validateReceiverPhone(isRequired = isPhoneRequired) + val email = emailState.text.toString() + val isEmailValid = email.isValidReceiverEmail() + val isFormValid = + nameState.text.isNotBlank() && + relation.isNotBlank() && + isEmailValid && + phoneValidation == ReceiverPhoneValidation.VALID Scaffold( modifier = modifier, @@ -134,8 +152,8 @@ internal fun ReceiverRegisterContent( onRegister( nameState.text.toString(), relation, - phoneState.text.toString(), - emailState.text.toString(), + phone, + email, messageState.text.toString(), ) }, @@ -198,7 +216,26 @@ internal fun ReceiverRegisterContent( AfternoteTextField( state = phoneState, placeholder = "연락처를 지정해주세요", + keyboardType = KeyboardType.Phone, + inputTransformation = PhoneNumberInputTransformation, + outputTransformation = PhoneNumberVisualTransformation, ) + if (phoneValidation != ReceiverPhoneValidation.VALID) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = + stringResource( + if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { + R.string.receiver_phone_required + } else { + R.string.receiver_phone_invalid + }, + ), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } } item { Spacer(modifier = Modifier.height(24.dp)) @@ -279,6 +316,22 @@ internal fun ReceiverRegisterContent( state = emailState, placeholder = "afternote@email.com", ) + if (!isEmailValid) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = + stringResource( + if (email.isBlank()) { + R.string.receiver_email_required + } else { + R.string.receiver_email_invalid + }, + ), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } } item { Spacer(modifier = Modifier.height(24.dp)) @@ -295,7 +348,7 @@ internal fun ReceiverRegisterContent( item { Spacer(modifier = Modifier.height(12.dp)) Text( - text = errorMessage, + text = errorMessage.asString(), modifier = Modifier.fillMaxWidth(), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.error, @@ -313,6 +366,7 @@ private fun ReceiverRegisterContentPreview() { ReceiverRegisterContent( title = "수신자 등록", actionText = "등록", + isPhoneRequired = false, isLoading = false, errorMessage = null, onBackClick = {}, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt index cf72523795..4775d9f9c7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt @@ -1,12 +1,13 @@ package com.afternote.feature.setting.presentation.viewmodel import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.UiText data class ReceiverEditUiState( val isLoading: Boolean = true, val isSaving: Boolean = false, val receiver: ReceiverDetail? = null, - val errorMessage: String? = null, + val errorMessage: UiText? = null, ) sealed interface ReceiverEditEvent { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt index 6eab069269..2d1b0dbf41 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -42,7 +44,7 @@ class ReceiverEditViewModel _uiState.update { it.copy(isLoading = false, receiver = receiver) } }.onFailure { _uiState.update { - it.copy(isLoading = false, errorMessage = "수신자 정보를 불러오지 못했습니다.") + it.copy(isLoading = false, errorMessage = UiText.Resource(R.string.receiver_load_failed)) } } } @@ -56,6 +58,21 @@ class ReceiverEditViewModel message: String, ) { if (_uiState.value.isSaving) return + if (!email.isValidReceiverEmail()) { + _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.receiver_email_invalid)) } + return + } + val phoneValidation = phone.validateReceiverPhone(isRequired = true) + if (phoneValidation != ReceiverPhoneValidation.VALID) { + val messageRes = + if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { + R.string.receiver_phone_required + } else { + R.string.receiver_phone_invalid + } + _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } + return + } _uiState.update { it.copy(isSaving = true, errorMessage = null) } viewModelScope.launch { @@ -64,14 +81,17 @@ class ReceiverEditViewModel userRepository.updateReceiver( receiverId = receiverId, name = name, - phone = phone, + phone = phone.normalizeReceiverPhone(), relation = relation, - email = email, + email = email.trim(), ) } - if (receiverUpdateResult.isFailure) { + receiverUpdateResult.exceptionOrNull()?.let { error -> _uiState.update { - it.copy(isSaving = false, errorMessage = "수신자 수정에 실패했습니다.") + it.copy( + isSaving = false, + errorMessage = error.toReceiverFailureMessage(R.string.receiver_edit_failed), + ) } return@launch } @@ -88,7 +108,7 @@ class ReceiverEditViewModel _uiState.update { it.copy( isSaving = false, - errorMessage = "기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다.", + errorMessage = UiText.Resource(R.string.receiver_message_update_partial_failed), ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt new file mode 100644 index 0000000000..5180d82be3 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt @@ -0,0 +1,11 @@ +package com.afternote.feature.setting.presentation.viewmodel + +import androidx.annotation.StringRes +import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.ui.UiText + +internal fun Throwable.toReceiverFailureMessage( + @StringRes fallbackResId: Int, +): UiText = + (this as? ReceiverRequestRejectedException)?.userMessage?.let(UiText::Dynamic) + ?: UiText.Resource(fallbackResId) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt new file mode 100644 index 0000000000..224e5757b1 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt @@ -0,0 +1,30 @@ +package com.afternote.feature.setting.presentation.viewmodel + +private val KOREAN_MOBILE_PHONE_REGEX = Regex("^01(?:0\\d{8}|[16789]\\d{7,8})$") +private val HYPHENATED_PHONE_REGEX = Regex("^\\d{3}-\\d{3,4}-\\d{4}$") +private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + +internal fun String.normalizeReceiverPhone(): String = filter(Char::isDigit) + +internal enum class ReceiverPhoneValidation { + VALID, + REQUIRED, + INVALID, +} + +internal fun String.validateReceiverPhone(isRequired: Boolean): ReceiverPhoneValidation { + if (isBlank()) { + return if (isRequired) ReceiverPhoneValidation.REQUIRED else ReceiverPhoneValidation.VALID + } + val hasValidCharactersAndSeparators = all(Char::isDigit) || matches(HYPHENATED_PHONE_REGEX) + return if (hasValidCharactersAndSeparators && normalizeReceiverPhone().matches(KOREAN_MOBILE_PHONE_REGEX)) { + ReceiverPhoneValidation.VALID + } else { + ReceiverPhoneValidation.INVALID + } +} + +internal fun String.isValidReceiverPhone(isRequired: Boolean = false): Boolean = + validateReceiverPhone(isRequired) == ReceiverPhoneValidation.VALID + +internal fun String.isValidReceiverEmail(): Boolean = EMAIL_REGEX.matches(trim()) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt index 05b393889c..e2816fd7a3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt @@ -1,8 +1,10 @@ package com.afternote.feature.setting.presentation.viewmodel +import com.afternote.core.ui.UiText + data class ReceiverRegisterUiState( val isLoading: Boolean = false, - val errorMessage: String? = null, + val errorMessage: UiText? = null, ) sealed interface ReceiverRegisterEvent { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt index 546ebceda0..b2911cb1fc 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt @@ -2,7 +2,10 @@ package com.afternote.feature.setting.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -31,20 +34,33 @@ class ReceiverRegisterViewModel email: String?, message: String?, ) { + if (!email.orEmpty().isValidReceiverEmail()) { + val messageRes = + if (email.isNullOrBlank()) R.string.receiver_email_required else R.string.receiver_email_invalid + _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } + return + } + if (!phone.orEmpty().isValidReceiverPhone()) { + _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.receiver_phone_invalid)) } + return + } + val normalizedEmail = email.orEmpty().trim() + _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { - runCatching { + runCatchingCancellable { userRepository.createReceiver( name = name, relation = relation, - phone = phone?.takeIf { it.isNotBlank() }, - email = email?.takeIf { it.isNotBlank() }, + phone = phone?.takeIf { it.isNotBlank() }?.normalizeReceiverPhone(), + email = normalizedEmail, message = message?.takeIf { it.isNotBlank() }, ) }.onSuccess { _events.send(ReceiverRegisterEvent.RegisterSuccess) - }.onFailure { - _uiState.update { it.copy(isLoading = false, errorMessage = "수신자 등록에 실패했습니다.") } + }.onFailure { error -> + val errorMessage = error.toReceiverFailureMessage(R.string.receiver_register_failed) + _uiState.update { it.copy(isLoading = false, errorMessage = errorMessage) } } } } diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index ec67cb5e75..bd564c3264 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -156,4 +156,12 @@ 마지막 인사말 마지막 인사말을 입력해주세요 관계를 직접 입력해주세요 + 연락처를 입력해주세요. + 올바른 연락처를 입력해주세요. + 올바른 이메일 주소를 입력해주세요. + 이메일 주소를 입력해주세요. + 수신자 등록에 실패했습니다. 잠시 후 다시 시도해주세요. + 수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요. + 기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다. + 수신자 정보를 불러오지 못했습니다. diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 308fd3458b..ce03d0030b 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -21,6 +21,7 @@ import com.afternote.core.model.delivery.ReceiverDeliveryConditions import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.component.PinSetupStep import com.afternote.feature.setting.presentation.screen.AppLockSetupScreen @@ -53,7 +54,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -import com.afternote.core.domain.testing.FakeUserRepository.ReceiverCreateCall as ReceiverRegistrationCall +import com.afternote.feature.setting.presentation.R as SettingR @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -153,11 +154,8 @@ class SettingAccountSecurityTest { } @Test - fun receiverRegister_blankOptionalFieldsAndFailure_preserveExactPayload() { - val repository = - settingContractUserRepository().apply { - onCreateReceiver = { _, _, _, _, _ -> throw IllegalStateException("offline") } - } + fun receiverRegister_blankRequiredEmail_isRejectedBeforeRepositoryCall() { + val repository = settingContractUserRepository() val viewModel = ReceiverRegisterViewModel(repository) composeRule.runOnIdle { @@ -170,21 +168,10 @@ class SettingAccountSecurityTest { ) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == "수신자 등록에 실패했습니다." + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_email_required) } - assertEquals( - listOf( - ReceiverRegistrationCall( - name = "김수신", - relation = "가족", - phone = null, - email = null, - message = null, - ), - ), - repository.receiverCreateCalls, - ) + assertTrue(repository.receiverCreateCalls.isEmpty()) assertFalse(viewModel.uiState.value.isLoading) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 9cfa89d819..2b18f86bbc 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -1,6 +1,17 @@ package com.afternote.feature.setting.presentation +import androidx.compose.ui.test.assertCountEquals +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.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter @@ -39,7 +50,10 @@ import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.UserApiService +import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.screen.ReceiverEditScreen +import com.afternote.feature.setting.presentation.screen.ReceiverRegisterScreen import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionError import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionViewModel @@ -77,6 +91,7 @@ import com.afternote.core.domain.testing.FakeUserRepository.PushUpdateCall as Co import com.afternote.core.domain.testing.FakeUserRepository.ReceiverCreateCall as CompletionReceiverRegistrationCall import com.afternote.core.domain.testing.FakeUserRepository.ReceiverMessageCall as CompletionReceiverMessageCall import com.afternote.core.domain.testing.FakeUserRepository.ReceiverUpdateCall as CompletionReceiverEditCall +import com.afternote.feature.setting.presentation.R as SettingR @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -303,7 +318,7 @@ class SettingCompletionTest { firstGate.complete(Result.failure(IllegalStateException("temporary failure"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == "수신자 등록에 실패했습니다." + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_register_failed) } assertFalse(viewModel.uiState.value.isLoading) @@ -383,7 +398,7 @@ class SettingCompletionTest { firstBasicGate.complete(Result.failure(IllegalStateException("basic update failed"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == "수신자 수정에 실패했습니다." + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_edit_failed) } assertFalse(viewModel.uiState.value.isSaving) assertTrue(repository.receiverMessageCalls.isEmpty()) @@ -399,7 +414,7 @@ class SettingCompletionTest { retryMessageGate.complete(Result.failure(IllegalStateException("message update failed"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.errorMessage == - "기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다." + UiText.Resource(SettingR.string.receiver_message_update_partial_failed) } assertEquals(listOf(expectedMessageCall), repository.receiverMessageCalls) @@ -425,6 +440,68 @@ class SettingCompletionTest { assertEquals(null, viewModel.uiState.value.errorMessage) } + @Test + fun receiverEdit_blankPhoneWiring_disablesSaveAndShowsRequiredMessage() { + val scenario = + CompletionUserScenario().apply { + receiverDetail = COMPLETION_DEFAULT_RECEIVER_DETAIL.copy(phone = null) + } + val repository = scenario.repository + val viewModel = + ReceiverEditViewModel( + savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), + userRepository = repository, + ) + + composeRule.setContent { + AfternoteTheme { + ReceiverEditScreen( + onBackClick = {}, + onEditSuccess = {}, + viewModel = viewModel, + ) + } + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + viewModel.uiState.value.receiver == scenario.receiverDetail + } + + composeRule.onNodeWithText("연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText("수정").assertIsNotEnabled() + assertTrue(repository.receiverUpdateCalls.isEmpty()) + } + + @Test + fun receiverRegister_blankPhoneWiring_allowsSubmitWithoutPhone() { + val repository = FakeUserRepository(receivers = emptyList()) + val viewModel = ReceiverRegisterViewModel(repository) + + composeRule.setContent { + AfternoteTheme { + ReceiverRegisterScreen( + onBackClick = {}, + onRegisterSuccess = {}, + viewModel = viewModel, + ) + } + } + + composeRule.onNodeWithText("이름을 입력하세요").performTextInput("김수신") + composeRule.onNodeWithText("관계를 선택하세요").performClick() + composeRule.onNodeWithText("어머니").performClick() + composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("afternote@email.com")) + composeRule.onNodeWithText("afternote@email.com").performTextInput("receiver@afternote.local") + + composeRule.onAllNodesWithText("연락처를 입력해주세요.").assertCountEquals(0) + composeRule.onNodeWithText("등록").assertIsEnabled() + + composeRule.onNodeWithText("등록").performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.receiverCreateCalls.size == 1 + } + assertEquals(null, repository.receiverCreateCalls.single().phone) + } + @Test fun deliveryCondition_failureThenRetry_succeedsWithExactReceiverPatch() { setHarnessContent() @@ -660,6 +737,8 @@ private class CompletionUserScenario { var deliveryConditions = ReceiverDeliveryConditions(receiverId = 77L, conditions = completionDefaultDeliveryConditions()) + var receiverDetail: ReceiverDetail = COMPLETION_DEFAULT_RECEIVER_DETAIL + val completedPushUpdates: Int get() = synchronized(this) { pushUpdateCompletions } @@ -690,8 +769,8 @@ private class CompletionUserScenario { takeGate(deliveryUpdateGates, "updateReceiverDeliveryConditions").await().getOrThrow() } onGetReceiverDetail = { receiverId -> - assertEquals(COMPLETION_DEFAULT_RECEIVER_DETAIL.receiverId, receiverId) - COMPLETION_DEFAULT_RECEIVER_DETAIL + assertEquals(this@CompletionUserScenario.receiverDetail.receiverId, receiverId) + this@CompletionUserScenario.receiverDetail } onUpdateReceiver = { _, _, _, _, _ -> takeGate(receiverUpdateGates, "updateReceiver").await().getOrThrow() diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt new file mode 100644 index 0000000000..2a8a1a53ec --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt @@ -0,0 +1,50 @@ +package com.afternote.feature.setting.presentation.viewmodel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverPhoneValidatorTest { + @Test + fun `empty phone is valid for registration because it is optional`() { + assertEquals(ReceiverPhoneValidation.VALID, "".validateReceiverPhone(isRequired = false)) + assertTrue("".isValidReceiverPhone(isRequired = false)) + } + + @Test + fun `empty phone is required for edit and blocks submission`() { + assertEquals(ReceiverPhoneValidation.REQUIRED, "".validateReceiverPhone(isRequired = true)) + assertFalse("".isValidReceiverPhone(isRequired = true)) + } + + @Test + fun `valid mobile phone with or without hyphens is accepted`() { + assertTrue("01012345678".isValidReceiverPhone()) + assertTrue("010-1234-5678".isValidReceiverPhone()) + assertTrue("011-123-4567".isValidReceiverPhone()) + } + + @Test + fun `invalid prefix or length is rejected`() { + assertFalse("02012345678".isValidReceiverPhone()) + assertFalse("0101234567".isValidReceiverPhone()) + assertFalse("010123456789".isValidReceiverPhone()) + assertFalse("abc01012345678".isValidReceiverPhone()) + assertFalse("010--1234-5678".isValidReceiverPhone()) + } + + @Test + fun `phone is normalized to digits`() { + assertEquals("01012345678", "010-1234-5678".normalizeReceiverPhone()) + } + + @Test + fun `email requires local and domain parts`() { + assertTrue("receiver@example.com".isValidReceiverEmail()) + assertTrue(" receiver@example.com ".isValidReceiverEmail()) + assertFalse("".isValidReceiverEmail()) + assertFalse("receiver".isValidReceiverEmail()) + assertFalse("receiver@example".isValidReceiverEmail()) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt new file mode 100644 index 0000000000..26c781e97a --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt @@ -0,0 +1,96 @@ +package com.afternote.feature.setting.presentation.viewmodel + +import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.model.user.ReceiverCreated +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.Before +import org.junit.Test +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverRegisterViewModelTest { + private val createCalls = AtomicInteger() + + @Before + fun setUp() { + createCalls.set(0) + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `invalid email is rejected before repository call`() { + val viewModel = ReceiverRegisterViewModel(repository()) + + viewModel.register("홍길동", "딸", "01012345678", "invalid", null) + + assertEquals(UiText.Resource(R.string.receiver_email_invalid), viewModel.uiState.value.errorMessage) + assertEquals(0, createCalls.get()) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `blank email is rejected as required before repository call`() { + val viewModel = ReceiverRegisterViewModel(repository()) + + viewModel.register("홍길동", "딸", null, "", null) + + assertEquals(UiText.Resource(R.string.receiver_email_required), viewModel.uiState.value.errorMessage) + assertEquals(0, createCalls.get()) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `server rejected message is mapped to dynamic UI text`() { + val serverMessage = "수신자 이메일은 필수입니다." + val error = ReceiverRequestRejectedException(serverMessage, Exception("origin")) + + assertEquals( + UiText.Dynamic(serverMessage), + error.toReceiverFailureMessage(R.string.receiver_register_failed), + ) + } + + @Test + fun `unexpected failure uses safe resource message`() { + val viewModel = ReceiverRegisterViewModel(repository(failure = Exception("internal details"))) + + viewModel.register("홍길동", "딸", "01012345678", "receiver@example.com", null) + + assertEquals(UiText.Resource(R.string.receiver_register_failed), viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + private fun repository(failure: Throwable? = null): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "createReceiver" -> { + createCalls.incrementAndGet() + failure?.let { throw it } + ReceiverCreated(receiverId = 1L, authCode = "auth") + } + + else -> { + error("Unexpected repository call: ${method.name}") + } + } + } as UserRepository +} From f8537903fbacc3a425d9d4797de96f4e570c9ac8 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Tue, 1 Sep 2026 10:49:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(data):=20mapReceiverRequestFailure?= =?UTF-8?q?=EB=A5=BC=20private=EB=A1=9C=20=EC=A2=81=ED=9E=88=EA=B3=A0=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EA=B3=84=EC=95=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/repoimpl/UserRepositoryImpl.kt | 2 +- .../repoimpl/ReceiverRequestFailureTest.kt | 121 ++++++++++++++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt index e50c225a0e..b00d04bab4 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt @@ -284,7 +284,7 @@ class UserRepositoryImpl } } -internal suspend inline fun mapReceiverRequestFailure(request: suspend () -> T): T = +private suspend inline fun mapReceiverRequestFailure(request: suspend () -> T): T = try { request() } catch (error: ApiException) { diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt index d6d9b02dc1..ae59f89b5f 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt @@ -1,16 +1,58 @@ package com.afternote.core.data.repoimpl +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test +/** + * `UserRepositoryImpl` 안의 `mapReceiverRequestFailure` 는 `private` 이라 이 파일에서 직접 호출할 수 + * 없다 — 그 helper 를 공유하는 공개 계약([UserRepositoryImpl.createReceiver])을 통해 같은 회귀를 고정한다. + */ class ReceiverRequestFailureTest { + private fun repositoryThrowingOnCreate(apiError: ApiException) = + UserRepositoryImpl( + userApiService = CreateReceiverThrowingApiService(onCreateReceiver = { throw apiError }), + authRepository = FakeAuthRepository(loggedIn = false), + errorReporter = NoOpErrorReporter, + ) + + private suspend fun createReceiver(repository: UserRepositoryImpl) = + repository.createReceiver( + name = "친구", + relation = "친구", + phone = null, + email = null, + message = null, + ) + @Test - fun `400 server message becomes user-facing domain error`() = + fun `400 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = runBlocking { val apiError = ApiException( @@ -19,8 +61,9 @@ class ReceiverRequestFailureTest { serverMessage = "수신자 이메일은 필수입니다.", fallbackMessage = "수신자 이메일은 필수입니다.", ) + val repository = repositoryThrowingOnCreate(apiError) - val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + val result = runCatching { createReceiver(repository) }.exceptionOrNull() assertTrue(result is ReceiverRequestRejectedException) val domainError = result as ReceiverRequestRejectedException @@ -29,7 +72,7 @@ class ReceiverRequestFailureTest { } @Test - fun `409 server message becomes user-facing domain error`() = + fun `409 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = runBlocking { val apiError = ApiException( @@ -38,14 +81,15 @@ class ReceiverRequestFailureTest { serverMessage = "이미 등록된 수신자입니다.", fallbackMessage = "이미 등록된 수신자입니다.", ) + val repository = repositoryThrowingOnCreate(apiError) - val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + val result = runCatching { createReceiver(repository) }.exceptionOrNull() assertTrue(result is ReceiverRequestRejectedException) } @Test - fun `other 4xx server message is not converted to input error`() = + fun `그 외 4xx 서버 메시지는 입력 오류로 변환하지 않는다`() = runBlocking { val apiError = ApiException( @@ -54,14 +98,15 @@ class ReceiverRequestFailureTest { serverMessage = "존재하지 않는 엔드포인트입니다.", fallbackMessage = "존재하지 않는 엔드포인트입니다.", ) + val repository = repositoryThrowingOnCreate(apiError) - val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + val result = runCatching { createReceiver(repository) }.exceptionOrNull() assertSame(apiError, result) } @Test - fun `5xx server message is not converted to user-facing error`() = + fun `5xx 서버 메시지는 사용자 노출 오류로 바꾸지 않는다`() = runBlocking { val apiError = ApiException( @@ -70,9 +115,69 @@ class ReceiverRequestFailureTest { serverMessage = "internal SQL details", fallbackMessage = "internal SQL details", ) + val repository = repositoryThrowingOnCreate(apiError) - val result = runCatching { mapReceiverRequestFailure { throw apiError } }.exceptionOrNull() + val result = runCatching { createReceiver(repository) }.exceptionOrNull() assertSame(apiError, result) } } + +private object NoOpErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} + +private class CreateReceiverThrowingApiService( + private val onCreateReceiver: suspend (UserCreateReceiverRequestDto) -> BaseResponse, +) : UserApiService { + override suspend fun getReceivers(): BaseResponse> = TODO("이 테스트 미사용") + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = + onCreateReceiver(request) + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyPushSettings(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") +} From 9aeb1e437a23e349c7daa16f2a15914bec0c998f Mon Sep 17 00:00:00 2001 From: kyungmin Date: Tue, 1 Sep 2026 21:42:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(setting):=20=EC=88=98=EC=8B=A0=EC=9E=90?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=98=A4=EB=A5=98=20=EC=9B=90=EB=AC=B8?= =?UTF-8?q?=20=EB=85=B8=EC=B6=9C=EA=B3=BC=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=95=84=EC=88=98=EC=84=B1=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=ED=95=B4=EC=86=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReceiverFailureMessage.toReceiverFailureMessage: ReceiverRequestRejectedException의 서버 원문(userMessage)을 UiText.Dynamic으로 그대로 노출하던 경로를 제거하고 로컬 리소스 (receiver_request_rejected)로 대체한다. feature/receiver의 ReceiverFailureTranslation.kt가 확정한 선례(#1339)와 같은 규칙 — 서버 문구는 로그·리포팅에만 남긴다. - 수신자 등록 화면의 전화번호를 필수로 바꿔 수정 화면(BE UserPatchReceiverRequest의 @NotBlank)과 맞춘다. 전화번호 없이 등록되면 이후 수정 화면에서 아무것도 저장할 수 없는 막다른 상태를 등록 시점에 차단한다 — 이미 같은 ViewModel에서 이메일에 적용된 패턴과 동일하다. Co-Authored-By: Claude Sonnet 5 --- .../screen/ReceiverRegisterScreen.kt | 4 +-- .../viewmodel/ReceiverFailureMessage.kt | 13 ++++++++-- .../viewmodel/ReceiverRegisterViewModel.kt | 11 ++++++-- .../src/main/res/values/strings.xml | 1 + .../presentation/SettingCompletionTest.kt | 25 +++++------------- .../ReceiverRegisterViewModelTest.kt | 26 +++++++++++++++++-- 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt index ba71a0204c..5b1b5b0b8f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt @@ -81,7 +81,7 @@ fun ReceiverRegisterScreen( ReceiverRegisterContent( title = "수신자 등록", actionText = "등록", - isPhoneRequired = false, + isPhoneRequired = true, isLoading = uiState.isLoading, errorMessage = uiState.errorMessage, onBackClick = onBackClick, @@ -366,7 +366,7 @@ private fun ReceiverRegisterContentPreview() { ReceiverRegisterContent( title = "수신자 등록", actionText = "등록", - isPhoneRequired = false, + isPhoneRequired = true, isLoading = false, errorMessage = null, onBackClick = {}, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt index 5180d82be3..f4538178f3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt @@ -3,9 +3,18 @@ package com.afternote.feature.setting.presentation.viewmodel import androidx.annotation.StringRes import com.afternote.core.domain.error.ReceiverRequestRejectedException import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R +/** + * [ReceiverRequestRejectedException.userMessage] 는 서버 원문이라 사용자 화면에 직접 노출하지 + * 않는다(리포팅·로그 용도로만 예외에 남긴다) — feature/receiver 의 ReceiverFailureTranslation.kt + * 이 확정한 선례(#1339)와 같은 규칙이다. + */ internal fun Throwable.toReceiverFailureMessage( @StringRes fallbackResId: Int, ): UiText = - (this as? ReceiverRequestRejectedException)?.userMessage?.let(UiText::Dynamic) - ?: UiText.Resource(fallbackResId) + if (this is ReceiverRequestRejectedException) { + UiText.Resource(R.string.receiver_request_rejected) + } else { + UiText.Resource(fallbackResId) + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt index b2911cb1fc..e32238d1d4 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt @@ -40,8 +40,15 @@ class ReceiverRegisterViewModel _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } return } - if (!phone.orEmpty().isValidReceiverPhone()) { - _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.receiver_phone_invalid)) } + val phoneValidation = phone.orEmpty().validateReceiverPhone(isRequired = true) + if (phoneValidation != ReceiverPhoneValidation.VALID) { + val messageRes = + if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { + R.string.receiver_phone_required + } else { + R.string.receiver_phone_invalid + } + _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } return } val normalizedEmail = email.orEmpty().trim() diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index bd564c3264..5bef489d95 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -162,6 +162,7 @@ 이메일 주소를 입력해주세요. 수신자 등록에 실패했습니다. 잠시 후 다시 시도해주세요. 수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요. + 입력하신 내용을 확인해 주세요. 기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다. 수신자 정보를 불러오지 못했습니다. diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 2b18f86bbc..3f04e31abb 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -1,15 +1,11 @@ package com.afternote.feature.setting.presentation -import androidx.compose.ui.test.assertCountEquals 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.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle @@ -297,7 +293,7 @@ class SettingCompletionTest { CompletionReceiverRegistrationCall( name = "김수신", relation = "가족", - phone = null, + phone = "01012345678", email = "receiver@afternote.local", message = null, ) @@ -306,7 +302,7 @@ class SettingCompletionTest { viewModel.register( name = expectedCall.name, relation = expectedCall.relation, - phone = " ", + phone = expectedCall.phone, email = expectedCall.email, message = "", ) @@ -326,7 +322,7 @@ class SettingCompletionTest { viewModel.register( name = expectedCall.name, relation = expectedCall.relation, - phone = " ", + phone = expectedCall.phone, email = expectedCall.email, message = "", ) @@ -472,7 +468,7 @@ class SettingCompletionTest { } @Test - fun receiverRegister_blankPhoneWiring_allowsSubmitWithoutPhone() { + fun receiverRegister_blankPhoneWiring_disablesSubmitAndShowsRequiredMessage() { val repository = FakeUserRepository(receivers = emptyList()) val viewModel = ReceiverRegisterViewModel(repository) @@ -487,19 +483,12 @@ class SettingCompletionTest { } composeRule.onNodeWithText("이름을 입력하세요").performTextInput("김수신") - composeRule.onNodeWithText("관계를 선택하세요").performClick() - composeRule.onNodeWithText("어머니").performClick() composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("afternote@email.com")) composeRule.onNodeWithText("afternote@email.com").performTextInput("receiver@afternote.local") - composeRule.onAllNodesWithText("연락처를 입력해주세요.").assertCountEquals(0) - composeRule.onNodeWithText("등록").assertIsEnabled() - - composeRule.onNodeWithText("등록").performClick() - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - repository.receiverCreateCalls.size == 1 - } - assertEquals(null, repository.receiverCreateCalls.single().phone) + composeRule.onNodeWithText("연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText("등록").assertIsNotEnabled() + assertTrue(repository.receiverCreateCalls.isEmpty()) } @Test diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt index 26c781e97a..2a4c2ec68b 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt @@ -56,12 +56,34 @@ class ReceiverRegisterViewModelTest { } @Test - fun `server rejected message is mapped to dynamic UI text`() { + fun `blank phone is rejected as required before repository call`() { + val viewModel = ReceiverRegisterViewModel(repository()) + + viewModel.register("홍길동", "딸", null, "receiver@example.com", null) + + assertEquals(UiText.Resource(R.string.receiver_phone_required), viewModel.uiState.value.errorMessage) + assertEquals(0, createCalls.get()) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `invalid phone is rejected before repository call`() { + val viewModel = ReceiverRegisterViewModel(repository()) + + viewModel.register("홍길동", "딸", "123", "receiver@example.com", null) + + assertEquals(UiText.Resource(R.string.receiver_phone_invalid), viewModel.uiState.value.errorMessage) + assertEquals(0, createCalls.get()) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `server rejected message is not shown verbatim to the user`() { val serverMessage = "수신자 이메일은 필수입니다." val error = ReceiverRequestRejectedException(serverMessage, Exception("origin")) assertEquals( - UiText.Dynamic(serverMessage), + UiText.Resource(R.string.receiver_request_rejected), error.toReceiverFailureMessage(R.string.receiver_register_failed), ) } From c4d688bfba8130b552fb0afaadfc6eb60332055c Mon Sep 17 00:00:00 2001 From: kyungmin Date: Tue, 1 Sep 2026 22:00:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(setting):=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=ED=98=B8=EC=B6=9C=EB=B6=80=EA=B0=80=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=84=20isValidReceiverPhone=EC=9D=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전화번호 검증을 validateReceiverPhone(isRequired)로 일원화하며 등록 ViewModel의 유일한 프로덕션 호출부가 없어져, isValidReceiverPhone이 테스트에서만 참조되는 죽은 함수로 남았다(ProductionVisibilityKonsistTest 위반). 함수를 지우고 ReceiverPhoneValidatorTest는 실제 공개 계약인 validateReceiverPhone을 직접 검증한다. Co-Authored-By: Claude Sonnet 5 --- .../viewmodel/ReceiverPhoneValidator.kt | 3 --- .../viewmodel/ReceiverPhoneValidatorTest.kt | 22 +++++++++---------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt index 224e5757b1..d61fb5f36f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt @@ -24,7 +24,4 @@ internal fun String.validateReceiverPhone(isRequired: Boolean): ReceiverPhoneVal } } -internal fun String.isValidReceiverPhone(isRequired: Boolean = false): Boolean = - validateReceiverPhone(isRequired) == ReceiverPhoneValidation.VALID - internal fun String.isValidReceiverEmail(): Boolean = EMAIL_REGEX.matches(trim()) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt index 2a8a1a53ec..d645e1d5a5 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt @@ -7,31 +7,29 @@ import org.junit.Test class ReceiverPhoneValidatorTest { @Test - fun `empty phone is valid for registration because it is optional`() { + fun `empty phone is valid when not required`() { assertEquals(ReceiverPhoneValidation.VALID, "".validateReceiverPhone(isRequired = false)) - assertTrue("".isValidReceiverPhone(isRequired = false)) } @Test - fun `empty phone is required for edit and blocks submission`() { + fun `empty phone is rejected as required when required`() { assertEquals(ReceiverPhoneValidation.REQUIRED, "".validateReceiverPhone(isRequired = true)) - assertFalse("".isValidReceiverPhone(isRequired = true)) } @Test fun `valid mobile phone with or without hyphens is accepted`() { - assertTrue("01012345678".isValidReceiverPhone()) - assertTrue("010-1234-5678".isValidReceiverPhone()) - assertTrue("011-123-4567".isValidReceiverPhone()) + assertEquals(ReceiverPhoneValidation.VALID, "01012345678".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.VALID, "010-1234-5678".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.VALID, "011-123-4567".validateReceiverPhone(isRequired = true)) } @Test fun `invalid prefix or length is rejected`() { - assertFalse("02012345678".isValidReceiverPhone()) - assertFalse("0101234567".isValidReceiverPhone()) - assertFalse("010123456789".isValidReceiverPhone()) - assertFalse("abc01012345678".isValidReceiverPhone()) - assertFalse("010--1234-5678".isValidReceiverPhone()) + assertEquals(ReceiverPhoneValidation.INVALID, "02012345678".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.INVALID, "0101234567".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.INVALID, "010123456789".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.INVALID, "abc01012345678".validateReceiverPhone(isRequired = true)) + assertEquals(ReceiverPhoneValidation.INVALID, "010--1234-5678".validateReceiverPhone(isRequired = true)) } @Test