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 850ca8a595..89577a602f 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -455,6 +455,7 @@ class TimeLetterMindRecordCompletionAndroidTest { repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = FakeErrorReporter(), ) } composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -541,6 +542,7 @@ class TimeLetterMindRecordCompletionAndroidTest { userRepository = appTestUserRepository(), draftLoader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), + errorReporter = FakeErrorReporter(), ) }, ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index da784e04bd..0c66be65c4 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -67,6 +67,10 @@ fun appTestUserRepository( class FakeErrorReporter : ErrorReporter { val failures = mutableListOf>>() + /** 기록된 마음의 기록 stage 목록. 콘솔 필터 값이라 문자열까지 고정한다 (#964). */ + val mindRecordStages: List + get() = failures.mapNotNull { it.second["mind_record_stage"] } + override fun writeFailure( throwable: Throwable, attributes: Map, 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 index 55f061595e..4cda23c977 100644 --- 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 @@ -29,6 +29,24 @@ enum class MindRecordFailureStage( */ DRAFT_DELETE("draft_delete"), + /** + * 일기 저장(정식 등록·임시저장 공통). 사용자가 방금 쓴 글이 서버에 닿지 못한 자리라 + * 재현이 어렵고, 실패하면 작성물이 그대로 사라진다 — 실기 QA 로는 잡히지 않는다. + */ + DIARY_SUBMIT("diary_submit"), + + /** + * 데일리질문 답변 저장. 위와 같은 성질이고, upsert 라 실패가 기존 임시저장 상태와도 + * 얽힌다 (#1018). + */ + DAILY_QUESTION_SUBMIT("daily_question_submit"), + + /** + * 작성 화면의 미디어 업로드. 실패하면 본문에서 그 첨부만 빠진 채 저장이 이어질 수 있어, + * 사용자는 «올린 줄 알았는데 없는» 기록을 남기게 된다 (#716·#731). + */ + MEDIA_UPLOAD("media_upload"), + /** * 수신자가 받은 기록 열람. 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 가치가 * 높다 — 재현할 계정도 조건도 우리 손에 없어 실기 QA 로 잡히지 않는다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index 821469024a..95c255c182 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload @@ -12,6 +13,8 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +34,7 @@ class DailyQuestionWriteViewModel private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, private val draftLoader: MindRecordDraftLoader, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -205,7 +209,9 @@ class DailyQuestionWriteViewModel // 제출 직전 fileKey 로 바뀔 수 있게 기억만 해 둔다 (#549). uploadedImageUrls += uploaded.fileUrl _uiState.update { it.copy(isUploadingImage = false) } - }.onFailure { + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). _uiState.update { it.copy( @@ -301,6 +307,8 @@ class DailyQuestionWriteViewModel // 이미 옮겨진 파일을 다시 옮기려다 실패한다 (#549). uploadedImageUrls.clear() }.onFailure { e -> + // 저장이 upsert 라 실패가 기존 임시저장 상태와도 얽힌다 (#964·#1018). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DAILY_QUESTION_SUBMIT, e) _uiState.update { it.copy( submitState = diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 2dc91250b6..f478a8b761 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.domain.repository.UserRepository @@ -15,6 +16,8 @@ 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.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +46,7 @@ class DiaryWriteViewModel private val photoUploadRepository: PhotoUploadRepository, private val userRepository: UserRepository, private val draftLoader: MindRecordDraftLoader, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val route = savedStateHandle.toRoute() @@ -123,7 +127,9 @@ class DiaryWriteViewModel val withUrl = if (it.imageUrl == null) it.copy(imageUrl = uploaded.fileUrl) else it withUrl.copy(isUploadingImage = false) } - }.onFailure { + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). _uiState.update { it.copy( @@ -177,6 +183,9 @@ class DiaryWriteViewModel // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). if (isDraft) loadDraftCount() }.onFailure { e -> + // 방금 쓴 글이 서버에 닿지 못한 자리다 — 재현이 어려워 실기 QA 로 + // 잡히지 않으므로 텔레메트리에 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DIARY_SUBMIT, e) _uiState.update { it.copy( submitState = diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt similarity index 66% rename from app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt rename to feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt index 13bd9f4bd7..1c196aee33 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt @@ -1,23 +1,21 @@ -package com.afternote.afternote_fe +package com.afternote.feature.mindrecord.presentation -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.lifecycle.SavedStateHandle -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.appTestUserRepository import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.Receiver import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel @@ -28,18 +26,17 @@ import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class MindRecordFlowAndroidTest { - @get:Rule(order = 0) +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class MindRecordFlowTest { + @get:Rule val composeRule = createComposeRule() - @get:Rule(order = 1) - val failureArtifactRule = - FailureArtifactRule { - composeRule.onRoot().captureToImage().asAndroidBitmap() - } - @Test fun diaryFailureThenRetry_preservesInputAndSendsExactPayload() { val repository = FakeDiaryRepository() @@ -52,7 +49,8 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } - val viewModel = diaryViewModel(repository) + val reporter = RecordingErrorReporter() + val viewModel = diaryViewModel(repository, reporter) var successCalls = 0 composeRule.setContent { @@ -89,6 +87,10 @@ class MindRecordFlowAndroidTest { assertEquals(TodayMood.HAPPY, payload.todayMood) assertEquals(listOf(7L), payload.receiverIds) assertFalse(payload.isDraft) + + // 실패는 한 번뿐이었으므로 기록도 한 번이다. 성공한 재시도가 더 남기면 Crashlytics + // 보관 한도(최근 8건)를 잡음으로 채운다 (#964). + assertEquals(listOf("diary_submit"), reporter.stages) } @Test @@ -121,6 +123,7 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } + val reporter = RecordingErrorReporter() val viewModel = DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), @@ -131,34 +134,45 @@ class MindRecordFlowAndroidTest { uploadedKey = "mindrecords/1/question.jpg", ), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = reporter, ) - composeRule.setContent { AfternoteTheme {} } + // 실제 작성 화면을 띄운다 — 빈 테마만 compose 하고 submit() 을 직접 부르면 화면과 + // ViewModel 사이 결선이 검증되지 않아, 계측 호출을 지워도 통과한다 (#964 리뷰). + composeRule.setContent { + AfternoteTheme { + DailyQuestionWriteScreen(viewModel = viewModel, onSubmitSuccess = { }) + } + } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isQuestionLoading } - composeRule.runOnIdle { - viewModel.onAnswerChanged("오늘은 용기 냈다") - viewModel.submit() - } + composeRule.runOnIdle { viewModel.onAnswerChanged("오늘은 용기 냈다") } + composeRule.onNode(hasText("저장") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.submitState is SubmitState.Failed } - assertEquals("오늘은 용기 냈다", viewModel.uiState.value.answer) + // 실제 화면을 태우므로 에디터가 되돌려 주는 HTML 이 그대로 상태에 들어간다 — + // 종전 테스트는 화면을 건너뛰어 원문 문자열이 남았다. + assertEquals("

오늘은 용기 냈다

", viewModel.uiState.value.answer) + assertEquals(listOf("daily_question_submit"), reporter.stages) - composeRule.runOnIdle { - viewModel.consumeSubmitResult() - viewModel.submit() - } + composeRule.runOnIdle { viewModel.consumeSubmitResult() } + composeRule.onNode(hasText("저장") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.submitState is SubmitState.Succeeded } assertEquals(2, repository.createdPayloads.size) assertEquals(repository.createdPayloads.first(), repository.createdPayloads.last()) - assertEquals("오늘은 용기 냈다", repository.createdPayloads.last().content) + assertEquals("

오늘은 용기 냈다

", repository.createdPayloads.last().content) assertFalse(repository.createdPayloads.last().isDraft) + // 성공한 재시도는 기록을 늘리지 않는다. + assertEquals(listOf("daily_question_submit"), reporter.stages) } - private fun diaryViewModel(repository: FakeDiaryRepository): DiaryWriteViewModel = + private fun diaryViewModel( + repository: FakeDiaryRepository, + reporter: RecordingErrorReporter = RecordingErrorReporter(), + ): DiaryWriteViewModel = DiaryWriteViewModel( savedStateHandle = SavedStateHandle( @@ -173,7 +187,23 @@ class MindRecordFlowAndroidTest { uploadedUrl = "https://cdn.test/image.jpg", uploadedKey = "mindrecords/1/image.jpg", ), - userRepository = appTestUserRepository(), + userRepository = mindRecordFlowUserRepository(), draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + errorReporter = reporter, ) } + +private fun mindRecordFlowUserRepository(): FakeUserRepository = + FakeUserRepository.strict().apply { + receiverState.value = listOf(Receiver(7L, "김수신", "가족", "fake-auth-7")) + onReceiverListFlow = null + onGetReceivers = null + onCreateReceiver = null + onGetMyProfile = null + onUpdateMyProfile = null + onDeleteAccount = null + onLogActivity = null + onGetMyPushSettings = null + onUpdateMyPushSettings = null + onGetConnectedAccounts = null + } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt similarity index 96% rename from app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt rename to feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index aae8df3c8b..2290395309 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -1,18 +1,12 @@ -package com.afternote.afternote_fe +package com.afternote.feature.mindrecord.presentation -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -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.testing.FakeUserRepository import com.afternote.core.model.user.User import com.afternote.core.ui.theme.AfternoteTheme @@ -38,6 +32,7 @@ import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepositor import com.afternote.feature.mindrecord.domain.testing.unexpectedCall import com.afternote.feature.mindrecord.presentation.model.DayBackground import com.afternote.feature.mindrecord.presentation.model.DayContent +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMindRecordScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen @@ -59,21 +54,20 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth -@RunWith(AndroidJUnit4::class) -class MindRecordLifecycleAndroidTest { - @get:Rule(order = 0) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class MindRecordLifecycleTest { + @get:Rule val composeRule = createComposeRule() - @get:Rule(order = 1) - val failureArtifactRule = - FailureArtifactRule { - composeRule.onRoot().captureToImage().asAndroidBitmap() - } - @Test fun diaryList_excludesDraftChangesMonthAndReloadsAfterDelete() { val currentMonth = YearMonth.now() @@ -162,7 +156,7 @@ class MindRecordLifecycleAndroidTest { loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), diaryRepository = diaryRepository, dailyQuestionRepository = dailyQuestionRepository, - errorReporter = FakeErrorReporter(), + errorReporter = RecordingErrorReporter(), ) composeRule.setContent { @@ -302,7 +296,7 @@ class MindRecordLifecycleAndroidTest { ), ), ) - val viewModel = ReceiverMindRecordViewModel(repository, FakeErrorReporter()) + val viewModel = ReceiverMindRecordViewModel(repository, RecordingErrorReporter()) composeRule.setContent { AfternoteTheme { @@ -338,6 +332,10 @@ class MindRecordLifecycleAndroidTest { .onNode(hasText("2026-08-10 - 2026-08-31") and hasClickAction()) .performClick() composeRule.onNodeWithText("초기화").performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + (viewModel.uiState.value as? ReceiverMindRecordUiState.Success)?.filter?.isApplied == false + } + composeRule.waitForIdle() composeRule.onNodeWithText("2026-08-10 - 2026-08-31").assertDoesNotExist() composeRule.onNodeWithText("범위 밖 일기").assertIsDisplayed() composeRule.onNodeWithText("데일리 질문").performClick() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt index f95aa683f5..b128c9def9 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt @@ -11,6 +11,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 @@ -161,6 +162,7 @@ class DailyQuestionEditTest { diaryRepository = EditTestEmptyDiaryRepository, dailyQuestionRepository = EditTestEmptyDailyQuestionRepository, ), + RecordingErrorReporter(), ) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index 974649175a..908d20ee87 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -11,6 +11,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.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -118,6 +119,7 @@ class DailyQuestionResumeDraftTest { diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) viewModel.onAnswerChanged("

") repository.releaseDraft() @@ -158,6 +160,7 @@ class DailyQuestionResumeDraftTest { diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) // 에디터가 컴포지션 직후 현재 HTML 을 되돌려 주는 것을 재현한다. viewModel.onAnswerChanged(currentAnswerFromEditor) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index 0380146b47..017c69f977 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -7,6 +7,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -60,6 +61,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") @@ -85,6 +87,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) assertEquals("이어쓸 본문", viewModel.uiState.value.answer) @@ -115,6 +118,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/1/just-picked.jpg", ), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") @@ -143,6 +147,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/staging/13/picked.jpg", ), noopDraftLoader(), + RecordingErrorReporter(), ) val url = runBlocking { viewModel.uploadMedia("content://picked") } @@ -168,6 +173,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) assertEquals(html, viewModel.uiState.value.answer) @@ -186,6 +192,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("답변") @@ -236,6 +243,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/staging/13/a.png", ), noopDraftLoader(), + RecordingErrorReporter(), ) val previewUrl = runBlocking { viewModel.uploadMedia("content://picked") } @@ -264,6 +272,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("

수정

") diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt index e06a8ec8cc..83a76f4d82 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayMood 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.flow.flowOf import kotlinx.coroutines.runBlocking @@ -80,6 +81,7 @@ class DiaryWriteImageKeyTest { ), userRepository = noReceiverUserRepository(), draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) private companion object { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt index 57a8edd6d0..2dc89e1be7 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt @@ -12,6 +12,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.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -121,6 +122,7 @@ class MindRecordFailureRecoveryTest { diaryRepository = EmptyDiaryRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt index c61e372626..cbb4224f7a 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload 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.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -98,6 +99,7 @@ class ReceiverLoadFailureTest { if (shouldFail) throw IOException("offline") else emptyList() }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() assertNotNull(viewModel.uiState.value.receiverLoadError) @@ -137,6 +139,7 @@ class ReceiverLoadFailureTest { } }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() assertNotNull(viewModel.uiState.value.receiverLoadError) @@ -161,6 +164,7 @@ class ReceiverLoadFailureTest { photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = userRepository { failure?.let { throw it } ?: emptyList() }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt new file mode 100644 index 0000000000..148c9b8876 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt @@ -0,0 +1,176 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +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.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy + +/** + * 작성 실패가 텔레메트리에 남는지 (#964). + * + * 이 모듈은 종전에 `Log.e` 도 `ErrorReporter` 도 0건이라, 사용자가 방금 쓴 글이 서버에 닿지 + * 못해도 릴리즈에서 알 방법이 없었다. 재현이 어려워 실기 QA 로는 잡히지 않는 자리다. + * + * **stage 값까지 고정한다.** 콘솔 필터가 그 문자열이라, 바뀌면 그 시점 이후 기록이 기존 이슈 + * 그룹과 끊겨 추이를 잃는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WriteFailureReportingTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `일기 저장 실패가 diary_submit 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDiaryRepository().apply { + onCreate = { Result.failure(IOException("offline")) } + } + val viewModel = diaryViewModel(repository, reporter) + advanceUntilIdle() + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.SOSO) + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertEquals(listOf("diary_submit"), reporter.stages) + } + + @Test + fun `일기 저장 성공은 기록하지 않는다`() = + runTest(dispatcher) { + // 성공까지 남기면 Crashlytics 보관 한도(최근 8건)를 잡음으로 채워 실제 장애를 민다. + val reporter = RecordingErrorReporter() + val viewModel = diaryViewModel(FakeDiaryRepository(), reporter) + advanceUntilIdle() + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.SOSO) + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertEquals(emptyList(), reporter.stages) + } + + @Test + fun `데일리질문 저장 실패가 daily_question_submit 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDailyQuestionRepository().apply { + onCreate = { Result.failure(IOException("offline")) } + } + val viewModel = + DailyQuestionWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + viewModel.onAnswerChanged("

답변

") + viewModel.submit() + advanceUntilIdle() + + assertEquals(listOf("daily_question_submit"), reporter.stages) + } + + @Test + fun `미디어 업로드 실패가 media_upload 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = diaryViewModel(FakeDiaryRepository(), reporter) + advanceUntilIdle() + + viewModel.uploadMedia("content://media/1") + advanceUntilIdle() + + assertEquals(listOf("media_upload"), reporter.stages) + } + + @Test + fun `데일리질문 미디어 업로드 실패도 media_upload 으로 기록된다`() = + runTest(dispatcher) { + // 같은 stage 라도 호출부가 둘이다 — 일기 쪽만 보면 데일리질문 계측 호출을 지워도 + // 통과한다 (#964 리뷰). + val reporter = RecordingErrorReporter() + val repository = FakeDailyQuestionRepository() + val viewModel = + DailyQuestionWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + viewModel.uploadMedia("content://media/1") + advanceUntilIdle() + + assertEquals(listOf("media_upload"), reporter.stages) + // 기록만 남기고 화면 안내를 빠뜨리면 사용자는 첨부가 붙은 줄 안다 (#716). + assertNotNull(viewModel.uiState.value.imageUploadError) + } + + private fun diaryViewModel( + repository: FakeDiaryRepository, + reporter: RecordingErrorReporter, + ): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + userRepository = emptyReceiverRepository(), + draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + errorReporter = reporter, + ) + + private fun failingUpload(): PhotoUploadRepository = PhotoUploadRepository { _, _ -> Result.failure(IOException("upload offline")) } + + /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ + private fun emptyReceiverRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceivers" -> emptyList() + "getReceiverListFlow" -> flowOf(emptyList()) + else -> error("Unexpected call: ${method.name}") + } + } as UserRepository +}