diff --git a/feature/setting/domain/build.gradle.kts b/feature/setting/domain/build.gradle.kts index 39b64eebd..9b6f1c894 100644 --- a/feature/setting/domain/build.gradle.kts +++ b/feature/setting/domain/build.gradle.kts @@ -6,6 +6,12 @@ plugins { dependencies { // 계정·알림 계약이 노출하는 도메인 모델(UserConnectedAccount·UserPushSetting·UserMarketingConsent). implementation(projects.core.model) + // 수신자 저장 UseCase 가 core 계약 UserReceiverRepository 를 쓴다 (#1691). + implementation(projects.core.domain) + implementation(libs.coroutines.core) testFixturesImplementation(projects.core.model) + + testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.core.domain)) } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt new file mode 100644 index 000000000..3444b9aa1 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt @@ -0,0 +1,68 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.repository.UserReceiverRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import javax.inject.Inject + +/** + * 수신자 저장 결과 (#1691). + * + * 기본정보와 인사말은 서로 다른 쓰기라 실패한 요청 단계에 따라 사용자에게 알릴 내용을 구분한다. + * 인사말 요청 단계에서 실패하면 기본정보 요청은 이미 성공한 상태다. + */ +public sealed interface UpdateReceiverInfoResult { + public data object Success : UpdateReceiverInfoResult + + /** + * 기본정보 쓰기가 실패해 인사말 쓰기를 시도하지 않았다. + * + * [cause] 는 저장소가 던진 원인 그대로다. 소비자가 거절 예외를 가려 문구를 고르므로 감싸지 않는다. + * `Exception` 이 아니라 `Throwable` 인 것은 이 정책을 옮겨 오기 전 ViewModel 이 쓰던 + * `runCatchingCancellable` 의 포착 범위(취소를 뺀 모든 `Throwable`)를 그대로 보존한 결과다. + */ + public data class BasicInfoFailed( + public val cause: Throwable, + ) : UpdateReceiverInfoResult + + public data object MessageFailedAfterBasicInfoUpdated : UpdateReceiverInfoResult +} + +/** + * 수신자 기본정보를 저장한 뒤 인사말을 저장한다 (#1691). + * + * 순서를 고정한다. 기본정보가 실패하면 인사말은 시도하지 않는다. 두 쓰기 사이에서 취소되면 + * 인사말 쓰기를 시작하지 않고 취소를 그대로 전파한다. + */ +public class UpdateReceiverInfoUseCase + @Inject + constructor( + private val repository: UserReceiverRepository, + ) { + public suspend operator fun invoke( + receiverId: Long, + name: String, + phone: String, + relation: String, + email: String, + message: String, + ): UpdateReceiverInfoResult { + try { + repository.updateReceiver(receiverId, name, phone, relation, email) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + return UpdateReceiverInfoResult.BasicInfoFailed(failure) + } + currentCoroutineContext().ensureActive() + return try { + repository.updateReceiverMessage(receiverId, message) + UpdateReceiverInfoResult.Success + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated + } + } + } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt new file mode 100644 index 000000000..83c12da89 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import javax.inject.Inject + +/** + * 타임레터 전달조건만 바꿔 수신자의 전달조건 전량을 저장한다 (#1691). + * + * 저장 API 가 조건 목록을 통째로 받으므로 다른 콘텐츠의 조건은 손대지 않고 그대로 실어 보낸다. + * 타임레터 조건이 아직 없으면 ACTIVE 로 합성하고, 수신자 요청 방식에는 무활동 기간을 비운다. + * + * 실패는 호출부가 판정한다. 이 UseCase 는 예외를 삼키지 않고 저장소 응답을 그대로 돌려준다. + */ +public class UpdateTimeLetterDeliveryConditionUseCase + @Inject + constructor( + private val repository: UserReceiverRepository, + ) { + public suspend operator fun invoke( + receiverId: Long, + conditions: List, + conditionType: DeliveryConditionType, + inactivityPeriod: InactivityPeriod, + ): ReceiverDeliveryConditions { + val period = inactivityPeriod.takeIf { conditionType == DeliveryConditionType.INACTIVITY } + val updated = + conditions + .map { condition -> + if (condition.contentType == DeliveryContentType.TIME_LETTER) { + condition.copy(conditionType = conditionType, inactivityPeriod = period) + } else { + condition + } + }.let { items -> + if (items.any { it.contentType == DeliveryContentType.TIME_LETTER }) { + items + } else { + items + + DeliveryConditionItem( + contentType = DeliveryContentType.TIME_LETTER, + conditionType = conditionType, + inactivityPeriod = period, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ) + } + } + return repository.updateReceiverDeliveryConditions(receiverId, updated) + } + } diff --git a/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt new file mode 100644 index 000000000..860f6cd60 --- /dev/null +++ b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt @@ -0,0 +1,181 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverSavePolicyTest { + @Test + fun `basic info failure skips message and preserves cause`() = + runTest { + val error = IllegalStateException("save failed") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error }) + val result = save(repository) + assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause) + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + /** + * 옮겨 오기 전 ViewModel 은 `runCatchingCancellable` 로 감싸 `Exception` 이 아닌 `Throwable` 도 + * 화면 오류로 바꿨다. UseCase 가 `Exception` 만 잡으면 그 경로가 코루틴 밖으로 튀어 화면이 저장 중에 + * 머문다 — 포착 범위를 좁히는 회귀를 여기서 막는다. + */ + @Test + fun `basic info failure keeps non exception throwable as cause`() = + runTest { + val error = AssertionError("save failed") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error }) + val result = save(repository) + assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause) + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + @Test + fun `message failure reports already saved basic info`() = + runTest { + val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> error("message failed") }) + assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository)) + assertEquals( + "updated", + repository.receiverState.value + .single() + .name, + ) + assertEquals(1, repository.receiverMessageCalls.size) + } + + /** 인사말 쪽도 같은 이유로 `Throwable` 까지 부분 저장 결과로 접는다. */ + @Test + fun `message failure reports partial save for non exception throwable`() = + runTest { + val repository = + FakeUserReceiverRepository( + onUpdateReceiverMessage = { _, _ -> throw AssertionError("message failed") }, + ) + assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository)) + assertEquals(1, repository.receiverMessageCalls.size) + } + + @Test + fun `success saves basic info before message`() = + runTest { + val repository = FakeUserReceiverRepository() + repository.onUpdateReceiverMessage = { _, _ -> + assertEquals( + "updated", + repository.receiverState.value + .single() + .name, + ) + } + assertEquals(UpdateReceiverInfoResult.Success, save(repository)) + } + + @Test + fun `cancellation between writes does not save message`() = + runTest { + val repository = FakeUserReceiverRepository() + repository.onUpdateReceiver = { _, _, _, _, _ -> + currentCoroutineContext().cancel() + repository.receiverState.value.single() + } + var completed = false + val job = + launch { + save(repository) + completed = true + } + job.join() + assertTrue(job.isCancelled) + assertTrue(repository.receiverMessageCalls.isEmpty()) + assertEquals(false, completed) + } + + @Test + fun `basic info cancellation propagates`() = + runTest { + val cancelled = CancellationException("cancelled") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw cancelled }) + try { + save(repository) + error("Expected cancellation") + } catch (actual: CancellationException) { + assertSame(cancelled, actual) + } + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + @Test + fun `message cancellation propagates`() = + runTest { + val cancelled = CancellationException("cancelled") + val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> throw cancelled }) + try { + save(repository) + error("Expected cancellation") + } catch (actual: CancellationException) { + assertSame(cancelled, actual) + } + } + + @Test + fun `time letter patch preserves other conditions and removes request period`() = + runTest { + val otherType = DeliveryContentType.entries.first { it != DeliveryContentType.TIME_LETTER } + val other = condition(otherType) + val timeLetter = condition(DeliveryContentType.TIME_LETTER) + val repository = FakeUserReceiverRepository() + val result = + UpdateTimeLetterDeliveryConditionUseCase(repository)( + 7L, + listOf(other, timeLetter), + DeliveryConditionType.RECEIVER_REQUEST, + InactivityPeriod.ONE_YEAR, + ) + assertEquals( + listOf(other, timeLetter.copy(conditionType = DeliveryConditionType.RECEIVER_REQUEST, inactivityPeriod = null)), + result.conditions, + ) + assertEquals(result.conditions, repository.deliveryUpdateCalls.single().conditions) + } + + @Test + fun `missing time letter is synthesized with selected inactivity period`() = + runTest { + val repository = FakeUserReceiverRepository() + val result = + UpdateTimeLetterDeliveryConditionUseCase(repository)( + 7L, + emptyList(), + DeliveryConditionType.INACTIVITY, + InactivityPeriod.ONE_YEAR, + ) + assertEquals(listOf(condition(DeliveryContentType.TIME_LETTER)), result.conditions) + } + + private suspend fun save(repository: FakeUserReceiverRepository): UpdateReceiverInfoResult = + UpdateReceiverInfoUseCase(repository)(7L, "updated", "01012345678", "가족", "receiver@test.com", "hello") + + private fun condition(type: DeliveryContentType) = + DeliveryConditionItem( + contentType = type, + conditionType = DeliveryConditionType.INACTIVITY, + inactivityPeriod = InactivityPeriod.ONE_YEAR, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 2f86862f4..d09b5a31c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -3,12 +3,11 @@ package com.afternote.feature.setting.presentation.delivery import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository -import com.afternote.core.model.delivery.ConditionState -import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -26,6 +25,7 @@ internal class DeliveryConditionViewModel constructor( @Assisted route: SettingRoute.AfterDeliveryRoute, private val receiverRepository: UserReceiverRepository, + private val updateTimeLetterDeliveryCondition: UpdateTimeLetterDeliveryConditionUseCase, ) : MviViewModel(DeliveryConditionUiState()) { private val receiverId = route.receiverId @@ -102,41 +102,15 @@ internal class DeliveryConditionViewModel val state = currentState if (!state.isInitialized || state.isSaveLocked) return - val hasTimeLetterCondition = - state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } - val updatedConditions = - state.conditions - .map { condition -> - if (condition.contentType == DeliveryContentType.TIME_LETTER) { - condition.copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } else { - condition - } - }.let { conditions -> - if (hasTimeLetterCondition) { - conditions - } else { - conditions + - defaultCondition(DeliveryContentType.TIME_LETTER).copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } - } - dispatch(DeliveryConditionReducerEvent.Saving) viewModelScope.launch { runCatchingCancellable { - receiverRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) + updateTimeLetterDeliveryCondition( + receiverId = receiverId, + conditions = state.conditions, + conditionType = state.conditionType, + inactivityPeriod = state.inactivityPeriod, + ) }.onSuccess { response -> dispatch(DeliveryConditionReducerEvent.Saved(response.conditions)) }.onFailure { @@ -145,17 +119,6 @@ internal class DeliveryConditionViewModel } } - private fun defaultCondition(contentType: DeliveryContentType) = - DeliveryConditionItem( - contentType = contentType, - conditionType = DeliveryConditionType.INACTIVITY, - inactivityPeriod = InactivityPeriod.ONE_YEAR, - state = ConditionState.ACTIVE, - fulfilled = false, - gracePeriodStartedAt = null, - fulfilledAt = null, - ) - @AssistedFactory interface Factory { fun create(route: SettingRoute.AfterDeliveryRoute): DeliveryConditionViewModel diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 6da2b1aae..33bbc3b17 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -5,6 +5,8 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.domain.UpdateReceiverInfoResult +import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.assisted.Assisted @@ -23,6 +25,7 @@ internal class ReceiverEditViewModel constructor( @Assisted route: SettingRoute.RecipientEditRoute, private val receiverRepository: UserReceiverRepository, + private val updateReceiverInfo: UpdateReceiverInfoUseCase, ) : MviViewModel(ReceiverEditUiState()) { private val receiverId = route.receiverId @@ -88,26 +91,35 @@ internal class ReceiverEditViewModel dispatch(ReceiverEditReducerEvent.Saving) viewModelScope.launch { - val basicInfoResult = - runCatchingCancellable { - receiverRepository.updateReceiver( - receiverId = receiverId, - name = name, - phone = phone.normalizeReceiverPhone(), - relation = relation, - email = email.trim(), + val result = + updateReceiverInfo( + receiverId = receiverId, + name = name, + phone = phone.normalizeReceiverPhone(), + relation = relation, + email = email.trim(), + message = message, + ) + when (result) { + is UpdateReceiverInfoResult.BasicInfoFailed -> { + dispatch( + ReceiverEditReducerEvent.SaveFailed( + result.cause.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), + ), ) } - basicInfoResult.exceptionOrNull()?.let { failure -> - dispatch(ReceiverEditReducerEvent.SaveFailed(failure.toReceiverFailureMessage(R.string.setting_receiver_edit_failed))) - return@launch - } - runCatchingCancellable { - receiverRepository.updateReceiverMessage(receiverId = receiverId, message = message) - }.onSuccess { - dispatch(ReceiverEditReducerEvent.Saved) - }.onFailure { - dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(R.string.setting_receiver_message_update_partial_failed))) + + UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated -> { + dispatch( + ReceiverEditReducerEvent.SaveFailed( + UiText.Resource(R.string.setting_receiver_message_update_partial_failed), + ), + ) + } + + UpdateReceiverInfoResult.Success -> { + dispatch(ReceiverEditReducerEvent.Saved) + } } } } 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 b1e985377..f070c9a2a 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 @@ -22,6 +22,7 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.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 @@ -211,6 +212,7 @@ class SettingAccountSecurityTest { DeliveryConditionViewModel( route = SettingRoute.AfterDeliveryRoute(RECEIVER_ID), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized 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 2ec681531..3780c5f5e 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 @@ -34,6 +34,8 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +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.ConnectedAccountsViewModel @@ -387,6 +389,7 @@ class SettingCompletionTest { ReceiverEditViewModel( route = SettingRoute.RecipientEditRoute(RECEIVER_ID), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.receiver == COMPLETION_DEFAULT_RECEIVER_DETAIL @@ -488,6 +491,7 @@ class SettingCompletionTest { ReceiverEditViewModel( route = SettingRoute.RecipientEditRoute(RECEIVER_ID), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.setContent { @@ -546,6 +550,7 @@ class SettingCompletionTest { DeliveryConditionViewModel( route = SettingRoute.AfterDeliveryRoute(RECEIVER_ID), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized