From 60799e612d69e37b421c89ffc786fd4ccfafc0d0 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 5 Aug 2026 15:24:00 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EB=A0=88=ED=84=B0?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20API=20=EB=B0=8F=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimeLetterDebugMockNetworkInterceptor.kt | 2 +- .../data/api/TimeLetterApiService.kt | 3 +- .../api/TimeLetterApiServiceContractTest.kt | 23 ++++ .../timeletter/presentation/build.gradle.kts | 1 + .../screen/sender/TimeletterScreen.kt | 34 ++++- .../viewmodel/TimeletterUiState.kt | 2 + .../viewmodel/TimeletterViewModel.kt | 32 ++++- .../src/main/res/values/strings.xml | 7 + .../viewmodel/TimeletterViewModelTest.kt | 127 ++++++++++++++++++ 9 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 feature/timeletter/data/src/test/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiServiceContractTest.kt create mode 100644 feature/timeletter/presentation/src/main/res/values/strings.xml create mode 100644 feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt diff --git a/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterDebugMockNetworkInterceptor.kt b/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterDebugMockNetworkInterceptor.kt index cf2ff424ed..01ebd36bde 100644 --- a/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterDebugMockNetworkInterceptor.kt +++ b/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterDebugMockNetworkInterceptor.kt @@ -42,7 +42,7 @@ class TimeLetterDebugMockNetworkInterceptor TimeLetterMockFixtures.MOCK_CREATE_JSON } - path == "/api/v1/time-letters/delete" && method == "POST" -> { + path == "/api/v1/time-letters" && method == "DELETE" -> { TimeLetterMockFixtures.MOCK_DELETE_JSON } diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiService.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiService.kt index 361017e851..bdce8e0dea 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiService.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiService.kt @@ -9,6 +9,7 @@ import com.afternote.feature.timeletter.data.dto.TimeLetterUpdateRequestDto import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path @@ -42,7 +43,7 @@ interface TimeLetterApiService { ): BaseResponse // 타임레터 단일/다건 삭제 - @POST("time-letters/delete") + @HTTP(method = "DELETE", path = "time-letters", hasBody = true) suspend fun deleteTimeLetters( @Body request: TimeLetterDeleteRequestDto, ): BaseResponse diff --git a/feature/timeletter/data/src/test/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiServiceContractTest.kt b/feature/timeletter/data/src/test/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiServiceContractTest.kt new file mode 100644 index 0000000000..fa8b4542f8 --- /dev/null +++ b/feature/timeletter/data/src/test/kotlin/com/afternote/feature/timeletter/data/api/TimeLetterApiServiceContractTest.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.timeletter.data.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.http.Body +import retrofit2.http.HTTP + +class TimeLetterApiServiceContractTest { + @Test + fun `deleteTimeLetters uses DELETE time-letters with a request body`() { + val method = + TimeLetterApiService::class.java.declaredMethods.single { + it.name == "deleteTimeLetters" + } + val http = requireNotNull(method.getAnnotation(HTTP::class.java)) + + assertEquals("DELETE", http.method) + assertEquals("time-letters", http.path) + assertTrue(http.hasBody) + assertTrue(method.parameterAnnotations.flatten().any { it is Body }) + } +} diff --git a/feature/timeletter/presentation/build.gradle.kts b/feature/timeletter/presentation/build.gradle.kts index 690fa4b1ad..dfa897a254 100644 --- a/feature/timeletter/presentation/build.gradle.kts +++ b/feature/timeletter/presentation/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.compose.wheel.picker) implementation(libs.coil.compose) + testImplementation(libs.coroutines.test) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt index 4bd2425429..23086895fb 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt @@ -12,9 +12,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -22,10 +24,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import com.afternote.core.ui.button.FAB.PenFloatingActionButton +import com.afternote.core.ui.popup.Popup +import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.topbar.HomeTopBar import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.EmptyTimeLetterContent import com.afternote.feature.timeletter.presentation.component.TimeLetterContent import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterUiState @@ -43,7 +48,23 @@ fun TimeletterScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var viewMode by remember { mutableStateOf(ViewMode.List) } + var pendingDeleteId by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } + val deleteFailureMessage = stringResource(R.string.timeletter_delete_failure) + + pendingDeleteId?.let { timeLetterId -> + Popup( + type = PopupType.Variant2, + message = stringResource(R.string.timeletter_delete_confirm_message), + confirmText = stringResource(R.string.timeletter_delete_confirm), + dismissText = stringResource(R.string.timeletter_delete_dismiss), + onConfirm = { + pendingDeleteId = null + viewModel.deleteTimeLetter(timeLetterId) + }, + onDismiss = { pendingDeleteId = null }, + ) + } val errorMessage = (uiState as? TimeletterUiState.Success)?.errorMessage LaunchedEffect(errorMessage) { @@ -55,6 +76,17 @@ fun TimeletterScreen( viewModel.consumeErrorMessage() } + val showDeleteFailure = (uiState as? TimeletterUiState.Success)?.showDeleteFailure == true + LaunchedEffect(showDeleteFailure) { + if (showDeleteFailure) { + snackbarHostState.showSnackbar( + message = deleteFailureMessage, + withDismissAction = true, + ) + viewModel.consumeDeleteFailure() + } + } + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -92,7 +124,7 @@ fun TimeletterScreen( onFilterClick = onFilterRecipientClick, onLetterClick = onLetterClick, onEditClick = onEditClick, - onDeleteClick = viewModel::deleteTimeLetter, + onDeleteClick = { pendingDeleteId = it }, modifier = Modifier.padding(paddingValues), ) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt index 30e5f68305..b833113ca0 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt @@ -9,6 +9,8 @@ sealed interface TimeletterUiState { val letters: TimeLetterList, val receiverNameMap: Map, val selectedFilterReceiverIds: Set = emptySet(), + val isDeleting: Boolean = false, + val showDeleteFailure: Boolean = false, val errorMessage: String? = null, ) : TimeletterUiState diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt index 67af2fc0a8..783aaa105c 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository @@ -35,6 +36,7 @@ class TimeletterViewModel private fun applyFilter() { val letters = allLetters ?: return val filterIds = selectedFilterReceiverIds + val currentState = _uiState.value as? TimeletterUiState.Success val filteredLetters = if (filterIds.isEmpty()) { letters @@ -50,6 +52,9 @@ class TimeletterViewModel letters = filteredLetters, receiverNameMap = receiverNameMap, selectedFilterReceiverIds = filterIds, + isDeleting = currentState?.isDeleting ?: false, + showDeleteFailure = currentState?.showDeleteFailure ?: false, + errorMessage = currentState?.errorMessage, ) } @@ -76,14 +81,26 @@ class TimeletterViewModel } fun deleteTimeLetter(timeLetterId: Long) { + val currentState = _uiState.value as? TimeletterUiState.Success ?: return + if (currentState.isDeleting) return + _uiState.value = + currentState.copy( + isDeleting = true, + showDeleteFailure = false, + errorMessage = null, + ) + viewModelScope.launch { - runCatching { timeLetterRepository.deleteTimeLetters(listOf(timeLetterId)) } + runCatchingCancellable { timeLetterRepository.deleteTimeLetters(listOf(timeLetterId)) } .onSuccess { load() } .onFailure { - val currentState = _uiState.value - if (currentState is TimeletterUiState.Success) { + val latestState = _uiState.value + if (latestState is TimeletterUiState.Success) { _uiState.value = - currentState.copy(errorMessage = "타임레터를 삭제할 수 없습니다.") + latestState.copy( + isDeleting = false, + showDeleteFailure = true, + ) } } } @@ -95,4 +112,11 @@ class TimeletterViewModel _uiState.value = currentState.copy(errorMessage = null) } } + + fun consumeDeleteFailure() { + val currentState = _uiState.value + if (currentState is TimeletterUiState.Success) { + _uiState.value = currentState.copy(showDeleteFailure = false) + } + } } diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1a478e3da3 --- /dev/null +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 타임레터를 삭제하시겠습니까? + 예 + 아니오 + 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. + diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt new file mode 100644 index 0000000000..0d18bebf4f --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -0,0 +1,127 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +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.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +@OptIn(ExperimentalCoroutinesApi::class) +class TimeletterViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `delete success reloads the time letter list`() { + var loadCount = 0 + var deletedIds: List? = null + val repository = + timeLetterRepository { methodName, args -> + when (methodName) { + "getTimeLetters" -> { + testLetters.also { loadCount += 1 } + } + + "deleteTimeLetters" -> { + Unit.also { + deletedIds = (args?.first() as? List<*>)?.filterIsInstance() + } + } + + else -> { + error("Unexpected repository call: $methodName") + } + } + } + val viewModel = TimeletterViewModel(repository, userRepository()) + + viewModel.load() + viewModel.deleteTimeLetter(1L) + + assertEquals(listOf(1L), deletedIds) + assertEquals(2, loadCount) + assertTrue(viewModel.uiState.value is TimeletterUiState.Success) + } + + @Test + fun `delete failure keeps the list and exposes a consumable failure state`() { + val repository = + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTimeLetters" -> testLetters + "deleteTimeLetters" -> throw IllegalStateException("delete failed") + else -> error("Unexpected repository call: $methodName") + } + } + val viewModel = TimeletterViewModel(repository, userRepository()) + + viewModel.load() + viewModel.deleteTimeLetter(1L) + + val failedState = viewModel.uiState.value as TimeletterUiState.Success + assertEquals(testLetters, failedState.letters) + assertFalse(failedState.isDeleting) + assertTrue(failedState.showDeleteFailure) + + viewModel.consumeDeleteFailure() + + assertFalse((viewModel.uiState.value as TimeletterUiState.Success).showDeleteFailure) + } + + private fun timeLetterRepository(handler: (String, Array?) -> Any?): TimeLetterRepository = + Proxy.newProxyInstance( + TimeLetterRepository::class.java.classLoader, + arrayOf(TimeLetterRepository::class.java), + ) { _, method, args -> handler(method.name, args) } as TimeLetterRepository + + private fun userRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getReceivers" -> emptyList() + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository + + private companion object { + val testLetters = + TimeLetterList( + timeLetters = + listOf( + TimeLetter( + id = 1L, + title = "제목", + sendAt = null, + deliveredAt = null, + status = TimeLetterStatus.SCHEDULED, + blocks = emptyList(), + receiverIds = emptyList(), + ), + ), + totalCount = 1, + ) + } +} From da16b73058401ce506410d5a2e86bf97d4dc855a Mon Sep 17 00:00:00 2001 From: kyungmin Date: Thu, 13 Aug 2026 00:31:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(timeletter):=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/sender/TimeletterScreen.kt | 10 ---------- .../viewmodel/TimeletterUiState.kt | 5 +---- .../viewmodel/TimeletterViewModel.kt | 20 ++++++++----------- .../viewmodel/TimeletterViewModelTest.kt | 19 +++++++++++++++++- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt index 23086895fb..89d4ae6844 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt @@ -66,16 +66,6 @@ fun TimeletterScreen( ) } - val errorMessage = (uiState as? TimeletterUiState.Success)?.errorMessage - LaunchedEffect(errorMessage) { - errorMessage ?: return@LaunchedEffect - snackbarHostState.showSnackbar( - message = errorMessage, - withDismissAction = true, - ) - viewModel.consumeErrorMessage() - } - val showDeleteFailure = (uiState as? TimeletterUiState.Success)?.showDeleteFailure == true LaunchedEffect(showDeleteFailure) { if (showDeleteFailure) { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt index b833113ca0..8f9769e166 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterUiState.kt @@ -11,10 +11,7 @@ sealed interface TimeletterUiState { val selectedFilterReceiverIds: Set = emptySet(), val isDeleting: Boolean = false, val showDeleteFailure: Boolean = false, - val errorMessage: String? = null, ) : TimeletterUiState - data class Error( - val message: String, - ) : TimeletterUiState + data object Error : TimeletterUiState } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt index 783aaa105c..ee35313720 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt @@ -54,7 +54,6 @@ class TimeletterViewModel selectedFilterReceiverIds = filterIds, isDeleting = currentState?.isDeleting ?: false, showDeleteFailure = currentState?.showDeleteFailure ?: false, - errorMessage = currentState?.errorMessage, ) } @@ -75,7 +74,7 @@ class TimeletterViewModel allLetters = letters applyFilter() }.onFailure { - _uiState.value = TimeletterUiState.Error("타임레터를 불러올 수 없습니다.") + _uiState.value = TimeletterUiState.Error } } } @@ -87,13 +86,17 @@ class TimeletterViewModel currentState.copy( isDeleting = true, showDeleteFailure = false, - errorMessage = null, ) viewModelScope.launch { runCatchingCancellable { timeLetterRepository.deleteTimeLetters(listOf(timeLetterId)) } - .onSuccess { load() } - .onFailure { + .onSuccess { + val latestState = _uiState.value + if (latestState is TimeletterUiState.Success) { + _uiState.value = latestState.copy(isDeleting = false) + } + load() + }.onFailure { val latestState = _uiState.value if (latestState is TimeletterUiState.Success) { _uiState.value = @@ -106,13 +109,6 @@ class TimeletterViewModel } } - fun consumeErrorMessage() { - val currentState = _uiState.value - if (currentState is TimeletterUiState.Success) { - _uiState.value = currentState.copy(errorMessage = null) - } - } - fun consumeDeleteFailure() { val currentState = _uiState.value if (currentState is TimeletterUiState.Success) { diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt index 0d18bebf4f..ee1ec40f92 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -60,7 +60,8 @@ class TimeletterViewModelTest { assertEquals(listOf(1L), deletedIds) assertEquals(2, loadCount) - assertTrue(viewModel.uiState.value is TimeletterUiState.Success) + val state = viewModel.uiState.value as TimeletterUiState.Success + assertFalse(state.isDeleting) } @Test @@ -88,6 +89,22 @@ class TimeletterViewModelTest { assertFalse((viewModel.uiState.value as TimeletterUiState.Success).showDeleteFailure) } + @Test + fun `load failure exposes error state`() { + val repository = + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTimeLetters" -> throw IllegalStateException("load failed") + else -> error("Unexpected repository call: $methodName") + } + } + val viewModel = TimeletterViewModel(repository, userRepository()) + + viewModel.load() + + assertEquals(TimeletterUiState.Error, viewModel.uiState.value) + } + private fun timeLetterRepository(handler: (String, Array?) -> Any?): TimeLetterRepository = Proxy.newProxyInstance( TimeLetterRepository::class.java.classLoader, From fe63618fbc23a88bb957fba5d62d3d8e7fe1e85d Mon Sep 17 00:00:00 2001 From: kyungmin Date: Fri, 14 Aug 2026 23:53:42 +0900 Subject: [PATCH 3/3] fix(timeletter): preserve delete failure during reload --- .../viewmodel/TimeletterViewModel.kt | 20 +++--- .../viewmodel/TimeletterViewModelTest.kt | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt index ee35313720..d2fc8b1ff1 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModel.kt @@ -80,10 +80,10 @@ class TimeletterViewModel } fun deleteTimeLetter(timeLetterId: Long) { - val currentState = _uiState.value as? TimeletterUiState.Success ?: return - if (currentState.isDeleting) return + val stateBeforeDelete = _uiState.value as? TimeletterUiState.Success ?: return + if (stateBeforeDelete.isDeleting) return _uiState.value = - currentState.copy( + stateBeforeDelete.copy( isDeleting = true, showDeleteFailure = false, ) @@ -97,14 +97,12 @@ class TimeletterViewModel } load() }.onFailure { - val latestState = _uiState.value - if (latestState is TimeletterUiState.Success) { - _uiState.value = - latestState.copy( - isDeleting = false, - showDeleteFailure = true, - ) - } + val latestSuccess = _uiState.value as? TimeletterUiState.Success + _uiState.value = + (latestSuccess ?: stateBeforeDelete).copy( + isDeleting = false, + showDeleteFailure = true, + ) } } } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt index ee1ec40f92..a09458bf70 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -1,10 +1,12 @@ package com.afternote.feature.timeletter.presentation.viewmodel import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -89,6 +91,29 @@ class TimeletterViewModelTest { assertFalse((viewModel.uiState.value as TimeletterUiState.Success).showDeleteFailure) } + @Test + fun `delete failure restores success state when reload is in progress`() { + val deleteResult = CompletableDeferred() + val reloadResult = CompletableDeferred() + val repository = overlappingDeleteAndReloadRepository(deleteResult, reloadResult) + val viewModel = TimeletterViewModel(repository, userRepository()) + + viewModel.load() + viewModel.deleteTimeLetter(1L) + viewModel.load() + + assertEquals(TimeletterUiState.Loading, viewModel.uiState.value) + + deleteResult.completeExceptionally(IllegalStateException("delete failed")) + + val failedState = viewModel.uiState.value as TimeletterUiState.Success + assertEquals(testLetters, failedState.letters) + assertFalse(failedState.isDeleting) + assertTrue(failedState.showDeleteFailure) + + reloadResult.complete(testLetters) + } + @Test fun `load failure exposes error state`() { val repository = @@ -123,6 +148,42 @@ class TimeletterViewModelTest { } } as UserRepository + private fun overlappingDeleteAndReloadRepository( + deleteResult: CompletableDeferred, + reloadResult: CompletableDeferred, + ): TimeLetterRepository = + object : TimeLetterRepository { + private var loadCount = 0 + + override suspend fun getTimeLetters(): TimeLetterList = if (loadCount++ == 0) testLetters else reloadResult.await() + + override suspend fun deleteTimeLetters(timeLetterIds: List) { + deleteResult.await() + } + + override suspend fun getTemporaryTimeLetters(): TimeLetterList = error("Unexpected call") + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = error("Unexpected call") + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + status: TimeLetterStatus, + receiverIds: List?, + ): TimeLetter = error("Unexpected call") + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + status: TimeLetterStatus?, + ): TimeLetter = error("Unexpected call") + + override suspend fun deleteAllTemporary() = error("Unexpected call") + } + private companion object { val testLetters = TimeLetterList(