From 835c5a20942d23204cd6778e2233c6df2cbc8b90 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 10:08:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(mindrecord):=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EA=B3=84=EC=B8=A1=EC=9D=84=20=EC=84=B8=EC=9A=B0=EA=B3=A0=20?= =?UTF-8?q?=EB=91=90=20=EC=A7=80=EC=A0=90=EC=9D=84=20=EC=8A=B9=EA=B2=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#964=20=EC=9D=BC=EB=B6=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 모듈은 Log.e 도 ErrorReporter 도 0건이었다 — 실패가 UI 상태로만 흡수돼 릴리즈에서 무슨 일이 있었는지 알 방법이 없었다. MindRecordFailureStage(mind_record_stage 키) + recordMindRecordFailure 를 두고, 열린 PR 이 건드리지 않는 두 ViewModel 부터 승격한다. - 임시저장 목록 조회 실패 — 사용자가 «쓰다 만 글» 을 찾으러 오는 화면이라 실패가 작성물 소실처럼 보인다 (#519 가 그 형태의 결함이었다). - 임시저장 삭제 실패 — 되돌릴 수 없고 부분 실패가 목록과 서버 상태를 어긋나게 둔다. - 수신자 열람 실패 — 읽기지만 유가족이 «지금 못 여는» 상황이고, 재현할 계정도 조건도 우리 손에 없어 실기 QA 로 잡히지 않는다. 전부 승격하지 않는다 — Crashlytics 는 non-fatal 을 최근 8건만 보관해, 목록 조회 실패 같은 흔한 잡음이 실제 장애를 밀어낸다. Firebase 의존은 추가하지 않는다. 주입받는 타입은 ErrorReporter 추상뿐이다. Co-Authored-By: Claude Opus 5 (1M context) --- .../MindRecordLifecycleAndroidTest.kt | 4 +- ...meLetterMindRecordCompletionAndroidTest.kt | 2 + .../reporting/MindRecordFailureReporting.kt | 56 +++++++++++++++++ .../viewmodel/DraftListViewModel.kt | 17 ++++- .../viewmodel/ReceiverMindRecordViewModel.kt | 8 +++ .../MindRecordFailureReportingTest.kt | 63 +++++++++++++++++++ .../reporting/RecordingErrorReporter.kt | 28 +++++++++ .../viewmodel/DraftListDeleteTest.kt | 25 +++++++- .../viewmodel/ReceiverRefreshFilterTest.kt | 3 +- 9 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/RecordingErrorReporter.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 1c32100817..489f03dc25 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.user.User import com.afternote.core.ui.theme.AfternoteTheme @@ -156,6 +157,7 @@ class MindRecordLifecycleAndroidTest { loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), diaryRepository = diaryRepository, dailyQuestionRepository = dailyQuestionRepository, + errorReporter = FakeErrorReporter(), ) composeRule.setContent { @@ -281,7 +283,7 @@ class MindRecordLifecycleAndroidTest { ), ), ) - val viewModel = ReceiverMindRecordViewModel(repository) + val viewModel = ReceiverMindRecordViewModel(repository, FakeErrorReporter()) composeRule.setContent { AfternoteTheme { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index b92d97415e..9ef7a7928f 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.afternote_fe.test.testReceiver import com.afternote.core.domain.repository.PhotoUploadRepository @@ -468,6 +469,7 @@ class TimeLetterMindRecordCompletionAndroidTest { loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), diaryRepository = repository, dailyQuestionRepository = draftDailyQuestionRepository, + errorReporter = FakeErrorReporter(), ) var routedArguments: Pair? = null var writeViewModel by mutableStateOf(null) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt new file mode 100644 index 0000000000..55f061595e --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt @@ -0,0 +1,56 @@ +package com.afternote.feature.mindrecord.presentation.reporting + +import com.afternote.core.common.reporting.ErrorReporter + +/** + * 마음의 기록 흐름에서 실패가 발생한 지점 (#964). + * + * 이 모듈은 종전에 `Log.e` 도 `ErrorReporter` 도 0건이었다 — 실패가 UI 상태로만 흡수돼 + * 릴리즈에서 무슨 일이 있었는지 알 방법이 없었다. + * + * [reportingName] 은 리포팅 콘솔의 필터 값이다. 바꾸면 그 시점 이후 기록이 기존 이슈 그룹과 + * 끊겨 추이를 잃으므로, 단계 자체가 사라지지 않는 한 유지한다. + * + * 흐름마다 전용 enum 과 `<흐름>_stage` 키를 따로 둔다 — 키를 합치면 콘솔에서 한 키에 서로 + * 다른 값 목록이 섞인다(애프터노트·수신자·홈이 각각 자기 키를 쓰는 것과 같은 이유다). + */ +enum class MindRecordFailureStage( + val reportingName: String, +) { + /** + * 임시저장 목록 조회. 사용자가 «쓰다 만 글» 을 찾으러 들어오는 화면이라, 실패하면 + * 작성물이 사라진 것처럼 보인다 — #519 가 실제로 그 형태의 결함이었다. + */ + DRAFT_LIST_LOAD("draft_list_load"), + + /** + * 임시저장 삭제. 되돌릴 수 없는 동작이고, 부분 실패(일부만 지워짐)가 목록과 서버 상태를 + * 어긋나게 둔다. + */ + DRAFT_DELETE("draft_delete"), + + /** + * 수신자가 받은 기록 열람. 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 가치가 + * 높다 — 재현할 계정도 조건도 우리 손에 없어 실기 QA 로 잡히지 않는다. + */ + RECEIVER_RECORD_LOAD("receiver_record_load"), +} + +/** + * 마음의 기록 흐름의 handled 실패를 공통 키 규격으로 기록한다. + * + * 사용자 입력 오류·사용자 취소처럼 오류가 아닌 경로는 호출부에서 걸러 넘기지 않는다. + * 코루틴 취소는 [ErrorReporter] 가 창구에서 거르므로 호출부가 다시 막지 않아도 된다 — + * 이 모듈의 저장소는 `runCatching` 으로 취소까지 실패로 바꿔 돌려주는 자리가 있다. + */ +fun ErrorReporter.recordMindRecordFailure( + stage: MindRecordFailureStage, + throwable: Throwable, +) { + recordFailure( + throwable = throwable, + attributes = mapOf(KEY_MIND_RECORD_STAGE to stage.reportingName), + ) +} + +private const val KEY_MIND_RECORD_STAGE = "mind_record_stage" diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 2cc8368739..df4247f0a9 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -2,11 +2,14 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -32,6 +35,7 @@ class DraftListViewModel private val loader: MindRecordDraftLoader, private val diaryRepository: DiaryRepository, private val dailyQuestionRepository: DailyQuestionRepository, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftListUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -75,6 +79,13 @@ class DraftListViewModel .awaitAll() } val failed = results.filter { (_, result) -> result.isFailure }.map { (item, _) -> item } + // 되돌릴 수 없는 동작이고, 부분 실패는 목록과 서버 상태를 어긋나게 둔다 — + // 화면은 사용자에게 알리지만 콘솔에는 아무 흔적도 남지 않았다 (#964). + results.forEach { (_, result) -> + result.exceptionOrNull()?.let { throwable -> + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, throwable) + } + } val refreshed = collectDrafts() _uiState.value = @@ -143,7 +154,11 @@ class DraftListViewModel private suspend fun collectDrafts(): List? = loader .load() - .map { drafts -> + // 사용자가 «쓰다 만 글» 을 찾으러 들어오는 화면이라, 실패하면 작성물이 사라진 + // 것처럼 보인다 — #519 가 실제로 그 형태의 결함이었다 (#964). + .onFailure { throwable -> + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_LIST_LOAD, throwable) + }.map { drafts -> val diaryItems = // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 뺀다. drafts.diaries.mapNotNull { diary -> 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 960957042e..b5a00b41c2 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,12 +2,15 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter 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 import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -28,6 +31,7 @@ class ReceiverMindRecordViewModel @Inject constructor( private val repository: MindRecordReceiverRepository, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val rawRecords = MutableStateFlow(null) private val _uiState = MutableStateFlow(ReceiverMindRecordUiState.Loading) @@ -89,6 +93,10 @@ class ReceiverMindRecordViewModel filter = currentFilter, ).withDerived() }.onFailure { e -> + // 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 대상이다 — + // 재현할 계정도 조건도 우리 손에 없어 실기 QA 로는 잡히지 않는다 (#964). + // 재진입 갱신 실패도 기록한다. 화면은 유지되지만 서버 상태는 실패다. + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECEIVER_RECORD_LOAD, e) // 재진입 갱신 실패는 보고 있던 화면을 유지한다. if (showsLoading) { // 서버 원문을 화면 문구로 쓰지 않는다 — 타입에서 막는다 (#614). diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt new file mode 100644 index 0000000000..72f1ee3018 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.mindrecord.presentation.reporting + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +/** + * 마음의 기록 계측 규격 (#964). + * + * 이 모듈은 종전에 `Log.e` 도 `ErrorReporter` 도 0건이라, 실패가 UI 상태로만 흡수되고 + * 릴리즈에서는 아무 흔적도 남지 않았다. + */ +class MindRecordFailureReportingTest { + private val reporter = RecordingErrorReporter() + + @Test + fun `단계는 전용 키로 기록된다`() { + // 키를 다른 흐름과 합치면 콘솔에서 한 키에 서로 다른 값 목록이 섞인다. + reporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_LIST_LOAD, IOException("offline")) + + val recorded = reporter.failures.single() + assertEquals("draft_list_load", recorded.attributes["mind_record_stage"]) + } + + @Test + fun `예외 원문은 콘솔로 나가지 않는다`() { + // 서버 응답 본문이 message 에 그대로 담겨 오는 경우가 있어 창구가 redact 한다. + reporter.recordMindRecordFailure( + MindRecordFailureStage.RECEIVER_RECORD_LOAD, + IllegalStateException("user@example.com 의 열람 권한이 없습니다"), + ) + + val recorded = reporter.failures.single() + assertTrue( + "예외 원문이 그대로 실렸다: ${recorded.throwable.message}", + recorded.throwable.message?.contains("user@example.com") != true, + ) + // 대신 타입은 남아야 어떤 실패인지 가릴 수 있다. + assertEquals( + "java.lang.IllegalStateException", + recorded.attributes.values.firstOrNull { it.startsWith("java.lang.IllegalStateException") }, + ) + } + + @Test + fun `코루틴 취소는 기록하지 않는다`() { + // 이 모듈의 저장소는 runCatching 으로 취소까지 실패로 바꿔 돌려준다 — 화면 이탈이 + // 잦은 목록에서 그 취소가 전부 기록되면 보관 한도(최근 8건)를 잡음이 채운다. + reporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_LIST_LOAD, CancellationException("화면 이탈")) + + assertTrue("취소가 기록됐다", reporter.failures.isEmpty()) + } + + @Test + fun `단계 이름은 서로 겹치지 않는다`() { + // reportingName 은 콘솔 필터 값이라 겹치면 다른 화면의 실패가 한 그룹으로 묶인다. + val names = MindRecordFailureStage.entries.map { it.reportingName } + + assertEquals(names.size, names.toSet().size) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/RecordingErrorReporter.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/RecordingErrorReporter.kt new file mode 100644 index 0000000000..b358efca50 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/RecordingErrorReporter.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.presentation.reporting + +import com.afternote.core.common.reporting.ErrorReporter + +/** + * 기록된 실패를 들여다보는 테스트용 [ErrorReporter]. + * + * `writeFailure` 를 구현한다 — `recordFailure` 는 인터페이스가 취소 제외·원문 redact 를 + * 처리한 뒤 이 함수를 부르므로, 그 정책까지 함께 태워야 실제 동작과 같아진다. + */ +class RecordingErrorReporter : ErrorReporter { + data class Failure( + val throwable: Throwable, + val attributes: Map, + ) + + val failures = mutableListOf() + + val stages: List + get() = failures.mapNotNull { it.attributes["mind_record_stage"] } + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + failures += Failure(throwable, attributes) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index e047f4990f..e487320945 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -10,6 +10,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -39,6 +40,24 @@ class DraftListDeleteTest { Dispatchers.resetMain() } + @Test + fun `삭제 실패는 텔레메트리에도 남는다`() { + // 화면은 사용자에게 알리지만, 종전에는 콘솔에 아무 흔적도 남지 않아 릴리즈에서 + // «지워지지 않는다» 는 제보를 받아도 재현 지점을 찾을 수 없었다 (#964). + val repository = + FakeDailyQuestionDraftRepository( + drafts = listOf(dailyQuestion(id = 1L)), + deleteResult = Result.failure(IllegalStateException("서버 거절")), + removesOnDelete = false, + ) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) + + viewModel.delete((viewModel.uiState.value as DraftListUiState.Success).items) + + assertEquals(listOf("draft_delete"), reporter.stages) + } + @Test fun `삭제에 실패하고 항목이 남아 있으면 완료가 아니라 실패로 알린다`() { val stubborn = dailyQuestion(id = 1L) @@ -98,7 +117,10 @@ class DraftListDeleteTest { assertTrue(state.items.isEmpty()) } - private fun viewModel(dailyQuestionRepository: DailyQuestionRepository): DraftListViewModel = + private fun viewModel( + dailyQuestionRepository: DailyQuestionRepository, + errorReporter: RecordingErrorReporter = RecordingErrorReporter(), + ): DraftListViewModel = DraftListViewModel( // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. 같은 두 // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. @@ -109,6 +131,7 @@ class DraftListDeleteTest { ), diaryRepository = EmptyDiaryDraftRepository, dailyQuestionRepository = dailyQuestionRepository, + errorReporter = errorReporter, ) private fun dailyQuestion(id: Long) = diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt index 65dd4ab0a5..e884973e71 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt @@ -4,6 +4,7 @@ import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -41,7 +42,7 @@ class ReceiverRefreshFilterTest { @Test fun `재진입 갱신이 정렬과 기간 필터를 지운다면 안 된다`() = runTest(dispatcher) { - val viewModel = ReceiverMindRecordViewModel(repository = FakeReceiverRepository()) + val viewModel = ReceiverMindRecordViewModel(repository = FakeReceiverRepository(), errorReporter = RecordingErrorReporter()) advanceUntilIdle() val applied = From 82c6ea56246ec3899c3b288e11505249deb0d49a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 16:02:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mindrecord):=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=AF=B8=EC=B6=A9=EC=A1=B1=EC=9D=80=20?= =?UTF-8?q?=ED=85=94=EB=A0=88=EB=A9=94=ED=8A=B8=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20(#9?= =?UTF-8?q?64=20=EB=A6=AC=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeliveryNotReadyException 은 장애가 아니라 정상 상태다 — 서버도 기기도 멀쩡하고 발신자가 전달 조건을 정해야 풀린다. 전달을 기다리는 수신자는 앱을 열 때마다 이 경로를 타므로, 기록하면 보관 한도(최근 8건)를 그 잡음이 채워 정작 잡아야 할 열람 실패를 밀어낸다 — 이 stage 를 승격한 이유가 무력해진다. 삭제 계측은 «실패 목록» 과 같은 순회로 합쳤다 — 갈라 두면 나중에 한쪽만 고친다. Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodel/DraftListViewModel.kt | 15 +++-- .../viewmodel/ReceiverMindRecordViewModel.kt | 9 ++- .../viewmodel/ReceiverFailureReportingTest.kt | 67 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverFailureReportingTest.kt diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index df4247f0a9..1c5753168f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -78,14 +78,17 @@ class DraftListViewModel .map { item -> async { item to deleteOne(item) } } .awaitAll() } - val failed = results.filter { (_, result) -> result.isFailure }.map { (item, _) -> item } // 되돌릴 수 없는 동작이고, 부분 실패는 목록과 서버 상태를 어긋나게 둔다 — // 화면은 사용자에게 알리지만 콘솔에는 아무 흔적도 남지 않았다 (#964). - results.forEach { (_, result) -> - result.exceptionOrNull()?.let { throwable -> - errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, throwable) - } - } + // 계측과 «실패 목록» 을 한 순회에 둔다 — 갈라 두면 나중에 한쪽만 고친다. + val failed = + results + .onEach { (_, result) -> + result.exceptionOrNull()?.let { throwable -> + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, throwable) + } + }.filter { (_, result) -> result.isFailure } + .map { (item, _) -> item } val refreshed = collectDrafts() _uiState.value = 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 b5a00b41c2..eadacd8cb6 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 @@ -96,7 +96,14 @@ class ReceiverMindRecordViewModel // 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 대상이다 — // 재현할 계정도 조건도 우리 손에 없어 실기 QA 로는 잡히지 않는다 (#964). // 재진입 갱신 실패도 기록한다. 화면은 유지되지만 서버 상태는 실패다. - errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECEIVER_RECORD_LOAD, e) + // + // 다만 전달 조건 미충족은 **정상 상태**다 — 서버도 기기도 멀쩡하고 + // 발신자가 조건을 정해야 풀린다. 전달을 기다리는 수신자는 앱을 열 + // 때마다 이 경로를 타므로, 기록하면 보관 한도(최근 8건)를 그 잡음이 + // 채워 정작 잡아야 할 열람 실패를 밀어낸다 (#964 판단 기준: 유지). + if (e !is DeliveryNotReadyException) { + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECEIVER_RECORD_LOAD, e) + } // 재진입 갱신 실패는 보고 있던 화면을 유지한다. if (showsLoading) { // 서버 원문을 화면 문구로 쓰지 않는다 — 타입에서 막는다 (#614). diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverFailureReportingTest.kt new file mode 100644 index 0000000000..b6384ae5c1 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverFailureReportingTest.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 수신자 열람 실패 계측이 «정상 상태» 까지 올리지 않는지 (#964 리뷰). + * + * 전달 조건 미충족(`DeliveryNotReadyException`)은 장애가 아니다 — 서버도 기기도 멀쩡하고 + * 발신자가 조건을 정해야 풀린다. 전달을 기다리는 수신자는 앱을 열 때마다 이 경로를 타므로, + * 기록하면 Crashlytics 보관 한도(최근 8건)를 그 잡음이 채워 **정작 잡아야 할 열람 실패를 + * 밀어낸다** — 이 stage 를 승격한 이유 자체가 무력해진다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverFailureReportingTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `전달 조건 미충족은 기록하지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + ReceiverMindRecordViewModel(FailingReceiverRepository(DeliveryNotReadyException(null)), reporter) + advanceUntilIdle() + + assertEquals(emptyList(), reporter.stages) + } + + @Test + fun `그 밖의 열람 실패는 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + ReceiverMindRecordViewModel(FailingReceiverRepository(IOException("offline")), reporter) + advanceUntilIdle() + + assertEquals(listOf("receiver_record_load"), reporter.stages) + } +} + +private class FailingReceiverRepository( + private val failure: Throwable, +) : MindRecordReceiverRepository { + override suspend fun getAll(): Result = Result.failure(failure) +}