Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand Down Expand Up @@ -156,6 +157,7 @@ class MindRecordLifecycleAndroidTest {
loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository),
diaryRepository = diaryRepository,
dailyQuestionRepository = dailyQuestionRepository,
errorReporter = FakeErrorReporter(),
)

composeRule.setContent {
Expand Down Expand Up @@ -281,7 +283,7 @@ class MindRecordLifecycleAndroidTest {
),
),
)
val viewModel = ReceiverMindRecordViewModel(repository)
val viewModel = ReceiverMindRecordViewModel(repository, FakeErrorReporter())

composeRule.setContent {
AfternoteTheme {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -468,6 +469,7 @@ class TimeLetterMindRecordCompletionAndroidTest {
loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository),
diaryRepository = repository,
dailyQuestionRepository = draftDailyQuestionRepository,
errorReporter = FakeErrorReporter(),
)
var routedArguments: Pair<Long, String>? = null
var writeViewModel by mutableStateOf<DiaryWriteViewModel?>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>(DraftListUiState.Loading)
val uiState: StateFlow<DraftListUiState> = _uiState.asStateFlow()
Expand Down Expand Up @@ -74,7 +78,17 @@ class DraftListViewModel
.map { item -> async { item to deleteOne(item) } }
.awaitAll()
}
val failed = results.filter { (_, result) -> result.isFailure }.map { (item, _) -> item }
// 되돌릴 수 없는 동작이고, 부분 실패는 목록과 서버 상태를 어긋나게 둔다 —
// 화면은 사용자에게 알리지만 콘솔에는 아무 흔적도 남지 않았다 (#964).
// 계측과 «실패 목록» 을 한 순회에 둔다 — 갈라 두면 나중에 한쪽만 고친다.
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 =
Expand Down Expand Up @@ -143,7 +157,11 @@ class DraftListViewModel
private suspend fun collectDrafts(): List<DraftItem>? =
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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +31,7 @@ class ReceiverMindRecordViewModel
@Inject
constructor(
private val repository: MindRecordReceiverRepository,
private val errorReporter: ErrorReporter,
) : ViewModel() {
private val rawRecords = MutableStateFlow<ReceiverMindRecords?>(null)
private val _uiState = MutableStateFlow<ReceiverMindRecordUiState>(ReceiverMindRecordUiState.Loading)
Expand Down Expand Up @@ -89,6 +93,17 @@ class ReceiverMindRecordViewModel
filter = currentFilter,
).withDerived()
}.onFailure { e ->
// 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 대상이다 —
// 재현할 계정도 조건도 우리 손에 없어 실기 QA 로는 잡히지 않는다 (#964).
// 재진입 갱신 실패도 기록한다. 화면은 유지되지만 서버 상태는 실패다.
//
// 다만 전달 조건 미충족은 **정상 상태**다 — 서버도 기기도 멀쩡하고
// 발신자가 조건을 정해야 풀린다. 전달을 기다리는 수신자는 앱을 열
// 때마다 이 경로를 타므로, 기록하면 보관 한도(최근 8건)를 그 잡음이
// 채워 정작 잡아야 할 열람 실패를 밀어낸다 (#964 판단 기준: 유지).
if (e !is DeliveryNotReadyException) {
errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECEIVER_RECORD_LOAD, e)
}
// 재진입 갱신 실패는 보고 있던 화면을 유지한다.
if (showsLoading) {
// 서버 원문을 화면 문구로 쓰지 않는다 — 타입에서 막는다 (#614).
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[normal] 같은 이름·같은 역할의 클래스가 이미 세 벌 있다.

  • feature/home/presentation/.../HomeTabViewModelTest.kt:489 — private class RecordingErrorReporter
  • feature/receiver/presentation/.../ReceiverHomeViewModelTest.kt:201 — 같은 이름
  • feature/receiver/presentation/.../ReceiverMemorialPlaylistViewModelTest.kt:90 — 같은 이름

여기에 FakeErrorReporter 3벌 · NoopErrorReporter 4벌을 더하면 ErrorReporter 테스트 더블이 레포에 열 벌 넘게 흩어진다. 이 파일이 그중 처음으로 파일 단위로 분리된 공개 클래스라 오히려 공용화하기 좋은 자리다.

core:common 에 testFixtures 로 올리면 세 모듈이 같은 한 벌을 쓸 수 있다 — 마침 #1040 이 마음의 기록 fake 를 testFixtures 로 모으는 중이라 방향도 같다. 이 PR 에서 당장 하지 않더라도, 새 벌을 늘리는 대신 그 통합에 얹는 쪽을 권한다.

(writeFailure 를 구현해 인터페이스 정책까지 태운 것은 위 세 벌보다 정확하다 — 공용화한다면 이 구현이 기준이 되는 편이 맞다.)

data class Failure(
val throwable: Throwable,
val attributes: Map<String, String>,
)

val failures = mutableListOf<Failure>()

val stages: List<String>
get() = failures.mapNotNull { it.attributes["mind_record_stage"] }

override fun writeFailure(
throwable: Throwable,
attributes: Map<String, String>,
) {
failures += Failure(throwable, attributes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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개로 늘었다. 같은 두
// 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다.
Expand All @@ -109,6 +131,7 @@ class DraftListDeleteTest {
),
diaryRepository = EmptyDiaryDraftRepository,
dailyQuestionRepository = dailyQuestionRepository,
errorReporter = errorReporter,
)

private fun dailyQuestion(id: Long) =
Expand Down
Loading
Loading