Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8cc6480
feat(mindrecord): 작성 실패를 텔레메트리에 남긴다 (쓰기 1순위 3종)
Sadturtleman Aug 27, 2026
676cba1
chore: QA 메타데이터에 androidTest 결정을 채우고 재검증한다
Sadturtleman Aug 27, 2026
ce8b1ee
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 27, 2026
7a1653d
Merge commit '993e316eed24e3f40701db0236439d81a4724ea4' into HEAD
OnlyReadOneBook Aug 27, 2026
b3e1b84
Merge commit '8e0445d273caea6a259aa0f64cabc994d54c6540' into HEAD
OnlyReadOneBook Aug 27, 2026
d56ce75
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 27, 2026
65afd8b
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 27, 2026
f677e57
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 27, 2026
857b05c
Merge remote-tracking branch 'origin/develop' into fix/964-write-fail…
Sadturtleman Aug 28, 2026
5be7cb9
test(mindrecord): 계측이 실제로 회귀를 잡도록 경계를 옮긴다
Sadturtleman Aug 28, 2026
b5b3aec
chore: 변경한 계측 테스트를 CI Test Plan selector 로 선언한다
Sadturtleman Aug 28, 2026
2b86faf
chore: CI Test Plan 을 full 로 전환한다 — 변경 테스트가 20개 상한을 넘는다
Sadturtleman Aug 28, 2026
ef539ab
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
94c906a
test: move mindrecord tests to JVM
OnlyReadOneBook Aug 28, 2026
ffd1fd4
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
ee7091e
Merge branch 'fix/964-write-failure-telemetry' into test/1090-mindrec…
OnlyReadOneBook Aug 28, 2026
bace75a
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
fa4ef58
Merge branch 'fix/964-write-failure-telemetry' into test/1090-mindrec…
OnlyReadOneBook Aug 28, 2026
4bcf4cc
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
3beada1
Merge branch 'fix/964-write-failure-telemetry' into test/1090-mindrec…
OnlyReadOneBook Aug 28, 2026
593f63f
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
a9ca273
Merge pull request #1353 from Afternote/test/1090-mindrecord-jvm-tests
OnlyReadOneBook Aug 28, 2026
f21f519
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
fb7c650
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
a504e96
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
d263c0b
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 28, 2026
5083588
Merge branch 'develop' into fix/964-write-failure-telemetry
Sadturtleman Aug 29, 2026
afee4d6
Merge branch 'develop' into fix/964-write-failure-telemetry
OnlyReadOneBook Aug 29, 2026
2cc71ff
Merge branch 'develop' into fix/964-write-failure-telemetry
Sadturtleman Aug 29, 2026
2bc0dda
Merge branch 'develop' into fix/964-write-failure-telemetry
Sadturtleman Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ class TimeLetterMindRecordCompletionAndroidTest {
repository = repository,
photoUploadRepository = FakePhotoUploadRepository.strict(),
draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository),
errorReporter = FakeErrorReporter(),
)
}
composeRule.waitUntil(timeoutMillis = TIMEOUT) {
Expand Down Expand Up @@ -541,6 +542,7 @@ class TimeLetterMindRecordCompletionAndroidTest {
userRepository = appTestUserRepository(),
draftLoader =
MindRecordDraftLoader(repository, draftDailyQuestionRepository),
errorReporter = FakeErrorReporter(),
)
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ fun appTestUserRepository(
class FakeErrorReporter : ErrorReporter {
val failures = mutableListOf<Pair<Throwable, Map<String, String>>>()

/** 기록된 마음의 기록 stage 목록. 콘솔 필터 값이라 문자열까지 고정한다 (#964). */
val mindRecordStages: List<String>
get() = failures.mapNotNull { it.second["mind_record_stage"] }

override fun writeFailure(
throwable: Throwable,
attributes: Map<String, String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 로 잡히지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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
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
Expand All @@ -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<DailyQuestionWriteUiState> = _uiState.asStateFlow()
Expand Down Expand Up @@ -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)
Comment thread
OnlyReadOneBook marked this conversation as resolved.
// null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716).
_uiState.update {
it.copy(
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<MindRecordRoute.DiaryWriteRoute>()

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +123,7 @@ class MindRecordFlowAndroidTest {
),
)
repository.onCreate = { createResults.removeFirst() }
val reporter = RecordingErrorReporter()
val viewModel =
DailyQuestionWriteViewModel(
savedStateHandle = SavedStateHandle(emptyMap()),
Expand All @@ -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("<p>오늘은 용기 냈다</p>", 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("<p>오늘은 용기 냈다</p>", 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(
Expand All @@ -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
}
Loading
Loading