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..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 @@ -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 } } +private 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..ae59f89b5f --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt @@ -0,0 +1,183 @@ +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 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = + runBlocking { + val apiError = + ApiException( + status = 400, + code = 400, + serverMessage = "수신자 이메일은 필수입니다.", + fallbackMessage = "수신자 이메일은 필수입니다.", + ) + val repository = repositoryThrowingOnCreate(apiError) + + val result = runCatching { createReceiver(repository) }.exceptionOrNull() + + assertTrue(result is ReceiverRequestRejectedException) + val domainError = result as ReceiverRequestRejectedException + assertEquals("수신자 이메일은 필수입니다.", domainError.userMessage) + assertSame(apiError, domainError.cause) + } + + @Test + fun `409 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = + runBlocking { + val apiError = + ApiException( + status = 409, + code = 409, + serverMessage = "이미 등록된 수신자입니다.", + fallbackMessage = "이미 등록된 수신자입니다.", + ) + val repository = repositoryThrowingOnCreate(apiError) + + val result = runCatching { createReceiver(repository) }.exceptionOrNull() + + assertTrue(result is ReceiverRequestRejectedException) + } + + @Test + fun `그 외 4xx 서버 메시지는 입력 오류로 변환하지 않는다`() = + runBlocking { + val apiError = + ApiException( + status = 404, + code = 404, + serverMessage = "존재하지 않는 엔드포인트입니다.", + fallbackMessage = "존재하지 않는 엔드포인트입니다.", + ) + val repository = repositoryThrowingOnCreate(apiError) + + val result = runCatching { createReceiver(repository) }.exceptionOrNull() + + assertSame(apiError, result) + } + + @Test + fun `5xx 서버 메시지는 사용자 노출 오류로 바꾸지 않는다`() = + runBlocking { + val apiError = + ApiException( + status = 500, + code = 500, + serverMessage = "internal SQL details", + fallbackMessage = "internal SQL details", + ) + val repository = repositoryThrowingOnCreate(apiError) + + 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("이 테스트 미사용") +} 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..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 @@ -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 = true, 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 = true, 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..f4538178f3 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt @@ -0,0 +1,20 @@ +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 = + 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/ReceiverPhoneValidator.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt new file mode 100644 index 0000000000..d61fb5f36f --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt @@ -0,0 +1,27 @@ +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.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..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 @@ -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,40 @@ 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 + } + 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() + _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..5bef489d95 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -156,4 +156,13 @@ 마지막 인사말 마지막 인사말을 입력해주세요 관계를 직접 입력해주세요 + 연락처를 입력해주세요. + 올바른 연락처를 입력해주세요. + 올바른 이메일 주소를 입력해주세요. + 이메일 주소를 입력해주세요. + 수신자 등록에 실패했습니다. 잠시 후 다시 시도해주세요. + 수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요. + 입력하신 내용을 확인해 주세요. + 기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다. + 수신자 정보를 불러오지 못했습니다. 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..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,6 +1,13 @@ package com.afternote.feature.setting.presentation +import androidx.compose.ui.test.assertIsDisplayed +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.onNodeWithText +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 +46,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 +87,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) @@ -282,7 +293,7 @@ class SettingCompletionTest { CompletionReceiverRegistrationCall( name = "김수신", relation = "가족", - phone = null, + phone = "01012345678", email = "receiver@afternote.local", message = null, ) @@ -291,7 +302,7 @@ class SettingCompletionTest { viewModel.register( name = expectedCall.name, relation = expectedCall.relation, - phone = " ", + phone = expectedCall.phone, email = expectedCall.email, message = "", ) @@ -303,7 +314,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) @@ -311,7 +322,7 @@ class SettingCompletionTest { viewModel.register( name = expectedCall.name, relation = expectedCall.relation, - phone = " ", + phone = expectedCall.phone, email = expectedCall.email, message = "", ) @@ -383,7 +394,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 +410,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 +436,61 @@ 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_disablesSubmitAndShowsRequiredMessage() { + val repository = FakeUserRepository(receivers = emptyList()) + val viewModel = ReceiverRegisterViewModel(repository) + + composeRule.setContent { + AfternoteTheme { + ReceiverRegisterScreen( + onBackClick = {}, + onRegisterSuccess = {}, + viewModel = viewModel, + ) + } + } + + composeRule.onNodeWithText("이름을 입력하세요").performTextInput("김수신") + composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("afternote@email.com")) + composeRule.onNodeWithText("afternote@email.com").performTextInput("receiver@afternote.local") + + composeRule.onNodeWithText("연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText("등록").assertIsNotEnabled() + assertTrue(repository.receiverCreateCalls.isEmpty()) + } + @Test fun deliveryCondition_failureThenRetry_succeedsWithExactReceiverPatch() { setHarnessContent() @@ -660,6 +726,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 +758,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..d645e1d5a5 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt @@ -0,0 +1,48 @@ +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 when not required`() { + assertEquals(ReceiverPhoneValidation.VALID, "".validateReceiverPhone(isRequired = false)) + } + + @Test + fun `empty phone is rejected as required when required`() { + assertEquals(ReceiverPhoneValidation.REQUIRED, "".validateReceiverPhone(isRequired = true)) + } + + @Test + fun `valid mobile phone with or without hyphens is accepted`() { + 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`() { + 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 + 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..2a4c2ec68b --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt @@ -0,0 +1,118 @@ +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 `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.Resource(R.string.receiver_request_rejected), + 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 +}