From 9d8b7fb8692157eb40ae2a1f4b4051d8d810c8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 22:36:47 +0900 Subject: [PATCH 1/7] fix(setting): propagate repository cancellation (#1501) --- .../account/ConnectedAccountsScreen.kt | 2 +- .../account/ConnectedAccountsViewModel.kt | 9 +- .../presentation/home/SettingViewModel.kt | 5 +- .../home/WithdrawConfirmScreen.kt | 2 +- .../notification/NotificationSettingScreen.kt | 2 +- .../notification/PushNotificationScreen.kt | 2 +- .../notification/PushNotificationViewModel.kt | 4 +- .../presentation/profile/ProfileEditScreen.kt | 2 +- .../profile/ProfileEditViewModel.kt | 7 +- .../SettingCoroutineCancellationTest.kt | 368 ++++++++++++++++++ 10 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 6e6d6295f..93774de3f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -22,7 +22,7 @@ import com.afternote.feature.setting.presentation.account.social.requestKakaoAcc import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult @Composable -fun ConnectedAccountsScreen( +internal fun ConnectedAccountsScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ConnectedAccountsViewModel = hiltViewModel(), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 9bb399211..88d50b126 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.account import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.model.user.UserConnectedAccount import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R @@ -15,7 +16,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ConnectedAccountsViewModel +internal class ConnectedAccountsViewModel @Inject constructor( private val accountRepository: SettingAccountRepository, @@ -32,7 +33,7 @@ class ConnectedAccountsViewModel private fun loadConnectedAccounts() { viewModelScope.launch { - runCatching { accountRepository.getConnectedAccounts() } + runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } }.onFailure { @@ -61,7 +62,7 @@ class ConnectedAccountsViewModel accessToken: String, ) { viewModelScope.launch { - runCatching { accountRepository.linkConnectedAccount(provider, accessToken) } + runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } } @@ -69,7 +70,7 @@ class ConnectedAccountsViewModel private fun unlink(provider: String) { viewModelScope.launch { - runCatching { accountRepository.unlinkConnectedAccount(provider) } + runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index d051696f5..e28c05820 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.feature.setting.domain.SettingAccountRepository @@ -42,7 +43,7 @@ sealed interface WithdrawUiState { * 로컬 토큰은 결과 무관하게 정리하도록 위임돼 있다. 호출처는 토큰 정리 여부를 신경 쓸 필요 없음. */ @HiltViewModel -class SettingViewModel +internal class SettingViewModel @Inject constructor( private val authRepository: AuthRepository, @@ -63,7 +64,7 @@ class SettingViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { myProfileRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { profile -> _uiState.value = SettingUiState.Success( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt index 73560e2fb..ea165e883 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt @@ -10,7 +10,7 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.feature.setting.presentation.R @Composable -fun WithdrawConfirmScreen( +internal fun WithdrawConfirmScreen( uiState: SettingUiState, onBackClick: () -> Unit, onWithdrawSuccess: () -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index b5a271b26..011c18032 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -47,7 +47,7 @@ import com.afternote.feature.setting.presentation.notification.component.DeviceA import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem @Composable -fun NotificationSettingScreen( +internal fun NotificationSettingScreen( onBack: () -> Unit, onPushNotificationClick: () -> Unit, viewModel: PushNotificationViewModel = hiltViewModel(), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index 2dcb24789..ea1317618 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -13,7 +13,7 @@ import com.afternote.feature.setting.presentation.notification.component.DeviceA import com.afternote.feature.setting.presentation.notification.component.PushToggleSection @Composable -fun PushNotificationScreen( +internal fun PushNotificationScreen( onBack: () -> Unit, viewModel: PushNotificationViewModel = hiltViewModel(), ) { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index 4e928b830..f55695787 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel -class PushNotificationViewModel +internal class PushNotificationViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -84,7 +84,7 @@ class PushNotificationViewModel private fun loadMarketingConsents() { viewModelScope.launch { Log.d(TAG, "loadMarketingConsents: start") - runCatching { notificationRepository.getMyMarketingConsents() } + runCatchingCancellable { notificationRepository.getMyMarketingConsents() } .onSuccess { consent -> Log.d(TAG, "loadMarketingConsents: success=$consent") _uiState.update { 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 5bb9aff05..956d90c94 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 @@ -36,7 +36,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable -fun ProfileEditScreen( +internal fun ProfileEditScreen( onBackClick: () -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, 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 6fabdd298..9d1101701 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,6 +2,7 @@ package com.afternote.feature.setting.presentation.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -13,7 +14,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ProfileEditViewModel +internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, @@ -30,7 +31,7 @@ class ProfileEditViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { myProfileRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { user -> _uiState.value = ProfileEditUiState.Success( @@ -51,7 +52,7 @@ class ProfileEditViewModel val current = _uiState.value as? ProfileEditUiState.Success ?: return _uiState.update { current.copy(isUpdating = true) } viewModelScope.launch { - runCatching { + runCatchingCancellable { myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, 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 new file mode 100644 index 000000000..52c92558a --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -0,0 +1,368 @@ +package com.afternote.feature.setting.presentation + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +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.model.user.User +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingUiState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationEvent +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +/** + * 설정 화면 ViewModel 의 suspend 저장소 호출이 코루틴 취소를 실패로 둔갑시키지 않는지 검증한다 (#1501). + * + * 취소는 화면 이탈로 [ViewModel.onCleared] 가 불릴 때 실제로 일어나므로, 여기서는 진짜 + * [ViewModelStore] 에 담았다가 [ViewModelStore.clear] 로 끊는다. 저장소 fake 는 응답하지 않고 + * 매달려 있어, 취소가 대기 중인 호출까지 닿았는지를 [PendingRepositoryCall.isCancelled] 로 본다. + * 예외를 직접 던지는 검사에 더해, 실제 ViewModel 종료가 진행 중 요청을 취소하는 경로까지 검증한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingCoroutineCancellationTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ShadowLog.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `연결 계정 조회 중 화면을 떠나면 오류 문구로 덮지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onGetConnectedAccounts = { pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `계정 연결 중 화면을 떠나면 연결 실패 문구를 띄우지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onLinkConnectedAccount = { _, _ -> pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + + viewModel.link(provider = "google", accessToken = "google-token") + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `계정 연결 해제 중 화면을 떠나면 해제 실패 문구를 띄우지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onUnlinkConnectedAccount = { pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + + viewModel.onToggle(provider = "naver", enabled = false) + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `프로필 편집 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeMyProfileRepository().apply { + onGetMyProfile = { pending.await() } + } + val viewModel = ProfileEditViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(ProfileEditUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `프로필 수정 중 화면을 떠나면 갱신 표시를 풀거나 실패 이벤트를 보내지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeMyProfileRepository(profile = PROFILE).apply { + onUpdateMyProfile = { _, _, _ -> pending.await() } + } + val viewModel = ProfileEditViewModel(repository) + val store = storeHolding(viewModel) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals( + ProfileEditUiState.Success( + name = PROFILE.name, + phone = PROFILE.phone.orEmpty(), + email = PROFILE.email, + isUpdating = true, + ), + viewModel.uiState.value, + ) + assertTrue(events.isEmpty()) + } + + @Test + fun `설정 홈 프로필 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val viewModel = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { pending.await() } + }, + accountRepository = FakeSettingAccountRepository.strict(), + ) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(SettingUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `마케팅 동의 조회 중 화면을 떠나면 상태 진단 어느 것도 남기지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = true, mindRecord = false, afterNote = true) } + onGetMyMarketingConsents = { pending.await() } + } + val reporter = RecordingErrorReporter() + val viewModel = pushViewModel(repository, reporter) + val store = storeHolding(viewModel) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + // 푸시 설정 조회는 먼저 정상 종료해 안정된 상태를 만든다. 매달린 것은 마케팅 조회뿐이다. + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(pending.isStarted) + val stableState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(stableState, viewModel.uiState.value) + assertTrue(events.isEmpty()) + assertEquals(0, reporter.recordedStages.size) + assertEquals(emptyList(), pushFailureLogs()) + } + + @Test + fun `마케팅 동의 조회는 성공하면 상태에 반영하고 평범한 실패는 로그만 남긴다`() = + runTest(dispatcher) { + val successReporter = RecordingErrorReporter() + val successViewModel = + pushViewModel( + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { UserMarketingConsent(sms = false, email = true, push = true) } + }, + successReporter, + ) + runCurrent() + + assertFalse(successViewModel.uiState.value.isSmsChecked) + assertTrue(successViewModel.uiState.value.isEmailChecked) + assertTrue(successViewModel.uiState.value.isPushChecked) + assertEquals(emptyList(), pushFailureLogs()) + + ShadowLog.clear() + val failureReporter = RecordingErrorReporter() + val failureViewModel = + pushViewModel( + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { error("marketing consents unavailable") } + }, + failureReporter, + ) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { failureViewModel.events.collect { events += it } } + runCurrent() + + // 평범한 실패는 진단 로그 한 줄로 끝난다. 화면 값은 기본값 그대로이고 안내도 띄우지 않는다. + assertEquals(listOf("loadMarketingConsents: failed"), pushFailureLogs()) + assertTrue(failureViewModel.uiState.value.isSmsChecked) + assertTrue(failureViewModel.uiState.value.isEmailChecked) + assertFalse(failureViewModel.uiState.value.isPushChecked) + assertTrue(events.isEmpty()) + assertEquals(0, failureReporter.recordedStages.size) + } + + @Test + fun `Exception 이 아닌 Throwable 실패도 평소대로 오류 상태로 매핑한다`() = + runTest(dispatcher) { + val viewModel = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { throw NonExceptionFailure() } + }, + accountRepository = FakeSettingAccountRepository.strict(), + ) + runCurrent() + + assertEquals(SettingUiState.Error("프로필을 불러올 수 없습니다."), viewModel.uiState.value) + } + + private fun pushViewModel( + repository: FakeSettingNotificationRepository, + reporter: ErrorReporter, + ) = PushNotificationViewModel( + context = ApplicationProvider.getApplicationContext(), + notificationRepository = repository, + errorReporter = reporter, + ) + + /** 푸시 ViewModel 이 남긴 실패 진단 로그의 메시지 목록. 성공 경로의 `Log.d` 는 세지 않는다. */ + private fun pushFailureLogs(): List = + ShadowLog + .getLogsForTag(PUSH_TAG) + .filter { it.type >= Log.ERROR } + .map { it.msg } + + private fun storeHolding(viewModel: ViewModel): ViewModelStore = ViewModelStore().apply { put(STORE_KEY, viewModel) } + + private class RecordingErrorReporter : ErrorReporter { + val recordedStages = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + recordedStages += attributes["stage"] + } + } + + /** `Exception` 을 상속하지 않는 실패. `runCatching` 계열이 `Throwable` 까지 잡는다는 계약을 고정한다. */ + private class NonExceptionFailure : Throwable("profile backend down") + + private companion object { + const val STORE_KEY = "setting-cancellation" + const val PUSH_TAG = "PushNotificationVM" + val PROFILE = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = null) + } +} + +/** + * 응답하지 않는 저장소 호출. 취소가 이 자리까지 닿았는지를 [isCancelled] 로 드러낸다. + * + * 취소 콜백(`invokeOnCancellation`)은 코루틴이 취소될 때만 호출되므로, 대기 중인 suspend 호출이 + * 실제로 끊겼다는 증거가 된다. + */ +private class PendingRepositoryCall { + @Volatile + var isStarted = false + private set + + @Volatile + var isCancelled = false + private set + + suspend fun await(): T = + suspendCancellableCoroutine { continuation -> + isStarted = true + continuation.invokeOnCancellation { isCancelled = true } + } +} From 57b3ad305a443140e3d279676d455fa480970c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 23 Sep 2026 16:32:01 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(setting):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=95=8C=EB=A6=BC=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20ErrorReporter=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20(#963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뉴스레터·마음의 기록·애프터노트 토글의 공통 저장 경로에서 실패를 recordFailure 로 남긴다. 속성은 stage=push_setting_update 와 push_setting 토글 이름뿐이다. 조회 실패는 Logcat 에 두고, 롤백·실패 안내·재시도 동작은 바꾸지 않는다. --- .../notification/PushNotificationViewModel.kt | 18 ++ .../PushNotificationSaveReportingTest.kt | 218 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index f55695787..6243823ec 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -75,6 +75,7 @@ internal class PushNotificationViewModel ) } }.onFailure { e -> + // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) _uiState.update { it.copy(isLoading = false) } } @@ -179,6 +180,13 @@ internal class PushNotificationViewModel }.onSuccess { _uiState.update { it.withUpdating(update.setting, updating = false) } }.onFailure { failure -> + errorReporter.recordFailure( + failure, + mapOf( + KEY_STAGE to STAGE_PUSH_SETTING_UPDATE, + KEY_PUSH_SETTING to update.setting.reportingName(), + ), + ) failedUpdate = update _uiState.update { it @@ -193,6 +201,8 @@ internal class PushNotificationViewModel companion object { private const val TAG = "PushNotificationVM" private const val KEY_STAGE = "stage" + private const val KEY_PUSH_SETTING = "push_setting" + private const val STAGE_PUSH_SETTING_UPDATE = "push_setting_update" private const val STAGE_SMS_CONSENT = "sms_consent_update" private const val STAGE_EMAIL_CONSENT = "email_consent_update" private const val STAGE_PUSH_CONSENT = "push_consent_update" @@ -205,6 +215,14 @@ private enum class PushSetting { AFTERNOTE, } +/** 진단 속성 값. 토글 종류만 담는 고정 문자열이라 사용자 정보가 섞이지 않는다. */ +private fun PushSetting.reportingName(): String = + when (this) { + PushSetting.NEWSLETTER -> "newsletter" + PushSetting.MIND_RECORD -> "mind_record" + PushSetting.AFTERNOTE -> "afternote" + } + private data class PushSettingUpdate( val setting: PushSetting, val on: Boolean, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt new file mode 100644 index 000000000..df72f27a5 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt @@ -0,0 +1,218 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.lifecycle.ViewModelStore +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 서비스 알림 토글 저장 실패가 개발자 진단으로 남는지 검증한다 (#963). + * + * 저장 실패만 [ErrorReporter] 로 올리고 조회 실패·성공·취소는 올리지 않는다. 기록 속성은 단계와 + * 토글 이름뿐이고, [ErrorReporter.recordFailure] 가 덧붙이는 예외 타입 외에는 아무것도 싣지 않는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PushNotificationSaveReportingTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `세 토글의 저장 실패는 토글마다 고정 속성으로 한 번씩 기록하고 롤백과 실패 안내는 그대로다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, _, _ -> error("server") } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + viewModel.onNewsletterToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + + viewModel.onMindRecordToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + + viewModel.onAfternoteToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + assertEquals( + listOf(saveFailureOf("newsletter"), saveFailureOf("mind_record"), saveFailureOf("afternote")), + reporter.records.map { it.saveContext() }, + ) + reporter.records.forEach { attributes -> + assertTrue("허용되지 않은 속성: ${attributes.keys}", ALLOWED_KEYS.containsAll(attributes.keys)) + assertEquals(IllegalStateException::class.java.name, attributes["error_type"]) + } + } + + @Test + fun `저장에 성공하면 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val viewModel = viewModel(FakeSettingNotificationRepository(pushSetting = ALL_ON), reporter) + runCurrent() + + viewModel.onNewsletterToggle(false) + viewModel.onMindRecordToggle(false) + viewModel.onAfternoteToggle(false) + runCurrent() + + assertFalse(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isMindRecordOn) + assertFalse(viewModel.uiState.value.isAfternoteOn) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `저장 중 화면을 떠나 취소되면 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + var cancelled = false + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, _, _ -> + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancelled = true } + } + } + } + val viewModel = viewModel(repository, reporter) + val store = ViewModelStore().apply { put(STORE_KEY, viewModel) } + runCurrent() + + viewModel.onMindRecordToggle(false) + runCurrent() + assertEquals(1, repository.pushUpdateCalls.size) + + store.clear() + runCurrent() + + assertTrue(cancelled) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `서비스 알림과 마케팅 동의 조회 실패는 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { error("push settings unavailable") } + onGetMyMarketingConsents = { error("marketing consents unavailable") } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + assertEquals(1, repository.getMyPushSettingsCalls) + assertEquals(1, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `재시도가 다시 실패하면 실제 실패마다 한 번씩 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + var remainingFailures = 2 + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, mindRecord, _ -> + if (remainingFailures > 0) { + remainingFailures-- + error("server") + } + ALL_ON.copy(mindRecord = mindRecord ?: ALL_ON.mindRecord) + } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + viewModel.onMindRecordToggle(false) + runCurrent() + viewModel.onSaveFailureRetry() + runCurrent() + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureRetry() + runCurrent() + + assertFalse(viewModel.uiState.value.isMindRecordOn) + assertEquals(3, repository.pushUpdateCalls.size) + assertEquals( + List(2) { saveFailureOf("mind_record") }, + reporter.records.map { it.saveContext() }, + ) + } + + private fun viewModel( + repository: FakeSettingNotificationRepository, + reporter: ErrorReporter, + ) = PushNotificationViewModel( + context = ApplicationProvider.getApplicationContext(), + notificationRepository = repository, + errorReporter = reporter, + ) + + /** [ErrorReporter.recordFailure] 가 정책을 적용한 뒤 넘긴 속성을 그대로 모은다. */ + private class RecordingReporter : ErrorReporter { + val records = mutableListOf>() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + records += attributes + } + } + + private fun Map.saveContext(): Map = filterKeys { it == KEY_STAGE || it == KEY_PUSH_SETTING } + + private fun saveFailureOf(pushSetting: String): Map = + mapOf(KEY_STAGE to "push_setting_update", KEY_PUSH_SETTING to pushSetting) + + private companion object { + const val STORE_KEY = "push-save-reporting" + const val KEY_STAGE = "stage" + const val KEY_PUSH_SETTING = "push_setting" + val ALLOWED_KEYS = setOf(KEY_STAGE, KEY_PUSH_SETTING, "error_type", "error_cause_type") + val ALL_ON = UserPushSetting(timeLetter = true, mindRecord = true, afterNote = true) + } +} From d0b688dbe94a41fb621975c76a4cade5d8bb3099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 23 Sep 2026 16:42:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(setting):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=97=90=EC=84=9C=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=82=AC=EC=A7=84=EC=9D=84=20=EA=B3=A8=EB=9D=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=9C=EB=8B=A4=20(#1438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배지를 core ProfileImagePicker 로 바꿔 갤러리(PickVisualMedia ImageOnly)를 연다. 고른 사진은 저장할 때 profiles 로 올리고 받은 fileKey 로 PATCH users/me 를 보낸다. 업로드가 실패하면 수정 요청을 보내지 않는다. 서버 profileImageUrl 을 아바타에 표시하고, 선택 취소는 기존 사진을 지우지 않는다. 조회 중이거나 저장 중에 돌아온 선택은 받을 수 있을 때까지 보관한다. --- .../presentation/profile/ProfileEditScreen.kt | 58 ++++- .../profile/ProfileEditUiState.kt | 13 +- .../profile/ProfileEditViewModel.kt | 30 ++- .../component/ProfilePhotoWithAddBadge.kt | 14 +- .../SettingAccountSecurityTest.kt | 5 +- .../presentation/SettingCompletionTest.kt | 3 +- .../SettingCoroutineCancellationTest.kt | 35 ++- .../profile/ProfileEditPhotoPickerTest.kt | 205 ++++++++++++++++++ .../profile/ProfileEditViewModelTest.kt | 199 +++++++++++++++++ 9 files changed, 544 insertions(+), 18 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 956d90c94..28a2c0f4b 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 @@ -19,7 +23,10 @@ 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 @@ -28,12 +35,11 @@ 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.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable internal fun ProfileEditScreen( @@ -44,6 +50,12 @@ internal fun ProfileEditScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnBackClick by rememberUpdatedState(onBackClick) + // when 분기 밖에서 만든다. 분기 안에 두면 상태가 바뀔 때 런처 등록과 보관 중인 결과가 함께 사라진다. + val onPickProfileImage = + rememberProfileImagePicker( + canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false, + onPhotoPicked = viewModel::selectProfileImage, + ) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -80,6 +92,7 @@ internal fun ProfileEditScreen( is ProfileEditUiState.Success -> { ProfileEditForm( state = state, + onPickImageClick = onPickProfileImage, onUpdateClick = viewModel::updateProfile, onWithdrawGuideClick = onWithdrawGuideClick, modifier = Modifier.padding(innerPadding), @@ -104,6 +117,7 @@ internal fun ProfileEditScreen( @Composable private fun ProfileEditForm( state: ProfileEditUiState.Success, + onPickImageClick: () -> Unit, onUpdateClick: (name: String, phone: String) -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, @@ -123,7 +137,10 @@ private fun ProfileEditForm( .padding(top = 50.dp), contentAlignment = Alignment.Center, ) { - ProfilePhotoWithAddBadge() + ProfileImagePicker( + onPickClick = onPickImageClick, + displayImageUri = state.displayImageUri, + ) } } item { @@ -214,3 +231,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 bc4fbf71d..429d39ede 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 @@ -3,12 +3,23 @@ package com.afternote.feature.setting.presentation.profile sealed interface ProfileEditUiState { 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?, + val selectedImageUri: String? = null, val isUpdating: Boolean = false, - ) : ProfileEditUiState + ) : ProfileEditUiState { + /** 아바타에 그릴 사진. 저장 전 선택이 서버 사진보다 앞선다. */ + val displayImageUri: String? get() = selectedImageUri ?: profileImageUrl + } data object Error : ProfileEditUiState } 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 9d1101701..daa7f72a0 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.PhotoUploadRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -13,11 +14,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * `POST /files/presigned-url` 의 `directory` 값. `PATCH users/me` 가 받은 키를 `profiles` 로 + * 승격하는 서버 계약과 짝이다. + */ +private const val PROFILE_UPLOAD_DIRECTORY = "profiles" + @HiltViewModel internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, + private val photoUploadRepository: PhotoUploadRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileEditUiState.Loading) val uiState = _uiState.asStateFlow() @@ -38,6 +46,7 @@ internal class ProfileEditViewModel name = user.name, phone = user.phone.orEmpty(), email = user.email, + profileImageUrl = user.profileImageUrl, ) }.onFailure { _uiState.value = ProfileEditUiState.Error @@ -45,6 +54,19 @@ internal class ProfileEditViewModel } } + /** 갤러리에서 고른 사진을 저장 전 선택으로 둔다. 서버 사진은 저장이 성공할 때 바뀐다. */ + fun selectProfileImage(uri: String) { + _uiState.update { state -> + // 저장 중 선택은 받지 않는다. 업로드는 저장을 누른 시점의 사진으로 진행되므로, + // 받아들이면 화면에 보이는 사진과 실제로 올라가는 사진이 갈린다. + if (state is ProfileEditUiState.Success && !state.isUpdating) { + state.copy(selectedImageUri = uri) + } else { + state + } + } + } + fun updateProfile( name: String, phone: String, @@ -53,10 +75,16 @@ internal class ProfileEditViewModel _uiState.update { current.copy(isUpdating = true) } 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 { _events.send(ProfileEditEvent.UpdateSuccess) 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 195e19c59..f33389665 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.SavedStateHandle 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 18ebb9186..db45efec8 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 @@ -19,6 +19,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 @@ -93,7 +94,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 52c92558a..ffbe2bb68 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) val events = mutableListOf() backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } @@ -185,6 +186,7 @@ class SettingCoroutineCancellationTest { name = PROFILE.name, phone = PROFILE.phone.orEmpty(), email = PROFILE.email, + profileImageUrl = PROFILE.profileImageUrl, isUpdating = true, ), viewModel.uiState.value, @@ -192,6 +194,34 @@ class SettingCoroutineCancellationTest { assertTrue(events.isEmpty()) } + @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) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertTrue(events.isEmpty()) + } + @Test fun `설정 홈 프로필 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = runTest(dispatcher) { @@ -341,6 +371,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/ProfileEditPhotoPickerTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt new file mode 100644 index 000000000..ac9901a1b --- /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.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..cc4f6c3c7 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt @@ -0,0 +1,199 @@ +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.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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.selectProfileImage(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) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.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(listOf(ProfileEditEvent.UpdateSuccess), events) + } + + @Test + fun `사진을 고르지 않으면 업로드 없이 사진 칸을 비워 보내 기존 사진을 유지한다`() = + runTest(dispatcher) { + val profileRepository = FakeMyProfileRepository(profile = SAVED) + val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict()) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.updateProfile(name = "새 이름", phone = "") + runCurrent() + + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = null, profileImageUrl = null)), + profileRepository.profileUpdateCalls, + ) + assertEquals(listOf(ProfileEditEvent.UpdateSuccess), events) + } + + @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) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.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(listOf(ProfileEditEvent.UpdateFailure), events) + } + + @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) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertFalse(viewModel.success().isUpdating) + assertEquals(listOf(ProfileEditEvent.UpdateFailure), events) + } + + @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.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(viewModel.success().isUpdating) + + viewModel.selectProfileImage(OTHER_PHOTO) + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + pendingUpdate.complete(SAVED) + runCurrent() + } + + private fun TestScope.collectEvents(viewModel: ProfileEditViewModel): List { + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + return events + } + + 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) + } +} From 11de47a59b0994967f3f2ed37411de980d4aaa8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 13:43:11 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test(setting):=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=EB=A5=BC=20MVI=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=A4=20(#1501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/SettingCoroutineCancellationTest.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 52c92558a..fd9f4e6b3 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 @@ -17,7 +17,7 @@ import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.notification.PushNotificationEvent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel -import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import kotlinx.coroutines.Dispatchers @@ -168,11 +168,9 @@ class SettingCoroutineCancellationTest { } val viewModel = ProfileEditViewModel(repository) val store = storeHolding(viewModel) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } runCurrent() - viewModel.updateProfile(name = "새 이름", phone = "01011112222") + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01011112222")) runCurrent() assertTrue(pending.isStarted) @@ -189,7 +187,6 @@ class SettingCoroutineCancellationTest { ), viewModel.uiState.value, ) - assertTrue(events.isEmpty()) } @Test From 6207255830dc88c7c12e4013b2eeaf0244a49444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:42:45 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor(setting):=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=C2=B7=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20MVI=20=EC=83=81=ED=83=9C=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20=EC=9E=AC?= =?UTF-8?q?=EC=9C=A0=EC=9E=85=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4=20(#1?= =?UTF-8?q?502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConnectedAccountsViewModel 과 PushNotificationViewModel 을 MviViewModel 로 전환한다. 화면 입력은 onIntent 하나로 모으고, 비동기 결과는 dispatch 로 내며 전이는 reduce 에서만 한다. - 연결 계정: Channel 을 걷고 연결 요청을 UiState.pendingLinkProvider 신호로 둔다. 소비는 ConsumeLinkRequest(provider) 로 받아, 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않는다. 카카오·구글 인증과 실패 스낵바는 화면 코루틴에서 잠금으로 하나씩 처리해 예전 이벤트 수집의 순서를 지킨다. - 알림 설정: MutableSharedFlow 를 걷고 마케팅 동의 저장 실패를 isMarketingConsentSaveFailed 신호로 둔다. 화면이 STARTED 인 동안만 신호를 올리도록 MarketingFeedbackStarted·Stopped Intent 를 두어, 화면이 없는 동안의 실패를 재진입에 재생하지 않는 #558 계약을 보존한다. 값 롤백과 ErrorReporter 진단 단계(sms·email·push_consent_update)는 그대로다. - 푸시 토글 재시도 대상(failedUpdate)을 VM 필드에서 UiState 로 옮겨 실패 안내와 같은 전이에서 채우고 비운다. 저장 중 중복 제출 가드와 성공·실패·재시도·취소 동작은 그대로다. - UiState 가 내부 타입을 품게 되어 PushNotificationUiState 와 그 상태를 받는 두 컴포넌트를 모듈 안으로 좁힌다. 알림 설정 화면은 Screen/Content 2단으로 나눈다. - konsist 규칙 D: 아직 전환하지 않은 feature presentation ViewModel 도 이벤트용 Channel·MutableSharedFlow 를 새로 선언하지 못하게 한다. 기존 선언은 LEGACY_EVENT_STREAMS 기준선(ReceiverRegisterViewModel._events)으로 인정한다. 두 VM 은 PENDING_MVI_MIGRATION 에서 뺀다. ReceiverRegisterViewModel 은 교체 대상(receiver-register-post)이라 #944 전환이 가져간다. 폼 값과 이메일 등록 버튼이 PM 결정 대기다. --- docs/convention/mvi.md | 11 +- .../account/ConnectedAccountsContract.kt | 48 +++ .../account/ConnectedAccountsScreen.kt | 49 +-- .../account/ConnectedAccountsUiState.kt | 22 +- .../account/ConnectedAccountsViewModel.kt | 88 ++++-- .../notification/NotificationSettingScreen.kt | 94 ++++-- .../notification/PushNotificationContract.kt | 95 ++++++ .../notification/PushNotificationScreen.kt | 19 +- .../notification/PushNotificationUiState.kt | 31 +- .../notification/PushNotificationViewModel.kt | 291 ++++++++++-------- .../component/DeviceAlarmOffSection.kt | 2 +- .../component/PushToggleSection.kt | 2 +- .../SettingAccountSecurityTest.kt | 18 +- .../presentation/SettingCompletionTest.kt | 20 +- .../SettingCoroutineCancellationTest.kt | 18 +- .../setting/presentation/SettingFlowTest.kt | 4 +- .../account/ConnectedAccountsMviTest.kt | 149 +++++++++ .../account/ConnectedAccountsSignalTest.kt | 46 +++ .../MarketingConsentFailureTest.kt | 125 +++++--- .../NotificationSettingSignalTest.kt | 117 +++++++ .../PushNotificationSaveReportingTest.kt | 24 +- .../PushNotificationViewModelTest.kt | 92 +++++- .../konsist/MviContractKonsistTest.kt | 131 +++++++- 23 files changed, 1156 insertions(+), 340 deletions(-) create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt diff --git a/docs/convention/mvi.md b/docs/convention/mvi.md index 7e6209284..0587d23f0 100644 --- a/docs/convention/mvi.md +++ b/docs/convention/mvi.md @@ -171,27 +171,30 @@ ViewModel 테스트는 작업 결과 신호와 소비 후 초기화를, 화면 ## 강제 -`MviContractKonsistTest` 가 셋을 본다. +`MviContractKonsistTest` 가 넷을 본다. | 규칙 | 내용 | | --- | --- | | A | `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 선언하지 않는다 | | B | `feature/*/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다 | | C | `MviIntent`·`ReducerEvent` 를 직접 구현하는 타입은 `sealed interface` 다 | +| D | 아직 전환하지 않은 `feature/*/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 새로 선언하지 않는다 (#1502) | 규칙 B 는 아직 전환하지 않은 ViewModel 을 `PENDING_MVI_MIGRATION` 예외로 둔다(가드 도입 시점 49개, onboarding 파일럿 이후 46개). 모듈 전환 이슈가 닫힐 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 2개는 규칙 B 의 대상이 아니다 — #1809 가 처리한다. -세 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 +규칙 D 는 규칙 B 의 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘리지 못하게 한다. 규칙 A 는 전환한 ViewModel 만 보기 때문이다. 가드를 세울 때 이미 있던 선언은 `LEGACY_EVENT_STREAMS` 기준선으로 인정하고, 그 ViewModel 이 전환되거나 지워지면 기준선에서도 뺀다. 전환 전 상태 홀더인 `MutableStateFlow` 는 대상이 아니다. + +네 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 규칙 A 의 대상이 아니다 — 더블은 계약을 지키는 대상이 아니라 계약을 흉내 내는 도구다. -규칙 A·B 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 +규칙 A·B·D 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 `parents(indirectParents = true)` 로는 안 되고(0.17.3 에서 이 스코프 구성으로는 직계와 같은 목록을 돌려준다 — 실측), 스캔한 파일에서 이름 색인을 만들어 직접 걷는다. ### 가드가 «안» 보는 것 - **`onIntent` 밖의 public 진입점.** 상속체가 `fun refreshOnReturn()` 같은 public 함수를 노출해도 - 세 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 + 네 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 규약이지 CI 계약이 아니다. 리뷰에서 본다. - **`reduce` 밖의 전이.** 규칙 A 는 «상태 홀더 선언» 을 막을 뿐, `dispatch` 를 거치지 않는 다른 경로를 전수로 잡지는 못한다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt new file mode 100644 index 000000000..54ccab240 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ConnectedAccountsIntent : MviIntent { + data class Toggle( + val provider: String, + val enabled: Boolean, + ) : ConnectedAccountsIntent + + /** 화면이 플랫폼 인증으로 받아낸 [accessToken] 으로 [provider] 를 연결한다. */ + data class Link( + val provider: String, + val accessToken: String, + ) : ConnectedAccountsIntent + + /** 화면이 처리한 연결 요청 [provider] 를 되돌려준다. 그사이 다른 제공자 요청이 올라왔으면 그 요청은 남는다. */ + data class ConsumeLinkRequest( + val provider: String, + ) : ConnectedAccountsIntent +} + +internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent { + data class Loaded( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class LoadFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class AccountsChanged( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class ChangeFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequested( + val provider: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequestConsumed( + val provider: String, + ) : ConnectedAccountsReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 93774de3f..784e1d093 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -3,9 +3,9 @@ package com.afternote.feature.setting.presentation.account import android.app.Activity import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -14,12 +14,16 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken import com.afternote.feature.setting.presentation.account.social.requestKakaoAccessToken import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Composable internal fun ConnectedAccountsScreen( @@ -33,16 +37,20 @@ internal fun ConnectedAccountsScreen( val snackbarHostState = remember { SnackbarHostState() } val kakaoAccountLinkFailedMessage = stringResource(R.string.setting_kakao_account_link_failed) val googleAccountLinkFailedMessage = stringResource(R.string.setting_google_account_link_failed) + // 소비가 ObserveSignal 의 effect 를 다시 시작시켜도 인증·스낵바는 끝까지 간다. 예전 이벤트 수집처럼 + // 요청을 하나씩 처리하도록 화면 코루틴에서 잠금으로 줄을 세운다. + val linkScope = rememberCoroutineScope() + val linkMutex = remember { Mutex() } - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is ConnectedAccountsEvent.ShowError -> { - snackbarHostState.showSnackbar(event.message) - } - - is ConnectedAccountsEvent.RequestLink -> { - when (event.provider) { + uiState.pendingLinkProvider?.let { pendingProvider -> + ObserveSignal( + signal = pendingProvider, + consumed = ConnectedAccountsIntent.ConsumeLinkRequest(pendingProvider), + onIntent = viewModel::onIntent, + ) { provider -> + linkScope.launch { + linkMutex.withLock { + when (provider) { "kakao" -> { val activity = context.findActivity() val authResult = @@ -51,15 +59,13 @@ internal fun ConnectedAccountsScreen( ?: KakaoAuthResult.Failure when (authResult) { is KakaoAuthResult.Success -> { - viewModel.link("kakao", authResult.accessToken) + viewModel.onIntent(ConnectedAccountsIntent.Link("kakao", authResult.accessToken)) } KakaoAuthResult.Cancelled -> {} KakaoAuthResult.Failure -> { - snackbarHostState.showSnackbar( - kakaoAccountLinkFailedMessage, - ) + snackbarHostState.showSnackbar(kakaoAccountLinkFailedMessage) } } } @@ -69,14 +75,13 @@ internal fun ConnectedAccountsScreen( context = context, credentialManager = credentialManager, serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, - ).onSuccess { token -> viewModel.link("google", token) } - .onFailure { e -> - if (e !is CoreAuthFailure.UserCancelledAuth) { - viewModel.notifyLinkError( - googleAccountLinkFailedMessage, - ) - } + ).onSuccess { token -> + viewModel.onIntent(ConnectedAccountsIntent.Link("google", token)) + }.onFailure { e -> + if (e !is CoreAuthFailure.UserCancelledAuth) { + snackbarHostState.showSnackbar(googleAccountLinkFailedMessage) } + } } } } @@ -88,7 +93,7 @@ internal fun ConnectedAccountsScreen( uiState = uiState, snackbarHostState = snackbarHostState, onBack = onBack, - onToggle = viewModel::onToggle, + onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) }, modifier = modifier, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index 7fb4ecbf5..e17aaa182 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -1,17 +1,17 @@ package com.afternote.feature.setting.presentation.account +import com.afternote.core.ui.mvi.UiState + data class ConnectedAccountsUiState( val isLoading: Boolean = false, val accounts: List = emptyList(), val errorMessage: String? = null, -) - -sealed interface ConnectedAccountsEvent { - data class RequestLink( - val provider: String, - ) : ConnectedAccountsEvent - - data class ShowError( - val message: String, - ) : ConnectedAccountsEvent -} + /** + * 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502). + * + * ViewModel 은 Activity·Context 에 닿지 않으므로 토큰을 직접 받지 못한다. 화면이 이 신호를 받아 + * 인증을 돌린 뒤 토큰만 [ConnectedAccountsIntent.Link] 로 돌려보내고, 받은 즉시 + * [ConnectedAccountsIntent.ConsumeLinkRequest] 로 되돌린다. + */ + val pendingLinkProvider: String? = null, +) : UiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 88d50b126..92a53c2e3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -1,17 +1,12 @@ package com.afternote.feature.setting.presentation.account -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,59 +15,100 @@ internal class ConnectedAccountsViewModel @Inject constructor( private val accountRepository: SettingAccountRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(ConnectedAccountsUiState(isLoading = true)) - val uiState = _uiState.asStateFlow() - - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() - + ) : MviViewModel( + ConnectedAccountsUiState(isLoading = true), + ) { init { loadConnectedAccounts() } + override fun onIntent(intent: ConnectedAccountsIntent) { + when (intent) { + is ConnectedAccountsIntent.Toggle -> { + toggle(intent.provider, intent.enabled) + } + + is ConnectedAccountsIntent.Link -> { + link(intent.provider, intent.accessToken) + } + + is ConnectedAccountsIntent.ConsumeLinkRequest -> { + dispatch( + ConnectedAccountsReducerEvent.LinkRequestConsumed(intent.provider), + ) + } + } + } + + override fun reduce( + state: ConnectedAccountsUiState, + event: ConnectedAccountsReducerEvent, + ): ConnectedAccountsUiState = + when (event) { + is ConnectedAccountsReducerEvent.Loaded -> { + state.copy(isLoading = false, accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.LoadFailed -> { + state.copy(isLoading = false, errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.AccountsChanged -> { + state.copy(accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.ChangeFailed -> { + state.copy(errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.LinkRequested -> { + state.copy(pendingLinkProvider = event.provider) + } + + // 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않게 한다. + is ConnectedAccountsReducerEvent.LinkRequestConsumed -> { + if (state.pendingLinkProvider == event.provider) state.copy(pendingLinkProvider = null) else state + } + } + private fun loadConnectedAccounts() { viewModelScope.launch { runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> - _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } + dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) }.onFailure { - _uiState.update { it.copy(isLoading = false, errorMessage = "계정 정보를 불러올 수 없습니다.") } + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) } } } - fun onToggle( + private fun toggle( provider: String, enabled: Boolean, ) { if (enabled) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.RequestLink(provider)) } + dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider)) } else { unlink(provider) } } - fun notifyLinkError(message: String) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.ShowError(message)) } - } - - fun link( + private fun link( provider: String, accessToken: String, ) { viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결에 실패했습니다.")) } } } private fun unlink(provider: String) { viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결 해제에 실패했습니다.")) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index 011c18032..df4342536 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -22,9 +22,9 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,15 +36,16 @@ import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.repeatOnLifecycle import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveFlag 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.notification.component.DeviceAlarmOffSection import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.launch @Composable internal fun NotificationSettingScreen( @@ -56,7 +57,6 @@ internal fun NotificationSettingScreen( val context = LocalContext.current val activity = remember(context) { context.findActivity() } val snackbarHostState = remember { SnackbarHostState() } - val lifecycleOwner = LocalLifecycleOwner.current val marketingConsentSaveFailedMessage = stringResource(R.string.setting_marketing_consent_save_failed) val openNotificationSettings = { val intent = @@ -67,7 +67,7 @@ internal fun NotificationSettingScreen( } val notificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) if ( !granted && activity != null && @@ -81,21 +81,61 @@ internal fun NotificationSettingScreen( } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) } - LaunchedEffect(viewModel, lifecycleOwner, marketingConsentSaveFailedMessage) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.events.collect { event -> - when (event) { - PushNotificationEvent.MarketingConsentSaveFailed -> { - snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) - } + // 화면이 STARTED 인 동안만 마케팅 동의 저장 실패를 안내한다. 화면이 없는 동안의 실패는 다음 진입에 재생하지 않는다 (#558). + LifecycleStartEffect(viewModel) { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + onStopOrDispose { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) } + } + + // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. + val snackbarScope = rememberCoroutineScope() + ObserveFlag( + raised = uiState.isMarketingConsentSaveFailed, + consumed = PushNotificationIntent.ConsumeMarketingConsentSaveFailure, + onIntent = viewModel::onIntent, + ) { + snackbarScope.launch { snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) } + } + + NotificationSettingContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onBack = onBack, + onPushNotificationClick = onPushNotificationClick, + onDeviceAlarmClick = { + val permissionGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { + NotificationPermissionAction.RequestPermission -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + NotificationPermissionAction.OpenSettings -> { + openNotificationSettings() } } - } - } + }, + onMarketingConsentChange = { consent, checked -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, checked)) + }, + ) +} +@Composable +private fun NotificationSettingContent( + uiState: PushNotificationUiState, + snackbarHostState: SnackbarHostState, + onBack: () -> Unit, + onPushNotificationClick: () -> Unit, + onDeviceAlarmClick: () -> Unit, + onMarketingConsentChange: (MarketingConsent, Boolean) -> Unit, +) { Scaffold( topBar = { DetailTopBar( @@ -117,22 +157,8 @@ internal fun NotificationSettingScreen( modifier = Modifier .fillMaxWidth() - .clickable { - val permissionGranted = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { - NotificationPermissionAction.RequestPermission -> { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - NotificationPermissionAction.OpenSettings -> { - openNotificationSettings() - } - } - }.padding(vertical = 12.dp), + .clickable(onClick = onDeviceAlarmClick) + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -160,9 +186,9 @@ internal fun NotificationSettingScreen( } else { DeviceAlarmOffSection( uiState = uiState, - onSmsCheck = viewModel::onSmsChecked, - onEmailCheck = viewModel::onEmailChecked, - onPushCheck = viewModel::onPushChecked, + onSmsCheck = { onMarketingConsentChange(MarketingConsent.SMS, it) }, + onEmailCheck = { onMarketingConsentChange(MarketingConsent.EMAIL, it) }, + onPushCheck = { onMarketingConsentChange(MarketingConsent.PUSH, it) }, ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt new file mode 100644 index 000000000..0ac593a7a --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -0,0 +1,95 @@ +package com.afternote.feature.setting.presentation.notification + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface PushNotificationIntent : MviIntent { + /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ + data object RefreshDeviceAlarmStatus : PushNotificationIntent + + data class ChangeMarketingConsent( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationIntent + + data class TogglePushSetting( + val setting: PushSetting, + val on: Boolean, + ) : PushNotificationIntent + + data object RetrySave : PushNotificationIntent + + data object DismissSaveFailure : PushNotificationIntent + + /** + * 알림 설정 화면이 STARTED 에 들어섰다. 이때부터 마케팅 동의 저장 실패를 안내 신호로 올린다. + * + * STARTED 밖에서 난 실패는 신호로 남기지 않고, [MarketingFeedbackStopped] 가 남은 신호도 걷는다. + * 화면이 없는 동안의 안내를 다음 진입에 재생하지 않는 #558 계약이다. + */ + data object MarketingFeedbackStarted : PushNotificationIntent + + data object MarketingFeedbackStopped : PushNotificationIntent + + data object ConsumeMarketingConsentSaveFailure : PushNotificationIntent +} + +internal sealed interface PushNotificationReducerEvent : ReducerEvent { + data class DeviceAlarmStatusRead( + val on: Boolean, + ) : PushNotificationReducerEvent + + data object PushSettingsLoading : PushNotificationReducerEvent + + data class PushSettingsLoaded( + val setting: UserPushSetting, + ) : PushNotificationReducerEvent + + data object PushSettingsLoadFailed : PushNotificationReducerEvent + + data class MarketingConsentsLoaded( + val consent: UserMarketingConsent, + ) : PushNotificationReducerEvent + + data class MarketingConsentChanged( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationReducerEvent + + /** [requested] 로 바꾸려던 저장이 실패했다. 값은 그 반대로 되돌린다. */ + data class MarketingConsentSaveFailed( + val consent: MarketingConsent, + val requested: Boolean, + ) : PushNotificationReducerEvent + + data class PushSettingSaving( + val update: PushSettingUpdate, + ) : PushNotificationReducerEvent + + data class PushSettingSaved( + val setting: PushSetting, + ) : PushNotificationReducerEvent + + data class PushSettingSaveFailed( + val update: PushSettingUpdate, + val previous: Boolean, + val failure: PushNotificationSaveFailure, + ) : PushNotificationReducerEvent + + data object SaveFailureDismissed : PushNotificationReducerEvent + + data class MarketingFeedbackActiveChanged( + val active: Boolean, + ) : PushNotificationReducerEvent + + data object MarketingConsentSaveFailureConsumed : PushNotificationReducerEvent +} + +/** 기기 알림이 꺼졌을 때 받는 마케팅 알림 채널. */ +internal enum class MarketingConsent { + SMS, + EMAIL, + PUSH, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index ea1317618..ae9c1ec4e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -6,11 +6,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.NetworkErrorPopup import com.afternote.core.ui.popup.ServerErrorPopup -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.notification.component.DeviceAlarmOffSection -import com.afternote.feature.setting.presentation.notification.component.PushToggleSection @Composable internal fun PushNotificationScreen( @@ -22,23 +17,23 @@ internal fun PushNotificationScreen( PushNotificationContent( uiState = uiState, onBack = onBack, - onNewsletterToggle = viewModel::onNewsletterToggle, - onMindRecordToggle = viewModel::onMindRecordToggle, - onAfternoteToggle = viewModel::onAfternoteToggle, + onNewsletterToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, it)) }, + onMindRecordToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, it)) }, + onAfternoteToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, it)) }, ) when (uiState.saveFailure) { PushNotificationSaveFailure.NETWORK -> { NetworkErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } PushNotificationSaveFailure.SERVER -> { ServerErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 81ee23a7c..adcf6fd7a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -1,6 +1,8 @@ package com.afternote.feature.setting.presentation.notification -data class PushNotificationUiState( +import com.afternote.core.ui.mvi.UiState + +internal data class PushNotificationUiState( val isLoading: Boolean = false, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) @@ -15,13 +17,34 @@ data class PushNotificationUiState( val isMindRecordUpdating: Boolean = false, val isAfternoteUpdating: Boolean = false, val saveFailure: PushNotificationSaveFailure? = null, -) + /** + * [saveFailure] 에서 재시도할 변경. [saveFailure] 와 같은 전이에서 함께 채우고 함께 비운다. + * + * 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. + * 이미 실패한 토글은 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, + * 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 + * 없어 의도적으로 단순화했다 (#558 리뷰 합의). + */ + val failedUpdate: PushSettingUpdate? = null, + /** 알림 설정 화면이 STARTED 인 동안만 true 다. 이때만 마케팅 동의 저장 실패를 안내 신호로 올린다 (#558). */ + val isMarketingFeedbackActive: Boolean = false, + /** 마케팅 동의 저장 실패 안내 신호. 화면이 스낵바를 띄우고 소비 Intent 로 내린다 (#1502). */ + val isMarketingConsentSaveFailed: Boolean = false, +) : UiState enum class PushNotificationSaveFailure { NETWORK, SERVER, } -sealed interface PushNotificationEvent { - data object MarketingConsentSaveFailed : PushNotificationEvent +/** 기기 알림이 켜졌을 때 받는 서비스 알림 종류. */ +internal enum class PushSetting { + NEWSLETTER, + MIND_RECORD, + AFTERNOTE, } + +internal data class PushSettingUpdate( + val setting: PushSetting, + val on: Boolean, +) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index 6243823ec..da43aa7fc 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -3,22 +3,15 @@ package com.afternote.feature.setting.presentation.notification import android.content.Context import android.util.Log import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingNotificationRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -28,56 +21,144 @@ internal class PushNotificationViewModel @ApplicationContext private val context: Context, private val notificationRepository: SettingNotificationRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(PushNotificationUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // 화면이 없는 동안의 안내는 다음 진입에 재생하지 않는다. - private val _events = - MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val events = _events.asSharedFlow() - - // 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. - // 이미 실패한 토글은 위에서 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, - // 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 - // 없어 의도적으로 단순화했다 (#558 리뷰 합의). - private var failedUpdate: PushSettingUpdate? = null - + ) : MviViewModel(PushNotificationUiState()) { init { refreshDeviceAlarmStatus() loadPushSettings() loadMarketingConsents() } - fun refreshDeviceAlarmStatus() { + override fun onIntent(intent: PushNotificationIntent) { + when (intent) { + PushNotificationIntent.RefreshDeviceAlarmStatus -> { + refreshDeviceAlarmStatus() + } + + is PushNotificationIntent.ChangeMarketingConsent -> { + updateMarketingConsent(intent.consent, intent.checked) + } + + is PushNotificationIntent.TogglePushSetting -> { + updatePushSetting(PushSettingUpdate(intent.setting, intent.on)) + } + + PushNotificationIntent.RetrySave -> { + retrySave() + } + + PushNotificationIntent.DismissSaveFailure -> { + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) + } + + PushNotificationIntent.MarketingFeedbackStarted -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = true)) + } + + PushNotificationIntent.MarketingFeedbackStopped -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = false)) + } + + PushNotificationIntent.ConsumeMarketingConsentSaveFailure -> { + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed) + } + } + } + + override fun reduce( + state: PushNotificationUiState, + event: PushNotificationReducerEvent, + ): PushNotificationUiState = + when (event) { + is PushNotificationReducerEvent.DeviceAlarmStatusRead -> { + state.copy(isDeviceAlarmOn = event.on) + } + + PushNotificationReducerEvent.PushSettingsLoading -> { + state.copy(isLoading = true) + } + + is PushNotificationReducerEvent.PushSettingsLoaded -> { + state.copy( + isLoading = false, + isNewsletterOn = event.setting.timeLetter, + isMindRecordOn = event.setting.mindRecord, + isAfternoteOn = event.setting.afterNote, + ) + } + + PushNotificationReducerEvent.PushSettingsLoadFailed -> { + state.copy(isLoading = false) + } + + is PushNotificationReducerEvent.MarketingConsentsLoaded -> { + state.copy( + isSmsChecked = event.consent.sms, + isEmailChecked = event.consent.email, + isPushChecked = event.consent.push, + ) + } + + is PushNotificationReducerEvent.MarketingConsentChanged -> { + state.withMarketingConsent(event.consent, event.checked) + } + + // 화면이 STARTED 밖이면 값만 되돌리고 안내 신호는 올리지 않는다 (#558). + is PushNotificationReducerEvent.MarketingConsentSaveFailed -> { + state + .withMarketingConsent(event.consent, !event.requested) + .copy(isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed || state.isMarketingFeedbackActive) + } + + is PushNotificationReducerEvent.PushSettingSaving -> { + state.withValue(event.update.setting, event.update.on).withUpdating(event.update.setting, updating = true) + } + + is PushNotificationReducerEvent.PushSettingSaved -> { + state.withUpdating(event.setting, updating = false) + } + + is PushNotificationReducerEvent.PushSettingSaveFailed -> { + state + .withValue(event.update.setting, event.previous) + .withUpdating(event.update.setting, updating = false) + .copy(saveFailure = event.failure, failedUpdate = event.update) + } + + PushNotificationReducerEvent.SaveFailureDismissed -> { + state.copy(saveFailure = null, failedUpdate = null) + } + + // 비활성으로 바뀌면 아직 소비되지 않은 안내도 걷는다 — 다음 진입에 재생하지 않는다. + is PushNotificationReducerEvent.MarketingFeedbackActiveChanged -> { + state.copy( + isMarketingFeedbackActive = event.active, + isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed && event.active, + ) + } + + PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed -> { + state.copy(isMarketingConsentSaveFailed = false) + } + } + + private fun refreshDeviceAlarmStatus() { val deviceAlarmOn = NotificationManagerCompat.from(context).areNotificationsEnabled() Log.d(TAG, "refreshDeviceAlarmStatus: deviceAlarmOn=$deviceAlarmOn") - _uiState.update { it.copy(isDeviceAlarmOn = deviceAlarmOn) } + dispatch(PushNotificationReducerEvent.DeviceAlarmStatusRead(deviceAlarmOn)) } private fun loadPushSettings() { viewModelScope.launch { Log.d(TAG, "loadPushSettings: start") - _uiState.update { it.copy(isLoading = true) } + dispatch(PushNotificationReducerEvent.PushSettingsLoading) runCatchingCancellable { notificationRepository.getMyPushSettings() } .onSuccess { setting -> Log.d(TAG, "loadPushSettings: success=$setting") - _uiState.update { - it.copy( - isLoading = false, - isNewsletterOn = setting.timeLetter, - isMindRecordOn = setting.mindRecord, - isAfternoteOn = setting.afterNote, - ) - } + dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) }.onFailure { e -> // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) - _uiState.update { it.copy(isLoading = false) } + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) } } } @@ -88,88 +169,44 @@ internal class PushNotificationViewModel runCatchingCancellable { notificationRepository.getMyMarketingConsents() } .onSuccess { consent -> Log.d(TAG, "loadMarketingConsents: success=$consent") - _uiState.update { - it.copy( - isSmsChecked = consent.sms, - isEmailChecked = consent.email, - isPushChecked = consent.push, - ) - } + dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) }.onFailure { e -> Log.e(TAG, "loadMarketingConsents: failed", e) } } } - fun onSmsChecked(checked: Boolean) { - _uiState.update { it.copy(isSmsChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = checked, email = null, push = null) } - .onSuccess { Log.d(TAG, "onSmsChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_SMS_CONSENT)) - _uiState.update { it.copy(isSmsChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onEmailChecked(checked: Boolean) { - _uiState.update { it.copy(isEmailChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = checked, push = null) } - .onSuccess { Log.d(TAG, "onEmailChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_EMAIL_CONSENT)) - _uiState.update { it.copy(isEmailChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onPushChecked(checked: Boolean) { - _uiState.update { it.copy(isPushChecked = checked) } + private fun updateMarketingConsent( + consent: MarketingConsent, + checked: Boolean, + ) { + dispatch(PushNotificationReducerEvent.MarketingConsentChanged(consent, checked)) viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = null, push = checked) } - .onSuccess { Log.d(TAG, "onPushChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_PUSH_CONSENT)) - _uiState.update { it.copy(isPushChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } + runCatchingCancellable { + notificationRepository.updateMyMarketingConsents( + sms = checked.takeIf { consent == MarketingConsent.SMS }, + email = checked.takeIf { consent == MarketingConsent.EMAIL }, + push = checked.takeIf { consent == MarketingConsent.PUSH }, + ) + }.onSuccess { + Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") + }.onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + } } } - fun onNewsletterToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.NEWSLETTER, on)) - } - - fun onMindRecordToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.MIND_RECORD, on)) - } - - fun onAfternoteToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.AFTERNOTE, on)) - } - - fun onSaveFailureDismiss() { - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } - } - - fun onSaveFailureRetry() { - val update = failedUpdate ?: return - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } + private fun retrySave() { + val update = currentState.failedUpdate ?: return + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) updatePushSetting(update) } private fun updatePushSetting(update: PushSettingUpdate) { - if (_uiState.value.isUpdating(update.setting)) return - val previousValue = _uiState.value.valueOf(update.setting) - _uiState.update { - it.withValue(update.setting, update.on).withUpdating(update.setting, updating = true) - } + if (currentState.isUpdating(update.setting)) return + val previousValue = currentState.valueOf(update.setting) + dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { runCatchingCancellable { notificationRepository.updateMyPushSettings( @@ -178,7 +215,7 @@ internal class PushNotificationViewModel afterNote = update.on.takeIf { update.setting == PushSetting.AFTERNOTE }, ) }.onSuccess { - _uiState.update { it.withUpdating(update.setting, updating = false) } + dispatch(PushNotificationReducerEvent.PushSettingSaved(update.setting)) }.onFailure { failure -> errorReporter.recordFailure( failure, @@ -187,13 +224,7 @@ internal class PushNotificationViewModel KEY_PUSH_SETTING to update.setting.reportingName(), ), ) - failedUpdate = update - _uiState.update { - it - .withValue(update.setting, previousValue) - .withUpdating(update.setting, updating = false) - .copy(saveFailure = failure.toSaveFailure()) - } + dispatch(PushNotificationReducerEvent.PushSettingSaveFailed(update, previousValue, failure.toSaveFailure())) } } } @@ -203,18 +234,9 @@ internal class PushNotificationViewModel private const val KEY_STAGE = "stage" private const val KEY_PUSH_SETTING = "push_setting" private const val STAGE_PUSH_SETTING_UPDATE = "push_setting_update" - private const val STAGE_SMS_CONSENT = "sms_consent_update" - private const val STAGE_EMAIL_CONSENT = "email_consent_update" - private const val STAGE_PUSH_CONSENT = "push_consent_update" } } -private enum class PushSetting { - NEWSLETTER, - MIND_RECORD, - AFTERNOTE, -} - /** 진단 속성 값. 토글 종류만 담는 고정 문자열이라 사용자 정보가 섞이지 않는다. */ private fun PushSetting.reportingName(): String = when (this) { @@ -223,10 +245,13 @@ private fun PushSetting.reportingName(): String = PushSetting.AFTERNOTE -> "afternote" } -private data class PushSettingUpdate( - val setting: PushSetting, - val on: Boolean, -) +/** 마케팅 동의 저장 실패의 진단 단계. 채널 이름만 담는 고정 문자열이다. */ +private fun MarketingConsent.reportingStage(): String = + when (this) { + MarketingConsent.SMS -> "sms_consent_update" + MarketingConsent.EMAIL -> "email_consent_update" + MarketingConsent.PUSH -> "push_consent_update" + } private fun PushNotificationUiState.valueOf(setting: PushSetting): Boolean = when (setting) { @@ -262,6 +287,16 @@ private fun PushNotificationUiState.withUpdating( PushSetting.AFTERNOTE -> copy(isAfternoteUpdating = updating) } +private fun PushNotificationUiState.withMarketingConsent( + consent: MarketingConsent, + checked: Boolean, +): PushNotificationUiState = + when (consent) { + MarketingConsent.SMS -> copy(isSmsChecked = checked) + MarketingConsent.EMAIL -> copy(isEmailChecked = checked) + MarketingConsent.PUSH -> copy(isPushChecked = checked) + } + private fun Throwable.toSaveFailure(): PushNotificationSaveFailure = when (this) { is PushSettingFailure.NetworkUnavailable -> PushNotificationSaveFailure.NETWORK diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt index d908c8d8e..039935aac 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt @@ -16,7 +16,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun DeviceAlarmOffSection( +internal fun DeviceAlarmOffSection( uiState: PushNotificationUiState, onSmsCheck: (Boolean) -> Unit, onEmailCheck: (Boolean) -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt index aca5f1757..081e521ad 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt @@ -6,7 +6,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun PushToggleSection( +internal fun PushToggleSection( uiState: PushNotificationUiState, onNewsletterToggle: (Boolean) -> Unit, onMindRecordToggle: (Boolean) -> Unit, 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 271ba3a7c..1192abaab 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 @@ -25,7 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall -import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel @@ -46,10 +46,8 @@ import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -124,14 +122,13 @@ class SettingAccountSecurityTest { !linkViewModel.uiState.value.isLoading } - composeRule.runOnIdle { linkViewModel.onToggle(provider = "google", enabled = true) } - val request = awaitEvent(linkViewModel.events) + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } - assertEquals(ConnectedAccountsEvent.RequestLink("google"), request) + assertEquals("google", linkViewModel.uiState.value.pendingLinkProvider) assertTrue(linkRepository.connectedLinkCalls.isEmpty()) linkRepository.onLinkConnectedAccount = { _, _ -> throw IllegalStateException("oauth rejected") } - composeRule.runOnIdle { linkViewModel.link(provider = "google", accessToken = "google-token") } + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { linkViewModel.uiState.value.errorMessage == "계정 연결에 실패했습니다." } @@ -152,7 +149,7 @@ class SettingAccountSecurityTest { } assertTrue(unlinkRepository.connectedUnlinkCalls.isEmpty()) - composeRule.runOnIdle { unlinkViewModel.onToggle(provider = "google", enabled = false) } + composeRule.runOnIdle { unlinkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { unlinkRepository.connectedUnlinkCalls.size == 1 } @@ -344,11 +341,6 @@ class SettingAccountSecurityTest { assertTrue(runBlocking { authRepository.isLoggedIn.first() }) } - private fun awaitEvent(events: Flow): T = - runBlocking { - withTimeout(TIMEOUT_MILLIS) { events.first() } - } - private companion object { const val TIMEOUT_MILLIS = 5_000L const val RECEIVER_ID = 77L 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 6f95ca6c6..dcb96eefd 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 @@ -38,6 +38,7 @@ import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent @@ -46,7 +47,10 @@ import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawUiState import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.MarketingConsent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import com.afternote.feature.setting.presentation.profile.ProfileEditEvent import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState @@ -143,7 +147,7 @@ class SettingCompletionTest { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 1 } @@ -162,7 +166,7 @@ class SettingCompletionTest { } assertFalse(viewModel.uiState.value.isNewsletterOn) - composeRule.runOnIdle { viewModel.onMindRecordToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 2 } @@ -186,10 +190,10 @@ class SettingCompletionTest { assertTrue(viewModel.uiState.value.saveFailure != null) composeRule.runOnIdle { - viewModel.onAfternoteToggle(false) - viewModel.onSmsChecked(true) - viewModel.onEmailChecked(true) - viewModel.onPushChecked(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 3 @@ -228,7 +232,7 @@ class SettingCompletionTest { } composeRule.runOnIdle { - viewModel.link(provider = "google", accessToken = "google-access-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-access-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedLinkCalls.size == 1 @@ -259,7 +263,7 @@ class SettingCompletionTest { ) composeRule.runOnIdle { - viewModel.onToggle(provider = "google", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedUnlinkCalls.size == 1 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 fd9f4e6b3..56787ff68 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 @@ -12,17 +12,17 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel -import com.afternote.feature.setting.presentation.notification.PushNotificationEvent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -100,7 +100,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.link(provider = "google", accessToken = "google-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -125,7 +125,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.onToggle(provider = "naver", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -225,8 +225,7 @@ class SettingCoroutineCancellationTest { val reporter = RecordingErrorReporter() val viewModel = pushViewModel(repository, reporter) val store = storeHolding(viewModel) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 푸시 설정 조회는 먼저 정상 종료해 안정된 상태를 만든다. 매달린 것은 마케팅 조회뿐이다. @@ -239,7 +238,7 @@ class SettingCoroutineCancellationTest { assertTrue(pending.isCancelled) assertEquals(stableState, viewModel.uiState.value) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, reporter.recordedStages.size) assertEquals(emptyList(), pushFailureLogs()) } @@ -273,8 +272,7 @@ class SettingCoroutineCancellationTest { }, failureReporter, ) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { failureViewModel.events.collect { events += it } } + failureViewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 평범한 실패는 진단 로그 한 줄로 끝난다. 화면 값은 기본값 그대로이고 안내도 띄우지 않는다. @@ -282,7 +280,7 @@ class SettingCoroutineCancellationTest { assertTrue(failureViewModel.uiState.value.isSmsChecked) assertTrue(failureViewModel.uiState.value.isEmailChecked) assertFalse(failureViewModel.uiState.value.isPushChecked) - assertTrue(events.isEmpty()) + assertFalse(failureViewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, failureReporter.recordedStages.size) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 2e092dfac..320a7632f 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -16,7 +16,9 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.home.SettingScreen import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -106,7 +108,7 @@ class SettingFlowTest { composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.isNewsletterOn } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt new file mode 100644 index 000000000..6baf72799 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt @@ -0,0 +1,149 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +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.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectedAccountsMviTest { + private val repository = FakeSettingAccountRepository(connectedAccounts = ACCOUNTS) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `토글을 켜면 저장소를 부르지 않고 연결 요청 신호만 올린다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + + assertEquals("google", viewModel.uiState.value.pendingLinkProvider) + assertTrue(repository.connectedLinkCalls.isEmpty()) + assertTrue(repository.connectedUnlinkCalls.isEmpty()) + } + + @Test + fun `토글을 끄면 신호 없이 바로 해제한다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + + assertNull(viewModel.uiState.value.pendingLinkProvider) + assertEquals(listOf("naver"), repository.connectedUnlinkCalls) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "naver" } + .isConnected, + ) + } + + @Test + fun `연결 요청 신호는 소비할 때까지 남고 소비하면 걷힌다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + // 다른 작업이 지나가도 신호는 그대로다. + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + + // 소비 뒤 같은 제공자를 다시 켜면 다시 요청한다 (A → null → A). + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않는다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("google")) + + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `연결이 연달아 실패해도 매번 요청하고 실패 문구를 남긴 뒤 성공하면 계정을 갱신한다`() { + var remainingFailures = 2 + repository.onLinkConnectedAccount = { provider, _ -> + if (remainingFailures > 0) { + remainingFailures-- + error("oauth rejected") + } + ACCOUNTS.copy(google = provider == "google", googleEmail = "linked@afternote.local") + } + val viewModel = ConnectedAccountsViewModel(repository) + + repeat(2) { + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-$it")) + assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.errorMessage) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "google" } + .isConnected, + ) + } + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-2")) + + assertEquals( + List(3) { FakeSettingAccountRepository.ConnectedAccountLinkCall("google", "token-$it") }, + repository.connectedLinkCalls.toList(), + ) + val google = + viewModel.uiState.value.accounts + .first { it.provider == "google" } + assertTrue(google.isConnected) + assertEquals("linked@afternote.local", google.email) + } + + @Test + fun `조회 실패는 로딩을 끝내고 실패 문구를 남긴다`() { + repository.onGetConnectedAccounts = { error("unavailable") } + + val state = ConnectedAccountsViewModel(repository).uiState.value + + assertFalse(state.isLoading) + assertTrue(state.accounts.isEmpty()) + assertEquals("계정 정보를 불러올 수 없습니다.", state.errorMessage) + } + + private companion object { + val ACCOUNTS = + UserConnectedAccount( + local = false, + naver = true, + google = false, + kakao = false, + apple = false, + localEmail = null, + naverEmail = "naver@afternote.local", + googleEmail = null, + kakaoEmail = null, + appleEmail = null, + ) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt new file mode 100644 index 000000000..5b714026d --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.setting.presentation.account + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +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 + +/** + * 연결된 계정 화면이 연결 요청 신호를 받자마자 소비하고, 인증은 신호와 떨어진 화면 코루틴에서 + * 돌리는지 확인한다 (#1502). 플랫폼 인증은 이 환경에서 토큰을 주지 못하므로 연결 호출까지는 가지 않는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ConnectedAccountsSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val repository = FakeSettingAccountRepository() + private val viewModel = ConnectedAccountsViewModel(repository) + + @Test + fun `화면은 연결 요청 신호를 받으면 곧바로 소비하고 다음 요청도 다시 받는다`() { + composeRule.setContent { + AfternoteTheme { + ConnectedAccountsScreen(onBack = {}, viewModel = viewModel) + } + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isLoading } + + repeat(2) { + composeRule.runOnIdle { viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingLinkProvider == null } + } + + assertTrue(repository.connectedLinkCalls.isEmpty()) + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt index 6d88a442f..b2091e81a 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt @@ -8,7 +8,6 @@ import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepos import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -17,6 +16,7 @@ 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.assertTrue import org.junit.Before import org.junit.Test @@ -49,23 +49,21 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isSmsChecked) - viewModel.onEmailChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isEmailChecked) - viewModel.onPushChecked(false) - advanceUntilIdle() - runCurrent() - assertTrue(viewModel.uiState.value.isPushChecked) + var signals = 0 + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + assertFalse(viewModel.uiState.value.checked(consent)) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.checked(consent)) + if (viewModel.uiState.value.isMarketingConsentSaveFailed) signals++ + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + } - assertEquals(List(3) { PushNotificationEvent.MarketingConsentSaveFailed }, events) + assertEquals(3, signals) assertEquals(listOf("sms_consent_update", "email_consent_update", "push_consent_update"), reporter.stages) assertEquals( listOf(Triple(false, null, null), Triple(null, false, null), Triple(null, null, false)), @@ -82,14 +80,12 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> throw CancellationException("screen left") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - viewModel.onEmailChecked(false) - viewModel.onPushChecked(false) + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + } advanceUntilIdle() runCurrent() @@ -97,39 +93,83 @@ class MarketingConsentFailureTest { listOf(false, false, false), viewModel.uiState.value.let { listOf(it.isSmsChecked, it.isEmailChecked, it.isPushChecked) }, ) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertTrue(reporter.stages.isEmpty()) } @Test - fun `구독이 끝난 동안의 실패 안내는 다음 구독에 재생하지 않는다`() = + fun `STARTED 밖에서 난 실패 안내는 다음 진입에 재생하지 않는다`() = + runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + advanceUntilIdle() + // 값 롤백은 화면 수명과 무관하게 일어난다. 안내만 막힌다. + assertTrue(viewModel.uiState.value.isSmsChecked) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `소비하기 전에 화면이 멈추면 남은 안내를 걷어 재진입에 띄우지 않는다`() = runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `안내는 소비할 때까지 남고 소비 뒤 연속 실패는 다시 안내한다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() val viewModel = viewModel( - repository().apply { - onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } - }, - RecordingReporter(), + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + reporter, ) - val events = mutableListOf() - val firstCollector = backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() - firstCollector.cancel() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } - runCurrent() - assertTrue(events.isEmpty()) + // 다른 입력이 지나가도 소비 전까지 신호는 남는다. + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) - viewModel.onEmailChecked(false) + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - runCurrent() - assertEquals(listOf(PushNotificationEvent.MarketingConsentSaveFailed), events) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + assertEquals(listOf("sms_consent_update", "sms_consent_update"), reporter.stages) } + private fun failingViewModel() = + viewModel( + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + RecordingReporter(), + ) + private fun repository() = FakeSettingNotificationRepository.strict().apply { onGetMyPushSettings = { UserPushSetting(false, false, false) } @@ -141,6 +181,13 @@ class MarketingConsentFailureTest { reporter: ErrorReporter, ) = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, reporter) + private fun PushNotificationUiState.checked(consent: MarketingConsent): Boolean = + when (consent) { + MarketingConsent.SMS -> isSmsChecked + MarketingConsent.EMAIL -> isEmailChecked + MarketingConsent.PUSH -> isPushChecked + } + private class RecordingReporter : ErrorReporter { val stages = mutableListOf() diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt new file mode 100644 index 000000000..61669efa7 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -0,0 +1,117 @@ +package com.afternote.feature.setting.presentation.notification + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertFalse +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 + +/** + * 알림 설정 화면이 마케팅 동의 저장 실패 신호를 받아 안내하고 소비하는지, STARTED 밖의 실패를 + * 재진입에 재생하지 않는지 화면 배선까지 확인한다 (#1502, #558). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class NotificationSettingSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val failedMessage = context.getString(R.string.setting_marketing_consent_save_failed) + private val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { UserMarketingConsent(sms = true, email = true, push = true) } + onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } + } + private val viewModel = PushNotificationViewModel(context, repository, NoOpErrorReporter) + private val lifecycleOwner = ManualLifecycleOwner() + + @Test + fun `저장 실패 신호를 받으면 안내를 띄우고 신호를 소비한다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithText(failedMessage).assertExists() + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + } + + @Test + fun `STARTED 밖에서 난 저장 실패는 다시 시작해도 안내하지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 1 && viewModel.uiState.value.isEmailChecked + } + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + composeRule.waitForIdle() + + assertTrue(composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + private fun setScreen() { + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + AfternoteTheme { + NotificationSettingScreen( + onBack = {}, + onPushNotificationClick = {}, + viewModel = viewModel, + ) + } + } + } + } + + /** 화면이 보는 수명만 따로 움직인다. 호스트 액티비티는 RESUMED 로 둔 채 STARTED 밖을 흉내 낸다. */ + private class ManualLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + + override val lifecycle: Lifecycle get() = registry + + fun moveTo(state: Lifecycle.State) { + registry.currentState = state + } + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt index df72f27a5..1068f0f47 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt @@ -56,19 +56,19 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) assertFalse(viewModel.uiState.value.isAfternoteUpdating) @@ -91,9 +91,9 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(FakeSettingNotificationRepository(pushSetting = ALL_ON), reporter) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onMindRecordToggle(false) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertFalse(viewModel.uiState.value.isNewsletterOn) @@ -119,7 +119,7 @@ class PushNotificationSaveReportingTest { val store = ViewModelStore().apply { put(STORE_KEY, viewModel) } runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertEquals(1, repository.pushUpdateCalls.size) @@ -166,12 +166,12 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertFalse(viewModel.uiState.value.isMindRecordOn) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt index 8a3276dba..dc8063ec7 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt @@ -47,7 +47,7 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -67,22 +67,22 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = Int.MAX_VALUE) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) assertNull(viewModel.uiState.value.saveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) assertFalse(viewModel.uiState.value.isMindRecordOn) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) assertFalse(viewModel.uiState.value.isAfternoteOn) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) @@ -105,13 +105,13 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) assertNull(viewModel.uiState.value.saveFailure) assertFalse(viewModel.uiState.value.isMindRecordOn) @@ -139,7 +139,7 @@ class PushNotificationViewModelTest { ) runCurrent() - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertEquals(PushNotificationSaveFailure.NETWORK, viewModel.uiState.value.saveFailure) @@ -154,8 +154,8 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onNewsletterToggle(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -167,6 +167,76 @@ class PushNotificationViewModelTest { assertFalse(viewModel.uiState.value.isNewsletterUpdating) } + @Test + fun `재시도를 연달아 보내도 실패한 변경을 한 번만 다시 저장한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + runCurrent() + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + viewModel.onIntent(PushNotificationIntent.RetrySave) + assertTrue(viewModel.uiState.value.isAfternoteUpdating) + runCurrent() + + assertEquals( + List(2) { PushUpdateCall(timeLetter = null, mindRecord = null, afterNote = false) }, + calls, + ) + assertFalse(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + assertNull(viewModel.uiState.value.failedUpdate) + } + + @Test + fun `실패 안내를 닫으면 재시도 대상도 비워 이후 재시도가 요청을 보내지 않는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + runCurrent() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) + + assertNull(viewModel.uiState.value.saveFailure) + assertNull(viewModel.uiState.value.failedUpdate) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(listOf(PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null)), calls) + assertTrue(viewModel.uiState.value.isNewsletterOn) + } + + @Test + fun `서로 다른 토글이 연달아 실패하면 재시도 대상은 마지막 실패만 남는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 2) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + runCurrent() + + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), calls.last()) + assertEquals(3, calls.size) + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isMindRecordOn) + } + private fun viewModel( calls: MutableList, failUpdateAttempts: Int = 0, diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 26d7bb7cf..10f60a322 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -26,6 +26,10 @@ import java.io.File * 49개는 [PENDING_MVI_MIGRATION] 예외로 두고, 모듈 전환 이슈가 닫힐 때마다 뺀다. * - **C.** `MviIntent`·`ReducerEvent` 를 직접 구현하는 화면 계약 타입은 `sealed interface` 다. * 열려 있으면 `when` 이 전수 분기를 보장하지 못해 진입점 단일화의 이득이 사라진다. + * - **D.** 아직 전환하지 않은 `feature/…/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 + * 새로 들이지 않는다 (#1502). 규칙 A 는 전환한 ViewModel 만 보므로, 이 규칙이 없으면 [PENDING_MVI_MIGRATION] + * 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘릴 수 있다. 가드 도입 시점에 이미 있던 선언은 + * [LEGACY_EVENT_STREAMS] 기준선으로 인정한다. `MutableStateFlow` 는 전환 전 상태 홀더라 대상이 아니다. * * 기준 문서는 `docs/convention/mvi.md` 다. * @@ -93,6 +97,38 @@ class MviContractKonsistTest { ) } + @Test + fun `미전환 ViewModel 도 이벤트 스트림을 새로 들이지 않는다`() { + val violations = legacyEventStreams(AfternoteKonsistScope.productionFiles) - LEGACY_EVENT_STREAMS + + check(violations.isEmpty()) { + buildString { + appendLine("MVI 미전환 ViewModel 이 이벤트용 Channel·MutableSharedFlow 를 새로 선언한다 (${violations.size}건).") + appendLine("producer 가 consumer 보다 오래 살면 Channel·SharedFlow 는 전달을 보장하지 못한다 (#228).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("일회성 신호는 UiState 의 nullable 필드로 흡수하고 소비는 Intent 로 받는다 (#1502).") + appendLine("기존 선언을 옮긴 것이면 ViewModel 을 MviViewModel 로 전환한다. 기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `해소된 이벤트 스트림 기준선은 경고로 알린다`() { + val stale = LEGACY_EVENT_STREAMS - legacyEventStreams(AfternoteKonsistScope.productionFiles) + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] LEGACY_EVENT_STREAMS 에 지금 소스에 없는 항목이 남아 있다 (${stale.size}건).") + appendLine("전환이 끝났으면 지운다 — 남겨 두면 같은 이름으로 다시 들인 스트림이 기준선에 숨는다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + @Test fun `Intent 와 ReducerEvent 는 sealed interface 다`() { val violations = openContractTypes(AfternoteKonsistScope.productionFiles) @@ -219,6 +255,60 @@ class MviContractKonsistTest { check(violations.any { it.endsWith("object Dismiss") }) { violations.toString() } } + @Test + fun `규칙 D - 미전환 ViewModel 의 Channel·MutableSharedFlow 만 잡는다`() { + val root = fixture.newFolder("rule-d") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/LegacyViewModel.kt", + """ + package sample + + class LegacyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SampleUiState()) + private val _events = Channel(Channel.BUFFERED) + private val signals = MutableSharedFlow(replay = 0) + private val plain = 0 + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/MigratedViewModel.kt", + """ + package sample + + class MigratedViewModel : MviViewModel(SampleUiState()) { + private val signals = Channel() + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/SnackbarRelay.kt", + """ + package sample + + class SnackbarRelay { + private val messages = Channel() + } + """, + ) + root.writeKotlin( + "app/src/main/kotlin/app/AppViewModel.kt", + """ + package app + + class AppViewModel : ViewModel() { + private val _events = Channel() + } + """, + ) + + val violations = legacyEventStreams(fixtureFiles(root)) + + // 상태 홀더(MutableStateFlow)는 전환 전 ViewModel 의 정상 구조다. 전환한 ViewModel 은 규칙 A, + // ViewModel 이 아닌 타입과 app 은 이 규칙 밖이다(app 은 #1809 몫). + assertEquals(setOf("sample.LegacyViewModel._events", "sample.LegacyViewModel.signals"), violations) + } + @Test fun `규칙 A·B - 중간 추상 베이스를 낀 상속도 MviViewModel 로 본다`() { val root = fixture.newFolder("indirect-parents") @@ -267,6 +357,25 @@ class MviContractKonsistTest { }.toSet() } + /** 규칙 D 위반 후보 — `feature/…/presentation` main 소스의 미전환 ViewModel 이 든 이벤트 스트림(`FQN.프로퍼티`). */ + private fun legacyEventStreams(files: List): Set { + val extendsMvi = mviViewModelSubclassTest(files) + return files + .filter { FEATURE_PRESENTATION_MAIN.containsMatchIn(it.normalizedProjectPath()) } + .flatMap { file -> + file + .classes() + .filter { it.name.endsWith(VIEW_MODEL_SUFFIX) } + .filterNot(extendsMvi) + .flatMap { declaration -> + declaration + .properties() + .filter { property -> EVENT_STREAM.containsMatchIn(property.text) } + .map { property -> "${file.packagee?.name}.${declaration.name}.${property.name}" } + } + }.toSet() + } + /** 규칙 A 위반 — 베이스를 상속한 ViewModel 이 직접 든 상태 홀더. */ private fun stateHolderDeclarations(files: List): List { val extendsMvi = mviViewModelSubclassTest(files) @@ -363,6 +472,21 @@ class MviContractKonsistTest { */ val STATE_HOLDER = Regex("""\b(MutableStateFlow|MutableSharedFlow|Channel)\s*[(<]""") + /** 규칙 D 의 이벤트 스트림 선언. [STATE_HOLDER] 에서 전환 전 상태 홀더인 `MutableStateFlow` 만 뺐다. */ + val EVENT_STREAM = Regex("""\b(MutableSharedFlow|Channel)\s*[(<]""") + + /** + * 규칙 D 의 기준선 — 가드 도입(#1502) 때 이미 있던 미전환 ViewModel 의 이벤트 스트림. + * + * 새 항목은 넣지 않는다. 해당 ViewModel 이 전환되거나 지워지면 여기서도 뺀다(남으면 경고). + */ + val LEGACY_EVENT_STREAMS = + setOf( + // #944 가 카카오 초대(BE#289)로 교체한다. 폼 값과 이메일 등록 버튼이 PM 결정 대기라 + // #1502 전환 범위에서 뺐다. + "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel._events", + ) + /** #1804 가 뺀다. */ private val ISSUE_1804_AFTERNOTE = setOf( @@ -426,13 +550,14 @@ class MviContractKonsistTest { "com.afternote.feature.receiver.presentation.senderdetail.SenderDetailViewModel", ) - /** #1805 가 뺀다. `Channel` 5곳 흡수(#1502)가 선행이다. */ + /** + * #1805 가 뺀다. 이벤트 스트림을 쓰던 ConnectedAccounts·PushNotification 은 #1502 가 전환했다. + * ReceiverRegisterViewModel 은 #944 가 카카오 초대로 교체하며 뺀다([LEGACY_EVENT_STREAMS]). + */ private val ISSUE_1805_SETTING = setOf( - "com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel", "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", "com.afternote.feature.setting.presentation.home.SettingViewModel", - "com.afternote.feature.setting.presentation.notification.PushNotificationViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", ) From e0af7b118781ca01582757db327854385bd8a16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:57:01 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(setting):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=A9=88?= =?UTF-8?q?=EC=B6=94=EB=A9=B4=20=EB=96=A0=20=EC=9E=88=EB=8D=98=20=EB=A7=88?= =?UTF-8?q?=EC=BC=80=ED=8C=85=20=EB=8F=99=EC=9D=98=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=8F=84=20=EA=B1=B0=EB=91=94=EB=8B=A4=20(#1?= =?UTF-8?q?502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVI 전환 뒤 실패 안내 스낵바를 화면 코루틴(rememberCoroutineScope)에 띄우면서, 화면이 STOP 돼도 떠 있던 안내와 그 뒤에 줄 선 안내가 취소되지 않고 재진입에 떴다. 예전 repeatOnLifecycle(STARTED) 수집은 멈출 때 스낵바를 함께 취소했으므로 #558 의 재진입 재생 방지가 그 경로에서 깨진 것이다. - onStopOrDispose 에서 스낵바 작업을 취소해 예전 동작을 되살린다. - 두 실패가 겹친 채 멈췄다 돌아오는 화면 테스트를 더한다. 수정 전 코드에서 실패하는 것을 확인했다. - konsist 머리 KDoc 의 규칙 개수를 넷으로 고친다(규칙 D 추가분). --- .../notification/NotificationSettingScreen.kt | 12 +++++-- .../NotificationSettingSignalTest.kt | 33 +++++++++++++++++++ .../konsist/MviContractKonsistTest.kt | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index df4342536..dfe059cdf 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -45,6 +45,7 @@ import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.launch @Composable @@ -84,14 +85,19 @@ internal fun NotificationSettingScreen( viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) } + // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. + val snackbarScope = rememberCoroutineScope() + // 화면이 STARTED 인 동안만 마케팅 동의 저장 실패를 안내한다. 화면이 없는 동안의 실패는 다음 진입에 재생하지 않는다 (#558). + // 멈출 때는 떠 있던 안내와 줄 선 안내도 거둔다. 화면 코루틴은 STOP 에 취소되지 않으므로 그대로 두면 재진입에 뜬다. LifecycleStartEffect(viewModel) { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - onStopOrDispose { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) } + onStopOrDispose { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + snackbarScope.coroutineContext.cancelChildren() + } } - // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. - val snackbarScope = rememberCoroutineScope() ObserveFlag( raised = uiState.isMarketingConsentSaveFailed, consumed = PushNotificationIntent.ConsumeMarketingConsentSaveFailure, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt index 61669efa7..4ef2fdb83 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -86,6 +86,36 @@ class NotificationSettingSignalTest { assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) } + @Test + fun `안내 중에 화면이 멈추면 떠 있던 안내와 밀린 안내를 거둬 재진입에 띄우지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + // 첫 실패의 안내가 떠 있는 동안 둘째 실패가 나서 그 뒤에 줄을 선다. + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { isFailedMessageShown() } + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 2 && + viewModel.uiState.value.isEmailChecked && + !viewModel.uiState.value.isMarketingConsentSaveFailed + } + + // 안내 시간이 흘러 저절로 닫히지 않게 시계를 세운다. 멈췄다 돌아오는 것만으로 걷히는지 본다. + composeRule.mainClock.autoAdvance = false + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.mainClock.advanceTimeBy(SNACKBAR_EXIT_MILLIS) + + assertFalse(isFailedMessageShown()) + } + + private fun isFailedMessageShown(): Boolean = composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + private fun setScreen() { composeRule.setContent { CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { @@ -113,5 +143,8 @@ class NotificationSettingSignalTest { private companion object { const val TIMEOUT_MILLIS = 5_000L + + /** 스낵바가 사라지는 애니메이션이 끝나기에 넉넉하고, 짧은 안내 시간(4초)보다는 짧다. */ + const val SNACKBAR_EXIT_MILLIS = 1_000L } } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 10f60a322..75710a336 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -17,7 +17,7 @@ import java.io.File * 정착했는데 그 아래 전이 경로는 화면마다 갈렸고, 진입점을 하나로 모은 화면은 51개 중 * 1개뿐이었다. 규약을 문서로만 두면 같은 일이 반복된다. * - * 규칙은 셋이다. + * 규칙은 넷이다. * - **A.** `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 * 선언하지 않는다. 별도 상태 홀더를 두면 전이가 `reduce` 밖으로 새고 베이스를 도입한 의미가 * 사라진다. `Channel` 금지는 #1502 가 세우려는 규칙과 같은 방향이라 **두 규칙을 하나로 합친다** — From d138c4fa7b2ef501bbce87b4c7a985db68091834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:11:15 +0900 Subject: [PATCH 7/7] test(setting): cover profile save back and picker restoration (#1438) --- .../ProfileSaveBackAndroidTest.kt | 120 ++++++++++++ .../ProfileEditActivityRestorationTest.kt | 182 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt 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/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 + } +}