diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt new file mode 100644 index 000000000..6f0607263 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt @@ -0,0 +1,120 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** #1438: 실제 SettingNavHost의 entry 이탈이 보류 중 저장을 취소하고 새 entry를 만드는지 검증한다. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ProfileSaveBackAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Inject + lateinit var profileRepository: MyProfileRepository + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun systemBackDuringPendingSaveCancelsRequestAndReentryLoadsServerProfile() { + // 기존 app Hilt fixture가 제공하는 동일 저장소의 응답만 보류한다. 프로덕션 nav/VM은 그대로다. + val repository = profileRepository as FakeUserRepository + val saved = User("서버 이름", "qa@example.test", "01000000000", null) + repository.profile = saved + val pendingSave = CompletableDeferred() + val cancelled = CompletableDeferred() + var exits = 0 + repository.onUpdateMyProfile = { _, _, _ -> + try { + pendingSave.await() + } catch (error: CancellationException) { + cancelled.complete(Unit) + throw error + } + } + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + ) + } + } + } + waitForText("프로필 수정") + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("프로필 설정") + composeRule.onAllNodes(hasSetTextAction())[0].performTextReplacement("저장 중 임시 이름") + composeRule.onNodeWithText("수정하기").performScrollTo().performClick() + composeRule.waitUntil(10_000) { repository.profileUpdateCalls.size == 1 } + composeRule.onNodeWithText("수정하기").assertIsNotEnabled() + assertFalse(pendingSave.isCompleted) + + // Activity의 실제 back dispatcher가 SettingNavHost/NavDisplay의 back 처리를 실행한다. + composeRule.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + waitForText("프로필 수정") + composeRule.waitUntil(10_000) { cancelled.isCompleted } + assertEquals(0, exits) + assertEquals(saved, repository.profile) + + val loadsBeforeReentry = repository.profileCalls + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("서버 이름") + composeRule.waitUntil(10_000) { repository.profileCalls > loadsBeforeReentry } + composeRule.onNodeWithText("수정하기").performScrollTo().assertIsEnabled() + + // 취소 뒤 늦은 성공도 현재 entry를 닫거나 서버 fake의 정본을 바꾸지 않는다. + pendingSave.complete(saved.copy(name = "저장 중 임시 이름")) + composeRule.waitForIdle() + composeRule.onNodeWithText("프로필 설정").assertIsDisplayed() + assertEquals(saved, repository.profile) + assertEquals(1, repository.profileUpdateCalls.size) + assertTrue(cancelled.isCompleted) + assertEquals(0, exits) + } + + private fun waitForText(text: String) { + composeRule.waitUntil(10_000) { composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt index 3084d5a82..a6001854a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt @@ -4,6 +4,10 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ProfileEditIntent : MviIntent { + data class SelectPhoto( + val uri: String, + ) : ProfileEditIntent + data class UpdateProfile( val name: String, val phone: String, @@ -20,6 +24,11 @@ internal sealed interface ProfileEditReducerEvent : ReducerEvent { val name: String, val phone: String, val email: String, + val profileImageUrl: String?, + ) : ProfileEditReducerEvent + + data class PhotoSelected( + val uri: String, ) : ProfileEditReducerEvent data object LoadFailed : ProfileEditReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 7caf1537d..6d74e3b99 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -1,5 +1,9 @@ package com.afternote.feature.setting.presentation.profile +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,7 +21,12 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,12 +34,12 @@ 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.ProfileImagePicker import com.afternote.core.ui.button.AfternoteButton 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.shared.component.ProfilePhotoWithAddBadge @Composable internal fun ProfileEditScreen( @@ -40,6 +49,11 @@ internal fun ProfileEditScreen( viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val onPickProfileImage = + rememberProfileImagePicker( + canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false, + onPhotoPicked = { viewModel.onIntent(ProfileEditIntent.SelectPhoto(it)) }, + ) (uiState as? ProfileEditUiState.Success)?.pendingEvent?.let { pendingEvent -> ObserveSignal( @@ -57,6 +71,7 @@ internal fun ProfileEditScreen( ProfileEditContent( uiState = uiState, + onPickImageClick = onPickProfileImage, onIntent = viewModel::onIntent, onBackClick = onBackClick, onWithdrawGuideClick = onWithdrawGuideClick, @@ -67,6 +82,7 @@ internal fun ProfileEditScreen( @Composable private fun ProfileEditContent( uiState: ProfileEditUiState, + onPickImageClick: () -> Unit, onIntent: (ProfileEditIntent) -> Unit, onBackClick: () -> Unit, onWithdrawGuideClick: () -> Unit, @@ -98,6 +114,7 @@ private fun ProfileEditContent( is ProfileEditUiState.Success -> { ProfileEditForm( state = state, + onPickImageClick = onPickImageClick, onUpdateClick = { name, phone -> onIntent(ProfileEditIntent.UpdateProfile(name, phone)) }, onWithdrawGuideClick = onWithdrawGuideClick, modifier = Modifier.padding(innerPadding), @@ -122,6 +139,7 @@ private fun ProfileEditContent( @Composable private fun ProfileEditForm( state: ProfileEditUiState.Success, + onPickImageClick: () -> Unit, onUpdateClick: (name: String, phone: String) -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, @@ -141,7 +159,10 @@ private fun ProfileEditForm( .padding(top = 50.dp), contentAlignment = Alignment.Center, ) { - ProfilePhotoWithAddBadge() + ProfileImagePicker( + onPickClick = onPickImageClick, + displayImageUri = state.displayImageUri, + ) } } item { @@ -232,3 +253,38 @@ private fun ProfileEditForm( } } } + +/** + * 갤러리 사진 선택기를 등록하고, 누르면 띄우는 함수를 돌려준다. + * + * 온보딩 프로필 화면과 같이 갤러리 전용이다(`PickVisualMedia.ImageOnly`). 취소 결과(`null`)는 선택 + * 변경이 아니라서 기존 사진을 그대로 둔다(온보딩 #1113·#1115 와 같은 처리). + * + * 돌아온 사진은 [canAcceptPhoto] 가 참일 때까지 [rememberSaveable] 에 보관했다가 넘긴다. 갤러리에 + * 다녀오는 사이 프로세스가 회수되면 결과가 새 ViewModel 의 프로필 조회보다 먼저 도착하고, 저장 중에 + * 돌아온 결과는 ViewModel 이 받지 않기 때문이다. 바로 넘기면 두 경우 모두 고른 사진이 사라진다. + */ +@Composable +private fun rememberProfileImagePicker( + canAcceptPhoto: Boolean, + onPhotoPicked: (String) -> Unit, +): () -> Unit { + val queuedPhoto = rememberSaveable { mutableStateOf(null) } + val currentOnPhotoPicked by rememberUpdatedState(onPhotoPicked) + + LaunchedEffect(queuedPhoto.value, canAcceptPhoto) { + val uri = queuedPhoto.value + if (uri != null && canAcceptPhoto) { + queuedPhoto.value = null + currentOnPhotoPicked(uri) + } + } + + val launcher = + rememberLauncherForActivityResult(PickVisualMedia()) { uri: Uri? -> + uri?.let { queuedPhoto.value = it.toString() } + } + return remember(launcher) { + { launcher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt index 28d930b9b..06a7a6efd 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt @@ -5,15 +5,25 @@ import com.afternote.core.ui.mvi.UiState internal sealed interface ProfileEditUiState : UiState { data object Loading : ProfileEditUiState + /** + * @param profileImageUrl 서버가 내려준 표시용 프로필 사진 주소. 아직 사진이 없으면 널이다. + * @param selectedImageUri 이 화면에서 고르기만 하고 아직 저장하지 않은 사진의 content URI. + * 저장이 성공하기 전까지 서버 사진은 건드리지 않는다. 선택 취소나 저장 실패가 기존 사진을 + * 지우지 않는 자리다(#1438). + */ data class Success( val name: String, val phone: String, val email: String, + val profileImageUrl: String? = null, + val selectedImageUri: String? = null, val isUpdating: Boolean = false, /** 수정을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ val isUpdated: Boolean = false, val pendingEvent: ProfileEditEvent? = null, ) : ProfileEditUiState { + /** 아바타에 그릴 사진. 저장 전 선택이 서버 사진보다 앞선다. */ + val displayImageUri: String? get() = selectedImageUri ?: profileImageUrl val isUpdateLocked: Boolean get() = isUpdating || isUpdated } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index fcf807dad..1a29df430 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -2,17 +2,21 @@ package com.afternote.feature.setting.presentation.profile import androidx.lifecycle.viewModelScope 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +private const val PROFILE_UPLOAD_DIRECTORY = "profiles" + @HiltViewModel internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, + private val photoUploadRepository: PhotoUploadRepository, ) : MviViewModel(ProfileEditUiState.Loading) { init { loadProfile() @@ -20,6 +24,7 @@ internal class ProfileEditViewModel override fun onIntent(intent: ProfileEditIntent) { when (intent) { + is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri)) is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) is ProfileEditIntent.ConsumeEvent -> dispatch(ProfileEditReducerEvent.EventConsumed(intent.event)) } @@ -31,7 +36,16 @@ internal class ProfileEditViewModel ): ProfileEditUiState = when (event) { is ProfileEditReducerEvent.Loaded -> { - ProfileEditUiState.Success(name = event.name, phone = event.phone, email = event.email) + ProfileEditUiState.Success( + name = event.name, + phone = event.phone, + email = event.email, + profileImageUrl = event.profileImageUrl, + ) + } + + is ProfileEditReducerEvent.PhotoSelected -> { + state.updateSuccess { if (it.isUpdating) it else it.copy(selectedImageUri = event.uri) } } ProfileEditReducerEvent.LoadFailed -> { @@ -66,6 +80,7 @@ internal class ProfileEditViewModel name = user.name, phone = user.phone.orEmpty(), email = user.email, + profileImageUrl = user.profileImageUrl, ), ) }.onFailure { @@ -83,10 +98,15 @@ internal class ProfileEditViewModel dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { + val uploadedKey = + current.selectedImageUri?.let { uri -> + photoUploadRepository.upload(uri, PROFILE_UPLOAD_DIRECTORY).getOrThrow().fileKey + } myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, - profileImageUrl = null, + // 서버는 표시용 URL 대신 업로드 키를 받아 승격한다. 널이면 기존 사진을 유지한다. + profileImageUrl = uploadedKey, ) }.onSuccess { dispatch(ProfileEditReducerEvent.UpdateSucceeded) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt index 6636c642b..b7082c635 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt @@ -13,16 +13,14 @@ import androidx.compose.ui.unit.dp import com.afternote.feature.setting.presentation.R /** - * 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 아직 picker 가 붙어 있지 않다. + * 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 picker 가 붙어 있지 않다. * - * 프로필 이미지 크기 134dp 의 단일 출처다(#1782). setting 의 프로필 편집·수신인 등록 두 화면이 - * 같은 블록을 복제하고 있어 여기로 모았다. + * 남은 소비처는 수신인 등록 화면 하나다. 프로필 편집 화면은 사진 선택을 붙이면서(#1438) core:ui 의 + * [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 옮겼다. 시안의 프로필 노드가 + * core 자산(`core_ui_ic_profile_placeholder`, 48dp 배지)과 같다는 #2003 의 판정을 따랐다. * - * core:ui 의 [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 흡수하지 않은 - * 이유는 자산과 배지 기하가 달라서다 — 이쪽은 `ic_default_profile`(60dp 뷰포트) 과 - * 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓰고, core 쪽은 - * `core_ui_ic_profile_placeholder` 와 48dp `PlusBadgeButton` 을 쓴다. 바꾸면 픽셀이 움직인다. - * 수렴은 시안 대조가 선행돼야 하므로 #2003 으로 분리했다. + * 이쪽은 `ic_default_profile`(60dp 뷰포트)과 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓴다. + * 수신인 등록 화면의 수렴은 #2003 에 남아 있다. */ @Composable fun ProfilePhotoWithAddBadge(modifier: Modifier = Modifier) { 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 1f0df3aec..7f94d4e02 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 @@ -9,6 +9,7 @@ import androidx.compose.ui.test.performTextInput import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository.DeliveryUpdateCall import com.afternote.core.model.delivery.ConditionState @@ -72,7 +73,7 @@ class SettingAccountSecurityTest { settingContractProfileRepository().apply { onGetMyProfile = { throw IllegalStateException("profile unavailable") } } - val loadFailureViewModel = ProfileEditViewModel(loadFailureRepository) + val loadFailureViewModel = ProfileEditViewModel(loadFailureRepository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { loadFailureViewModel.uiState.value == ProfileEditUiState.Error @@ -92,7 +93,7 @@ class SettingAccountSecurityTest { assertTrue(loadFailureRepository.profileUpdateCalls.isEmpty()) val updateFailureRepository = settingContractProfileRepository() - val updateFailureViewModel = ProfileEditViewModel(updateFailureRepository) + val updateFailureViewModel = ProfileEditViewModel(updateFailureRepository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { updateFailureViewModel.uiState.value is ProfileEditUiState.Success } 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 08c4f6b51..aaeb57841 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 @@ -18,6 +18,7 @@ import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem @@ -100,7 +101,7 @@ class SettingCompletionTest { val scenario = CompletionUserScenario() val repository = scenario.profileRepository val updateGate = scenario.enqueueProfileUpdate() - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index 56787ff68..4f78f1ac5 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeAuthRepository 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.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting @@ -146,7 +147,7 @@ class SettingCoroutineCancellationTest { FakeMyProfileRepository().apply { onGetMyProfile = { pending.await() } } - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) val store = storeHolding(viewModel) runCurrent() assertTrue(pending.isStarted) @@ -166,7 +167,7 @@ class SettingCoroutineCancellationTest { FakeMyProfileRepository(profile = PROFILE).apply { onUpdateMyProfile = { _, _, _ -> pending.await() } } - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) val store = storeHolding(viewModel) runCurrent() @@ -183,12 +184,39 @@ class SettingCoroutineCancellationTest { name = PROFILE.name, phone = PROFILE.phone.orEmpty(), email = PROFILE.email, + profileImageUrl = PROFILE.profileImageUrl, isUpdating = true, ), viewModel.uiState.value, ) } + @Test + fun `프로필 사진 업로드 중 화면을 떠나면 수정 요청도 실패 이벤트도 보내지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val profileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { PROFILE } + } + val uploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> pending.await() }) + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + val store = storeHolding(viewModel) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertEquals(null, (viewModel.uiState.value as ProfileEditUiState.Success).pendingEvent) + } + @Test fun `설정 홈 프로필 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = runTest(dispatcher) { @@ -336,6 +364,7 @@ class SettingCoroutineCancellationTest { private companion object { const val STORE_KEY = "setting-cancellation" const val PUSH_TAG = "PushNotificationVM" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" val PROFILE = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = null) } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt new file mode 100644 index 000000000..4a1657670 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt @@ -0,0 +1,182 @@ +package com.afternote.feature.setting.presentation.profile + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider +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.CompletableDeferred +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR + +/** 실제 Activity와 ActivityResultRegistry를 저장/복원한다. 피커 UI와 OS 프로세스 종료는 실행하지 않는다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditActivityRestorationTest { + @get:Rule + val composeRule = createEmptyComposeRule() + + private var controller: ActivityController? = null + + @After + fun tearDown() { + controller?.pause()?.stop()?.destroy() + controller = null + ProfilePickerRestorationActivity.profileRepository = null + } + + @Test + fun `새 Activity에 복원된 피커 결과는 조회 완료까지 보관하고 두번째 복원 후에도 선택을 유지한다`() { + val initialRepository = FakeMyProfileRepository(profile = SAVED) + ProfilePickerRestorationActivity.profileRepository = initialRepository + val first = launch() + waitForSelection(first, null) + val badge = + ApplicationProvider + .getApplicationContext() + .getString(CoreUiR.string.core_ui_content_description_profile_edit) + composeRule.onNodeWithContentDescription(badge).performClick() + val request = checkNotNull(shadowOf(first).nextStartedActivityForResult) + val firstState = destroyAndSave() + + // NonConfigurationInstances/VM을 넘기지 않고 새 Activity와 새 저장소를 만든다. + val load = CompletableDeferred() + val restoredRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { load.await() } } + ProfilePickerRestorationActivity.profileRepository = restoredRepository + val secondController = Robolectric.buildActivity(ProfilePickerRestorationActivity::class.java).create(firstState) + controller = secondController + val second = secondController.get() + assertNotSame(first, second) + assertNotSame(first.profileViewModel, second.profileViewModel) + assertEquals(ProfileEditUiState.Loading, second.profileViewModel.uiState.value) + + // 아직 compose launcher가 재등록되기 전 raw ActivityResult를 실제 registry에 넣는다. + assertTrue( + second.activityResultRegistry.dispatchResult(request.requestCode, Activity.RESULT_OK, Intent().setData(Uri.parse(PICKED))), + ) + secondController.start().resume().visible() + composeRule.waitForIdle() + assertEquals(ProfileEditUiState.Loading, second.profileViewModel.uiState.value) + + // 결과가 조회 대기 중 rememberSaveable에 들어간 뒤 다시 저장/복원한다. + val secondState = destroyAndSave() + val finalLoad = CompletableDeferred() + val finalRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { finalLoad.await() } } + ProfilePickerRestorationActivity.profileRepository = finalRepository + val third = launch(secondState) + composeRule.waitForIdle() + assertNotSame(second.profileViewModel, third.profileViewModel) + assertEquals(ProfileEditUiState.Loading, third.profileViewModel.uiState.value) + finalLoad.complete(SAVED) + waitForSelection(third, PICKED) + + composeRule.runOnIdle { + assertEquals(PICKED, (third.profileViewModel.uiState.value as ProfileEditUiState.Success).displayImageUri) + assertEquals(1, finalRepository.getProfileCalls) + assertTrue(finalRepository.profileUpdateCalls.isEmpty()) + assertNull((first.profileViewModel.uiState.value as ProfileEditUiState.Success).selectedImageUri) + } + } + + private fun launch(state: Bundle? = null): ProfilePickerRestorationActivity { + val next = + Robolectric + .buildActivity(ProfilePickerRestorationActivity::class.java) + .create(state) + .start() + .resume() + .visible() + controller = next + return next.get() + } + + private fun waitForSelection( + activity: ProfilePickerRestorationActivity, + uri: String?, + ) { + composeRule.waitUntil(5_000) { + val state = activity.profileViewModel.uiState.value + state is ProfileEditUiState.Success && state.selectedImageUri == uri + } + composeRule.waitForIdle() + } + + private fun destroyAndSave(): Bundle { + val active = checkNotNull(controller) + val state = Bundle() + active + .pause() + .saveInstanceState(state) + .stop() + .destroy() + controller = null + // Parcel round trip은 원 Activity/registry의 메모리 객체 공유를 피한다. + val parcel = Parcel.obtain() + return try { + parcel.writeBundle(state) + parcel.setDataPosition(0) + checkNotNull(parcel.readBundle(ProfilePickerRestorationActivity::class.java.classLoader)) + } finally { + parcel.recycle() + } + } + + private companion object { + const val PICKED = "content://media/picker/0/profile/1438" + val SAVED = User(name = "QA", email = "qa@example.test", phone = null, profileImageUrl = null) + } +} + +/** 외부 서버와 DI만 대체한다. Activity의 registry와 saved-state 처리는 그대로 실행한다. */ +class ProfilePickerRestorationActivity : ComponentActivity() { + internal lateinit var profileViewModel: ProfileEditViewModel + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repository = checkNotNull(profileRepository) + profileViewModel = + ViewModelProvider( + this, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) as T + }, + )[ProfileEditViewModel::class.java] + setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = profileViewModel) + } + } + } + + companion object { + internal var profileRepository: FakeMyProfileRepository? = null + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt index b46c6bf4b..c72aed92d 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.setting.presentation.profile import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.model.user.User import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -41,17 +42,17 @@ class ProfileEditMviTest { fun `load fills form and load failure becomes error`() { assertEquals( ProfileEditUiState.Success(name = "홍길동", phone = "", email = "hong@afternote.local"), - ProfileEditViewModel(repository).uiState.value, + ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()).uiState.value, ) val failing = FakeMyProfileRepository(onGetMyProfile = { error("load failed") }) - assertEquals(ProfileEditUiState.Error, ProfileEditViewModel(failing).uiState.value) + assertEquals(ProfileEditUiState.Error, ProfileEditViewModel(failing, FakePhotoUploadRepository.strict()).uiState.value) } @Test fun `update while in flight or after success does not submit again`() { val gate = enqueueUpdate() - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "또 이름", phone = "")) @@ -72,7 +73,7 @@ class ProfileEditMviTest { @Test fun `success signal stays until consumed and consuming clears it once`() { enqueueUpdate().complete(Result.success(PROFILE)) - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01012345678")) @@ -92,7 +93,7 @@ class ProfileEditMviTest { @Test fun `failure allows retry and a late consume does not clear the newer signal`() { enqueueUpdate().complete(Result.failure(IllegalStateException("update failed"))) - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) @@ -110,7 +111,7 @@ class ProfileEditMviTest { @Test fun `cancelled update is not reported as failure`() { enqueueUpdate().completeExceptionally(CancellationException("cancelled")) - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt new file mode 100644 index 000000000..ad82860bf --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt @@ -0,0 +1,205 @@ +package com.afternote.feature.setting.presentation.profile + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +import androidx.test.core.app.ApplicationProvider +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.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import com.afternote.core.ui.R as CoreUiR + +/** + * 프로필 편집 화면의 사진 배지 → 갤러리 선택 결과 처리 (#1438). + * + * 피커 자체는 띄우지 않는다. [LocalActivityResultRegistryOwner] 를 결과를 정해 둔 레지스트리로 갈아 + * 끼우고 화면의 공개 계약(배지 탭 → 피커 요청 → ViewModel 의 선택)만 본다. 온보딩의 + * `OnboardingProfilePickerResultTest` 와 같은 관용구다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditPhotoPickerTest { + @get:Rule + val composeRule = createComposeRule() + + private val pickBadge = + ApplicationProvider + .getApplicationContext() + .getString(CoreUiR.string.core_ui_content_description_profile_edit) + + @Test + fun `배지는 갤러리 이미지 선택만 요청하고 취소는 기존 사진을 지우지 않는다`() { + val registry = GalleryResultRegistry(result = null) + val viewModel = loadedViewModel() + setContent(registry) { viewModel } + awaitSuccess(viewModel) + + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { + assertEquals(listOf(PickVisualMedia.ImageOnly), registry.requestedMediaTypes) + assertNull(viewModel.success().selectedImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().displayImageUri) + } + } + + @Test + fun `갤러리에서 고른 사진은 저장 전 선택으로 아바타에 실린다`() { + val registry = GalleryResultRegistry(result = Uri.parse(PICKED_PHOTO)) + val viewModel = loadedViewModel() + setContent(registry) { viewModel } + awaitSuccess(viewModel) + + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().selectedImageUri == PICKED_PHOTO } + composeRule.runOnIdle { assertEquals(PICKED_PHOTO, viewModel.success().displayImageUri) } + } + + @Test + fun `프로필 조회가 끝나기 전에 돌아온 사진은 보관했다가 조회가 끝나면 넘긴다`() { + // 갤러리에 다녀오는 사이 프로세스가 회수된 경우를 흉내 낸다. 화면은 그대로 복원되고 ViewModel 만 + // 새로 생겨 조회 중일 때 피커 결과가 먼저 도착한다. + val registry = GalleryResultRegistry(result = null, deferResult = true) + val firstViewModel = loadedViewModel() + val pendingProfile = CompletableDeferred() + val restoredViewModel = + ProfileEditViewModel( + FakeMyProfileRepository.strict().apply { onGetMyProfile = { pendingProfile.await() } }, + FakePhotoUploadRepository.strict(), + ) + var current by mutableStateOf(firstViewModel) + setContent(registry) { current } + awaitSuccess(firstViewModel) + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { current = restoredViewModel } + composeRule.waitForIdle() + composeRule.runOnIdle { registry.deliver(Uri.parse(PICKED_PHOTO)) } + composeRule.waitForIdle() + composeRule.runOnIdle { assertEquals(ProfileEditUiState.Loading, restoredViewModel.uiState.value) } + + pendingProfile.complete(SAVED) + + composeRule.waitUntil(TIMEOUT_MILLIS) { + (restoredViewModel.uiState.value as? ProfileEditUiState.Success)?.selectedImageUri == PICKED_PHOTO + } + composeRule.runOnIdle { assertNull(firstViewModel.success().selectedImageUri) } + } + + @Test + fun `저장 중에 돌아온 사진은 보관했다가 저장이 실패한 뒤에 넘긴다`() { + val registry = GalleryResultRegistry(result = null, deferResult = true) + val pendingUpdate = CompletableDeferred() + val viewModel = + ProfileEditViewModel( + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> pendingUpdate.await() } + }, + FakePhotoUploadRepository.strict(), + ) + setContent(registry) { viewModel } + awaitSuccess(viewModel) + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) } + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().isUpdating } + composeRule.runOnIdle { registry.deliver(Uri.parse(PICKED_PHOTO)) } + composeRule.waitForIdle() + composeRule.runOnIdle { assertNull(viewModel.success().selectedImageUri) } + + pendingUpdate.completeExceptionally(IOException("offline")) + + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().selectedImageUri == PICKED_PHOTO } + } + + private fun setContent( + registry: GalleryResultRegistry, + viewModel: () -> ProfileEditViewModel, + ) { + val registryOwner = + object : ActivityResultRegistryOwner { + override val activityResultRegistry = registry + } + composeRule.setContent { + AfternoteTheme { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides registryOwner) { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel()) + } + } + } + } + + private fun awaitSuccess(viewModel: ProfileEditViewModel) { + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } + composeRule.waitForIdle() + } + + private fun loadedViewModel() = + ProfileEditViewModel( + FakeMyProfileRepository.strict().apply { onGetMyProfile = { SAVED } }, + FakePhotoUploadRepository.strict(), + ) + + private fun ProfileEditViewModel.success(): ProfileEditUiState.Success = uiState.value as ProfileEditUiState.Success + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + const val SERVER_IMAGE_URL = "https://cdn.test/profiles/saved.jpg" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" + val SAVED = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = SERVER_IMAGE_URL) + } +} + +/** 갤러리 피커 요청을 기록하고, 정해 둔 결과를 즉시 또는 [deliver] 때 돌려주는 레지스트리. */ +private class GalleryResultRegistry( + private val result: Uri?, + private val deferResult: Boolean = false, +) : ActivityResultRegistry() { + val requestedMediaTypes = mutableListOf() + private var pendingRequestCode: Int? = null + + fun deliver(uri: Uri?) { + val requestCode = checkNotNull(pendingRequestCode) { "피커 요청이 없다" } + pendingRequestCode = null + dispatchResult(requestCode, uri) + } + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + requestedMediaTypes += (input as PickVisualMediaRequest).mediaType + if (deferResult) { + pendingRequestCode = requestCode + } else { + @Suppress("UNCHECKED_CAST") + dispatchResult(requestCode, result as O) + } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt new file mode 100644 index 000000000..706442676 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt @@ -0,0 +1,187 @@ +package com.afternote.feature.setting.presentation.profile + +import com.afternote.core.domain.model.UploadedFile +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 프로필 사진 변경의 저장 계약 (#1438). + * + * 고른 사진은 `profiles` 로 올린 뒤 받은 파일 키로 `PATCH users/me` 를 보낸다. 업로드가 실패하면 + * 수정 요청을 보내지 않고, 선택하지 않았으면 사진 칸을 널로 보내 서버 사진을 그대로 둔다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `서버 프로필 사진을 싣고 고른 사진은 저장 전까지 그 위에만 보인다`() = + runTest(dispatcher) { + val viewModel = ProfileEditViewModel(FakeMyProfileRepository(profile = SAVED), FakePhotoUploadRepository.strict()) + runCurrent() + + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + assertEquals(SERVER_IMAGE_URL, viewModel.success().displayImageUri) + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(PICKED_PHOTO, viewModel.success().displayImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + } + + @Test + fun `고른 사진은 profiles 로 올린 뒤 받은 파일 키로 수정 요청을 보낸다`() = + runTest(dispatcher) { + val order = mutableListOf() + val uploadRepository = + FakePhotoUploadRepository( + onUpload = { _, _ -> + order += "upload" + Result.success(UploadedFile(fileUrl = UPLOADED_URL, fileKey = UPLOADED_KEY)) + }, + ) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> + order += "patch" + SAVED + } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) + runCurrent() + + assertEquals(listOf("upload", "patch"), order) + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = "01011112222", profileImageUrl = UPLOADED_KEY)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + + @Test + fun `사진을 고르지 않으면 업로드 없이 사진 칸을 비워 보내 기존 사진을 유지한다`() = + runTest(dispatcher) { + val profileRepository = FakeMyProfileRepository(profile = SAVED) + val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict()) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) + runCurrent() + + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = null, profileImageUrl = null)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + + @Test + fun `업로드가 실패하면 수정 요청을 보내지 않고 고른 사진을 그대로 둔다`() = + runTest(dispatcher) { + val uploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> Result.failure(IOException("upload failed")) }) + val profileRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { SAVED } } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) + runCurrent() + + assertEquals(1, uploadRepository.uploads.size) + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + assertFalse(viewModel.success().isUpdating) + assertEquals(ProfileEditEvent.UpdateFailure, viewModel.success().pendingEvent) + } + + @Test + fun `수정 요청이 실패해도 고른 사진은 남아 다시 저장할 수 있다`() = + runTest(dispatcher) { + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> throw IOException("offline") } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) + runCurrent() + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertFalse(viewModel.success().isUpdating) + assertEquals(ProfileEditEvent.UpdateFailure, viewModel.success().pendingEvent) + } + + @Test + fun `저장 중에 고른 사진은 받지 않아 올라가는 사진과 보이는 사진이 갈리지 않는다`() = + runTest(dispatcher) { + val pendingUpdate = CompletableDeferred() + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> pendingUpdate.await() } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) + runCurrent() + assertTrue(viewModel.success().isUpdating) + + viewModel.onIntent(ProfileEditIntent.SelectPhoto(OTHER_PHOTO)) + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + pendingUpdate.complete(SAVED) + runCurrent() + } + + private fun ProfileEditViewModel.success(): ProfileEditUiState.Success = uiState.value as ProfileEditUiState.Success + + private companion object { + const val SERVER_IMAGE_URL = "https://cdn.test/profiles/saved.jpg?signature=abc" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" + const val OTHER_PHOTO = "content://media/picker/0/profile/2" + const val UPLOADED_URL = "https://cdn.test/profiles/new.jpg" + const val UPLOADED_KEY = "profiles/tmp/new.jpg" + val SAVED = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = SERVER_IMAGE_URL) + } +}