Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand All @@ -40,6 +42,9 @@ import com.afternote.core.ui.button.AfternoteButtonType
import com.afternote.core.ui.mvi.ObserveSignal
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.topbar.DetailTopBar
import com.afternote.feature.setting.presentation.R
import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation
import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone

@Composable
internal fun ProfileEditScreen(
Expand Down Expand Up @@ -147,6 +152,7 @@ private fun ProfileEditForm(
val nameState = rememberTextFieldState(initialText = state.name)
val phoneState = rememberTextFieldState(initialText = state.phone)
val emailState = rememberTextFieldState(initialText = state.email)
val isPhoneInvalid = phoneState.text.toString().validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID

LazyColumn(
modifier = modifier.fillMaxSize(),
Expand Down Expand Up @@ -199,8 +205,18 @@ private fun ProfileEditForm(
AfternoteTextField(
state = phoneState,
placeholder = "연락처를 지정해주세요",
keyboardType = KeyboardType.Phone,
isError = isPhoneInvalid,
modifier = Modifier.fillMaxWidth(),
)
if (isPhoneInvalid) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.setting_receiver_phone_invalid),
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.error,
)
}
}
}
item {
Expand All @@ -225,7 +241,7 @@ private fun ProfileEditForm(
AfternoteButton(
text = "수정하기",
onClick = { onUpdateClick(nameState.text.toString(), phoneState.text.toString()) },
type = if (state.isUpdateLocked) AfternoteButtonType.Un else AfternoteButtonType.Default,
type = if (state.isUpdateLocked || isPhoneInvalid) AfternoteButtonType.Un else AfternoteButtonType.Default,
modifier =
Modifier
.padding(horizontal = 20.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.afternote.core.domain.repository.MyProfileRepository
import com.afternote.core.domain.repository.PhotoUploadRepository
import com.afternote.core.domain.result.runCatchingCancellable
import com.afternote.core.ui.mvi.MviViewModel
import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation
import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
Expand Down Expand Up @@ -95,6 +97,7 @@ internal class ProfileEditViewModel
) {
val current = currentState as? ProfileEditUiState.Success ?: return
if (current.isUpdateLocked) return
if (phone.validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID) return
dispatch(ProfileEditReducerEvent.Updating)
viewModelScope.launch {
runCatchingCancellable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.afternote.feature.setting.presentation.profile

import android.text.InputType
import android.view.inputmethod.EditorInfo
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextReplacement
import com.afternote.core.domain.testing.FakeMyProfileRepository
import com.afternote.core.domain.testing.FakePhotoUploadRepository
import com.afternote.core.model.user.User
import com.afternote.core.ui.theme.AfternoteTheme
import kotlinx.coroutines.awaitCancellation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@OptIn(ExperimentalComposeUiApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProfileEditPhoneTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun `연락처를 누르면 전화번호 키보드를 요청한다`() {
val viewModel = viewModel(FakeMyProfileRepository(profile = PROFILE))
var inputType: Int? = null
composeRule.setContent {
AfternoteTheme {
InterceptPlatformTextInput(
interceptor = { request, _ ->
val editorInfo = EditorInfo()
request.createInputConnection(editorInfo)
inputType = editorInfo.inputType
awaitCancellation()
},
) {
ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel)
}
}
}
awaitSuccess(viewModel)

phoneField(PHONE).performScrollTo().performClick()

composeRule.waitUntil(TIMEOUT_MILLIS) { inputType != null }
composeRule.runOnIdle { assertEquals(InputType.TYPE_CLASS_PHONE, inputType!! and InputType.TYPE_MASK_CLASS) }
}

@Test
fun `서버의 잘못된 연락처에도 오류가 보이고 저장이 막힌다`() {
val repository = FakeMyProfileRepository(profile = PROFILE.copy(phone = INVALID_PHONE))
val viewModel = viewModel(repository)
setContent(viewModel)
awaitSuccess(viewModel)

scrollToText(PHONE_ERROR)
composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed()
scrollToText("수정하기")
composeRule
.onNodeWithText("수정하기")
.assertIsNotEnabled()
.performClick()

composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) }
}

@Test
fun `붙여넣은 잘못된 연락처를 고치면 오류가 해제되고 저장된다`() {
val repository = FakeMyProfileRepository(profile = PROFILE)
val viewModel = viewModel(repository)
var navigatedBack = 0
setContent(viewModel) { navigatedBack++ }
awaitSuccess(viewModel)

phoneField(PHONE).performScrollTo().performTextReplacement(INVALID_PHONE)
scrollToText(PHONE_ERROR)
composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed()
scrollToText("수정하기")
composeRule.onNodeWithText("수정하기").assertIsNotEnabled()
composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) }

scrollToText(INVALID_PHONE)
phoneField(INVALID_PHONE).performTextReplacement("010-5555-6666")
composeRule.onNodeWithText(PHONE_ERROR).assertDoesNotExist()
scrollToText("수정하기")
composeRule
.onNodeWithText("수정하기")
.assertIsEnabled()
.performClick()

composeRule.waitUntil(TIMEOUT_MILLIS) { navigatedBack == 1 }
composeRule.runOnIdle {
assertEquals(
listOf(FakeMyProfileRepository.ProfileUpdateCall("기존 이름", "010-5555-6666", null)),
repository.profileUpdateCalls,
)
}
}

private fun scrollToText(value: String) {
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(value))
}

