From 6551d72e6b24054f8066dde3ea432eee439944c0 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 12:41:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=97=B4=EB=9E=8C=20=EC=8B=A4=ED=8C=A8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20=EB=8D=B0=EB=93=9C=EC=97=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자가 마음의 기록을 열람하지 못하는 상황에서 화면에 남는 것이 오류 문구 하나뿐이라, 앱바·뒤로가기·재시도가 전혀 없어 시스템 백키 외에는 빠져나갈 방법이 없었다. 서버 응답의 message 도 그대로 화면 문구가 돼, 사용자는 "전달 조건" 이 무엇인지 자기가 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 수신자가 할 수 있는 일이 없다. 앱바를 붙여 이탈 수단을 만들고, 이미 받아 두고도 쓰지 않던 재시도 콜백을 실제 버튼으로 잇는다. 실패 문구는 서버 원문 대신 도메인 문구로 바꾼다 — 전달 조건 미충족(403/2009)은 기다리는 상태임을 알리고, 나머지는 일반 실패 문구로 수렴시킨다. Closes #614 --- .../mindrecord/presentation/build.gradle.kts | 1 + .../navigation/MindRecordNavGraph.kt | 3 +- .../receiver/ReceiverMindRecordScreen.kt | 42 +++++++++++++--- .../viewmodel/ReceiverMindRecordUiState.kt | 10 +++- .../viewmodel/ReceiverMindRecordViewModel.kt | 23 +++++++-- .../src/main/res/values/strings.xml | 5 ++ .../screen/receiver/ReceiverDeadEndTest.kt | 48 +++++++++++++++++++ 7 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 58f43aeb94..e96506b712 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(projects.feature.mindrecord.domain) implementation(projects.core.common) implementation(projects.core.domain) + implementation(projects.core.network) implementation(projects.core.ui) implementation(projects.core.model) implementation(libs.coil.compose) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 5e2813c196..50ee0019a3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -33,7 +33,8 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { MemorySpaceScreen(onBackClick = actions::onMemorySpaceBack) } composable { - ReceiverMindRecordScreen() + // 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614). + ReceiverMindRecordScreen(onBackClick = actions::onMemorySpaceBack) } composable { DailyQuestionWriteScreen( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt index 30e8f11051..6a67316bd5 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,14 +30,19 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.ReceiverDiaryGridCard import com.afternote.feature.mindrecord.presentation.component.ReceiverMindRecordTopBar import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordCard @@ -63,7 +69,17 @@ fun ReceiverMindRecordScreen( // 탭한 기록의 본문을 여는 시트. 목록 응답이 이미 본문을 갖고 있어 추가 조회가 없다 (#618). var openedRecordId by remember { mutableStateOf(null) } - Scaffold(modifier = modifier) { paddingValues -> + // 앱바가 없으면 이 화면은 막다른 곳이 된다 — 실패 시 화면에 남는 것이 오류 문구 하나뿐이라 + // 시스템 백키 외에 빠져나갈 수단이 없었다 (#614). + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = stringResource(R.string.mindrecord_receiver_title), + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { ReceiverMindRecordUiState.Loading -> { @@ -71,7 +87,7 @@ fun ReceiverMindRecordScreen( } is ReceiverMindRecordUiState.Error -> { - ErrorBox(message = state.message, onRetry = viewModel::refresh) + ErrorBox(message = state.message.asString(), onRetry = viewModel::refresh) } is ReceiverMindRecordUiState.Success -> { @@ -219,10 +235,24 @@ private fun ErrorBox( onRetry: () -> Unit, ) { Box(modifier = Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.Center) { - Text(text = message, color = AfternoteDesign.colors.gray9) - // 재시도 트리거는 후속 PR. 현재는 라이프사이클 재진입으로 처리. - @Suppress("UNUSED_EXPRESSION") - onRetry + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = message, + color = AfternoteDesign.colors.gray9, + textAlign = TextAlign.Center, + ) + // 종전에는 콜백을 받아 두고 쓰지 않아, 화면에 재시도 수단이 없었다 (#614). + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_receiver_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt index b1db3312b9..23b4722f8c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.MindRecordSummary /** @@ -16,8 +17,15 @@ sealed interface ReceiverMindRecordUiState { val filter: ReceiverMindRecordFilter = ReceiverMindRecordFilter(), ) : ReceiverMindRecordUiState + /** + * 조회 실패. + * + * [message] 는 **도메인 문구**다. 종전에는 서버 응답의 `message` 를 그대로 실어 + * "아직 전달 조건이 충족되지 않았습니다" 같은 원문이 화면 문구가 됐다 — 사용자는 + * "전달 조건" 이 무엇인지, 자기가 무엇을 해야 하는지 알 수 없었다 (#614). + */ data class Error( - val message: String, + val message: UiText, ) : ReceiverMindRecordUiState } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index 9a9b2ad137..0cc344952d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -2,9 +2,12 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.network.model.ApiException +import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -62,9 +65,7 @@ class ReceiverMindRecordViewModel ).withDerived() }.onFailure { e -> _uiState.value = - ReceiverMindRecordUiState.Error( - message = e.message ?: "마음의 기록을 불러오지 못했습니다.", - ) + ReceiverMindRecordUiState.Error(message = e.toDomainMessage()) } } } @@ -103,3 +104,19 @@ class ReceiverMindRecordViewModel } } } + +/** + * 서버 실패를 **도메인 문구**로 바꾼다 (#614). + * + * 전달 조건 미충족(403 / code 2009)은 수신자가 할 수 있는 일이 없는 상태다 — 발신자가 + * 전달 조건을 설정해야 풀린다. 그래서 원문("아직 전달 조건이 충족되지 않았습니다") 대신 + * 무슨 상황이고 무엇을 기다리는지 알려 준다. + */ +internal fun Throwable.toDomainMessage(): UiText = + when ((this as? ApiException)?.code) { + DELIVERY_CONDITION_NOT_MET -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) + else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) + } + +/** 전달 조건 미충족 — `403 {"code":2009}`. */ +private const val DELIVERY_CONDITION_NOT_MET = 2009 diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index ea7f4f8d8e..4afa1e8ded 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -164,4 +164,9 @@ 임시 저장된 일기를 불러오지 못했습니다. 등록에 실패했습니다. 주간 리포트를 불러오지 못했습니다. + + 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. + 마음의 기록을 불러오지 못했어요. + 다시 시도 + 마음의 기록 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt new file mode 100644 index 0000000000..312a3d40ed --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.mindrecord.presentation.screen.receiver + +import com.afternote.core.network.model.ApiException +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException + +/** + * 수신자 열람 실패 문구 가드 (#614). + * + * 종전에는 서버 응답의 `message` 를 그대로 화면에 실어 «아직 전달 조건이 충족되지 + * 않았습니다» 라는 원문이 그대로 노출됐다. 사용자는 "전달 조건" 이 무엇인지, 자기가 + * 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 + * 수신자가 할 수 있는 일이 없다. + */ +class ReceiverDeadEndTest { + @Test + fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { + val error = ApiException(status = 403, code = 2009, serverMessage = "아직 전달 조건이 충족되지 않았습니다.", message = "아직 전달 조건이 충족되지 않았습니다.") + + assertEquals(UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), error.toDomainMessage()) + } + + @Test + fun `서버 원문을 화면 문구로 쓰지 않는다`() { + // 어떤 코드가 오든 결과는 리소스 문구다 — 원문이 새어 나갈 자리가 없다. + val message = + ApiException( + status = 500, + code = 1004, + serverMessage = "서버 내부 오류가 발생했습니다.", + message = "서버 내부 오류가 발생했습니다.", + ).toDomainMessage() + + assertEquals(UiText.Resource(R.string.mindrecord_receiver_load_failed), message) + } + + @Test + fun `네트워크 예외도 같은 실패 문구로 수렴한다`() { + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IOException("timeout").toDomainMessage(), + ) + } +} From 857dda704639bfa2aa7e3a209c526f66216be16a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 15:49:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=8B=A4=ED=8C=A8=20=EB=A7=A4=ED=95=91=EC=9D=84=20?= =?UTF-8?q?data=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EB=82=B4?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presentation 이 core:network 를 직접 무는 것은 이 레포에서 처음 열리는 경계였다. ApiException.code 를 화면 코드가 까 보면 «서버 에러 코드» 라는 전송 계층 개념이 presentation 까지 올라온다. mapAccountFailure 선례대로 data 가 도메인 예외(DeliveryNotReadyException)로 바꾸고 화면은 타입만 본다. presentation 의 core:network 의존도 함께 사라진다. 테스트는 두 계층으로 나눠, 코드→예외 계약은 data 에서, 예외→문구 계약은 presentation 에서 잡는다. 수신자 기록 화면의 뒤로가기도 onMemorySpaceBack 차용을 그만두고 전용 액션을 둔다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/AppNavigationActions.kt | 4 ++ .../MindRecordReceiverFailureMapper.kt | 28 +++++++++ .../MindRecordReceiverRepositoryImpl.kt | 2 +- .../MindRecordReceiverFailureMapperTest.kt | 58 +++++++++++++++++++ .../domain/error/DeliveryNotReadyException.kt | 11 ++++ .../mindrecord/presentation/build.gradle.kts | 1 - .../navigation/MindRecordNavActions.kt | 9 +++ .../navigation/MindRecordNavGraph.kt | 2 +- .../viewmodel/ReceiverMindRecordViewModel.kt | 17 +++--- .../screen/receiver/ReceiverDeadEndTest.kt | 27 +++++---- 10 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt create mode 100644 feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt create mode 100644 feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 40cb28a7a5..87de0b211a 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -100,6 +100,10 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct navController.popBackStack() } + override fun onReceiverMindRecordBack() { + navController.popBackStack() + } + override fun onWriteDailyQuestion() { navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt new file mode 100644 index 0000000000..e9b356b558 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException + +/** 전달 조건 미충족 — `403 {"code":2009}` (실서버 실측, 2026-08-23). */ +private const val CODE_DELIVERY_CONDITION_NOT_MET = 2009 + +/** + * 수신자 기록 조회 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 + * 타입만으로 분기하게 하는 것이 목적이다 (`mapAccountFailure` 와 같은 자리·같은 이유). + * + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다. 그 필드가 사용자 + * 노출용이라는 규정이 명세에 없다. 표시 문구는 화면이 자기 리소스로 갖는다. + */ +internal fun Result.mapReceiverFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_DELIVERY_CONDITION_NOT_MET -> Result.failure(DeliveryNotReadyException(exception)) + else -> this + } + } + + else -> { + this + } + } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt index 21ca3c9bed..8362a9fab0 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt @@ -25,5 +25,5 @@ class MindRecordReceiverRepositoryImpl diaries = diariesDeferred.await().diaries.map { it.toDomain() }, ) } - } + }.mapReceiverFailure() } diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt new file mode 100644 index 0000000000..9ddd632418 --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 수신자 조회 실패의 계층 경계 가드 (#614). + * + * "서버 에러 코드" 는 전송 계층 개념이라 화면까지 올라가면 안 된다. 여기서 도메인 예외로 + * 바꿔 두면 presentation 은 타입만 보고 분기하고 `core:network` 를 모른다. + */ +class MindRecordReceiverFailureMapperTest { + private fun apiFailure( + status: Int, + code: Int, + ) = Result.failure( + ApiException(status = status, code = code, serverMessage = "서버 원문", message = "서버 원문"), + ) + + @Test + fun `전달 조건 미충족 코드는 도메인 예외가 된다`() { + val mapped = apiFailure(status = 403, code = 2009).mapReceiverFailure() + + assertTrue(mapped.exceptionOrNull() is DeliveryNotReadyException) + } + + @Test + fun `원래 예외를 cause 로 남긴다`() { + // 진단 정보를 버리지 않는다 — 화면에 쓰지 않을 뿐이다. + val original = apiFailure(status = 403, code = 2009) + val mapped = original.mapReceiverFailure() + + assertSame(original.exceptionOrNull(), mapped.exceptionOrNull()?.cause) + } + + @Test + fun `다른 서버 코드는 그대로 흘려보낸다`() { + val original = apiFailure(status = 500, code = 1004) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `서버 응답이 없는 실패는 손대지 않는다`() { + val original = Result.failure(IOException("timeout")) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `성공은 그대로 통과한다`() { + assertSame(Unit, Result.success(Unit).mapReceiverFailure().getOrNull()) + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt new file mode 100644 index 0000000000..aebe73cd68 --- /dev/null +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt @@ -0,0 +1,11 @@ +package com.afternote.feature.mindrecord.domain.error + +/** + * 전달 조건이 아직 충족되지 않아 수신자가 기록을 열람할 수 없는 상태 (#614). + * + * 수신자가 할 수 있는 일이 없다 — 발신자가 전달 조건을 설정해야 풀린다. 화면은 이 타입만 + * 보고 문구를 고르고, 어떤 서버 코드에서 왔는지는 data 계층의 매핑만 안다. + */ +class DeliveryNotReadyException( + cause: Throwable? = null, +) : Exception(cause) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index e96506b712..58f43aeb94 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { implementation(projects.feature.mindrecord.domain) implementation(projects.core.common) implementation(projects.core.domain) - implementation(projects.core.network) implementation(projects.core.ui) implementation(projects.core.model) implementation(libs.coil.compose) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index eb6213e21b..6b7fefc2aa 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt @@ -10,6 +10,15 @@ package com.afternote.feature.mindrecord.presentation.navigation interface MindRecordNavActions { fun onMemorySpaceBack() + /** + * 수신자 기록 화면에서 나간다. + * + * 지금 동작은 추억 공간과 같은 `popBackStack()` 이지만 이름을 빌리지 않는다 — 이 + * 인터페이스는 화면별로 액션을 나눠 두는 곳이고, 빌려 쓰면 «추억 공간만 다르게 + * 처리하자» 는 변경이 이 화면까지 조용히 끌고 간다. + */ + fun onReceiverMindRecordBack() + fun onWriteDailyQuestion() fun onWriteDiary() diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 50ee0019a3..6632640691 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -34,7 +34,7 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { } composable { // 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614). - ReceiverMindRecordScreen(onBackClick = actions::onMemorySpaceBack) + ReceiverMindRecordScreen(onBackClick = actions::onReceiverMindRecordBack) } composable { DailyQuestionWriteScreen( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index 0cc344952d..e9a213f413 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -2,8 +2,8 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.network.model.ApiException import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository @@ -106,17 +106,14 @@ class ReceiverMindRecordViewModel } /** - * 서버 실패를 **도메인 문구**로 바꾼다 (#614). + * 실패를 화면 문구로 바꾼다 (#614). * - * 전달 조건 미충족(403 / code 2009)은 수신자가 할 수 있는 일이 없는 상태다 — 발신자가 - * 전달 조건을 설정해야 풀린다. 그래서 원문("아직 전달 조건이 충족되지 않았습니다") 대신 - * 무슨 상황이고 무엇을 기다리는지 알려 준다. + * 서버 에러 코드는 보지 않는다 — 그 판정은 data 계층(`mapReceiverFailure`)이 하고 여기는 + * 도메인 예외 타입만 본다. 전달 조건 미충족은 수신자가 할 수 있는 일이 없는 상태라 + * (발신자가 조건을 설정해야 풀린다) 원문 대신 무엇을 기다리는지 알려 준다. */ internal fun Throwable.toDomainMessage(): UiText = - when ((this as? ApiException)?.code) { - DELIVERY_CONDITION_NOT_MET -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) + when (this) { + is DeliveryNotReadyException -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) } - -/** 전달 조건 미충족 — `403 {"code":2009}`. */ -private const val DELIVERY_CONDITION_NOT_MET = 2009 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt index 312a3d40ed..c588513835 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -1,7 +1,7 @@ package com.afternote.feature.mindrecord.presentation.screen.receiver -import com.afternote.core.network.model.ApiException import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage import org.junit.Assert.assertEquals @@ -15,27 +15,26 @@ import java.io.IOException * 않았습니다» 라는 원문이 그대로 노출됐다. 사용자는 "전달 조건" 이 무엇인지, 자기가 * 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 * 수신자가 할 수 있는 일이 없다. + * + * 화면은 **도메인 예외 타입만** 본다. 어떤 서버 코드가 그 타입이 되는지는 data 계층의 + * `mapReceiverFailure` 가 알고, 그쪽 계약은 `MindRecordReceiverFailureMapperTest` 가 잡는다. */ class ReceiverDeadEndTest { @Test fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { - val error = ApiException(status = 403, code = 2009, serverMessage = "아직 전달 조건이 충족되지 않았습니다.", message = "아직 전달 조건이 충족되지 않았습니다.") - - assertEquals(UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), error.toDomainMessage()) + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), + DeliveryNotReadyException().toDomainMessage(), + ) } @Test fun `서버 원문을 화면 문구로 쓰지 않는다`() { - // 어떤 코드가 오든 결과는 리소스 문구다 — 원문이 새어 나갈 자리가 없다. - val message = - ApiException( - status = 500, - code = 1004, - serverMessage = "서버 내부 오류가 발생했습니다.", - message = "서버 내부 오류가 발생했습니다.", - ).toDomainMessage() - - assertEquals(UiText.Resource(R.string.mindrecord_receiver_load_failed), message) + // 결과 타입이 UiText.Resource 라 원문이 들어갈 자리 자체가 없다. + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IllegalStateException("서버 내부 오류가 발생했습니다.").toDomainMessage(), + ) } @Test