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 a454195206..da868c49fe 100644 --- a/feature/timeletter/presentation/build.gradle.kts +++ b/feature/timeletter/presentation/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.compose.wheel.picker) implementation(libs.coil.compose) + testImplementation(libs.coroutines.test) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.junit) 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 11e57a58ac..84639183fa 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,10 +12,12 @@ 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.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -23,10 +25,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 @@ -45,16 +50,33 @@ 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) - val errorMessage = (uiState as? TimeletterUiState.Success)?.errorMessage - LaunchedEffect(errorMessage) { - errorMessage ?: return@LaunchedEffect - snackbarHostState.showSnackbar( - message = errorMessage, - withDismissAction = true, + 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 }, ) - 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 @@ -95,7 +117,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..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 @@ -9,10 +9,9 @@ sealed interface TimeletterUiState { val letters: TimeLetterList, val receiverNameMap: Map, val selectedFilterReceiverIds: Set = emptySet(), - val errorMessage: String? = null, + val isDeleting: Boolean = false, + val showDeleteFailure: Boolean = false, ) : 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 67af2fc0a8..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 @@ -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,8 @@ class TimeletterViewModel letters = filteredLetters, receiverNameMap = receiverNameMap, selectedFilterReceiverIds = filterIds, + isDeleting = currentState?.isDeleting ?: false, + showDeleteFailure = currentState?.showDeleteFailure ?: false, ) } @@ -70,29 +74,43 @@ class TimeletterViewModel allLetters = letters applyFilter() }.onFailure { - _uiState.value = TimeletterUiState.Error("타임레터를 불러올 수 없습니다.") + _uiState.value = TimeletterUiState.Error } } } fun deleteTimeLetter(timeLetterId: Long) { + val stateBeforeDelete = _uiState.value as? TimeletterUiState.Success ?: return + if (stateBeforeDelete.isDeleting) return + _uiState.value = + stateBeforeDelete.copy( + isDeleting = true, + showDeleteFailure = false, + ) + viewModelScope.launch { - runCatching { timeLetterRepository.deleteTimeLetters(listOf(timeLetterId)) } - .onSuccess { load() } - .onFailure { - val currentState = _uiState.value - if (currentState is TimeletterUiState.Success) { - _uiState.value = - currentState.copy(errorMessage = "타임레터를 삭제할 수 없습니다.") + runCatchingCancellable { timeLetterRepository.deleteTimeLetters(listOf(timeLetterId)) } + .onSuccess { + val latestState = _uiState.value + if (latestState is TimeletterUiState.Success) { + _uiState.value = latestState.copy(isDeleting = false) } + load() + }.onFailure { + val latestSuccess = _uiState.value as? TimeletterUiState.Success + _uiState.value = + (latestSuccess ?: stateBeforeDelete).copy( + isDeleting = false, + showDeleteFailure = true, + ) } } } - fun consumeErrorMessage() { + fun consumeDeleteFailure() { val currentState = _uiState.value if (currentState is TimeletterUiState.Success) { - _uiState.value = currentState.copy(errorMessage = null) + _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 index 416d8b8c57..d92e62ff76 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -1,4 +1,8 @@ + 타임레터를 삭제하시겠습니까? + 예 + 아니오 + 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. 이름으로 검색하기 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..a09458bf70 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -0,0 +1,205 @@ +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 +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) + val state = viewModel.uiState.value as TimeletterUiState.Success + assertFalse(state.isDeleting) + } + + @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) + } + + @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 = + 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, + 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 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( + timeLetters = + listOf( + TimeLetter( + id = 1L, + title = "제목", + sendAt = null, + deliveredAt = null, + status = TimeLetterStatus.SCHEDULED, + blocks = emptyList(), + receiverIds = emptyList(), + ), + ), + totalCount = 1, + ) + } +}