private fun phoneField(value: String) = composeRule.onNode(hasSetTextAction() and hasText(value))

private fun setContent(
viewModel: ProfileEditViewModel,
onBackClick: () -> Unit = {},
) {
composeRule.setContent {
AfternoteTheme {
ProfileEditScreen(onBackClick = onBackClick, onWithdrawGuideClick = {}, viewModel = viewModel)
}
}
}

private fun viewModel(repository: FakeMyProfileRepository) = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict())

private fun awaitSuccess(viewModel: ProfileEditViewModel) {
composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success }
composeRule.waitForIdle()
}

private companion object {
const val TIMEOUT_MILLIS = 5_000L
const val PHONE = "01012345678"
const val INVALID_PHONE = "01055556666ggyyy"
const val PHONE_ERROR = "올바른 연락처를 입력해주세요."
val PROFILE = User("기존 이름", "qa@afternote.local", PHONE, null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,68 @@ class ProfileEditViewModelTest {
Dispatchers.resetMain()
}

@Test
fun `잘못된 연락처는 사진 업로드와 프로필 수정 전에 차단한다`() =
runTest(dispatcher) {
for (phone in listOf("01055556666ggyyy", "0101234567", "02012345678", "010--1234-5678")) {
val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = phone))
val uploadRepository = FakePhotoUploadRepository.strict()
val viewModel = ProfileEditViewModel(profileRepository, uploadRepository)
runCurrent()
viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO))

viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone))
runCurrent()

assertTrue(profileRepository.profileUpdateCalls.isEmpty())
assertTrue(uploadRepository.uploads.isEmpty())
assertFalse(viewModel.success().isUpdating)
assertNull(viewModel.success().pendingEvent)
assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri)
}
}

@Test
fun `빈 연락처와 유효한 휴대폰의 기존 저장 값을 보존한다`() =
runTest(dispatcher) {
for (phone in listOf("", " ", "01012345678", "010-1234-5678", "011-123-4567")) {
val profileRepository = FakeMyProfileRepository(profile = SAVED)
val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict())
runCurrent()

viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone))
runCurrent()

assertEquals(
listOf(ProfileUpdateCall(name = "새 이름", phone = phone.takeIf { it.isNotBlank() }, profileImageUrl = null)),
profileRepository.profileUpdateCalls,
)
assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent)
}
}

@Test
fun `잘못된 연락처를 고치면 보관한 사진과 함께 저장할 수 있다`() =
runTest(dispatcher) {
val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = "01055556666ggyyy"))
val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY)
val viewModel = ProfileEditViewModel(profileRepository, uploadRepository)
runCurrent()
viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO))
viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666ggyyy"))
runCurrent()

viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666"))
runCurrent()

assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads)
assertEquals(
listOf(ProfileUpdateCall(name = "새 이름", phone = "01055556666", profileImageUrl = UPLOADED_KEY)),
profileRepository.profileUpdateCalls,
)
assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent)
}

@Test
fun `서버 프로필 사진을 싣고 고른 사진은 저장 전까지 그 위에만 보인다`() =
runTest(dispatcher) {
Expand Down
Loading