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
@@ -1,8 +1,8 @@
package com.afternote.feature.afternote.data.repositoryimpl.author

import com.afternote.core.network.model.ApiException
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind
import com.afternote.feature.afternote.domain.error.AfternoteFailure
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
Expand All @@ -21,10 +21,10 @@ private data class ApiErrorBodyDto(
* 저장 API 실패 Throwable을 도메인 검증 예외로 치환할 수 있으면 치환한다.
*/
internal fun mapAuthoringFailure(throwable: Throwable): Throwable {
if (throwable is AfternoteAuthoringValidationException) return throwable
if (throwable is AfternoteFailure.AuthoringValidation) return throwable

if (throwable is ApiException && throwable.code == RECEIVERS_REQUIRED_SERVER_CODE) {
return AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
return AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
}

if (throwable is HttpException && throwable.code() == HTTP_BAD_REQUEST) {
Expand All @@ -34,7 +34,7 @@ internal fun mapAuthoringFailure(throwable: Throwable): Throwable {
apiErrorJson.decodeFromString<ApiErrorBodyDto>(body)
}.getOrNull()
if (parsed?.code == RECEIVERS_REQUIRED_SERVER_CODE) {
return AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
return AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.afternote.feature.afternote.data.repositoryimpl.author

import com.afternote.core.network.model.ApiException
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind
import com.afternote.feature.afternote.domain.error.AfternoteFailure
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
Expand All @@ -21,17 +21,17 @@ import retrofit2.Response
class AfternoteAuthoringFailureMapperTest {
@Test
fun `이미 검증 예외면 그대로 반환`() {
val original = AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
val original = AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
assertSame(original, mapAuthoringFailure(original))
}

@Test
fun `ApiException 475면 RECEIVERS_REQUIRED 검증 예외로 치환`() {
val result = mapAuthoringFailure(ApiException(status = 400, code = 475, serverMessage = null, message = "x"))
assertTrue(result is AfternoteAuthoringValidationException)
assertTrue(result is AfternoteFailure.AuthoringValidation)
assertEquals(
AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED,
(result as AfternoteAuthoringValidationException).kind,
(result as AfternoteFailure.AuthoringValidation).kind,
)
}

Expand All @@ -50,7 +50,7 @@ class AfternoteAuthoringFailureMapperTest {
@Test
fun `HttpException 400 + 바디 code 475면 검증 예외로 치환`() {
val result = mapAuthoringFailure(httpException(code = 400, body = """{"code":475}"""))
assertTrue(result is AfternoteAuthoringValidationException)
assertTrue(result is AfternoteFailure.AuthoringValidation)
}

@Test
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.afternote.feature.afternote.domain.error

import com.afternote.feature.afternote.domain.repository.author.MediaKind

/**
* 애프터노트 작성·저장 흐름의 도메인 실패 루트.
* HTTP 상태·Retrofit·에러 바디 형식 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다.
*
* 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다.
* `message` 는 Logcat·Crashlytics 단서용 진단 문구다. 표시 문구는 화면 리소스가 갖는다.
*/
sealed class AfternoteFailure(
message: String?,
cause: Throwable?,
) : Exception(message, cause) {
/** 저장(생성/수정) API 가 거절한 검증 실패. 화면은 [kind] 로 입력 항목별 안내를 고른다. */
class AuthoringValidation(
val kind: AfternoteAuthoringValidationKind,
) : AfternoteFailure("afternote authoring validation: ${kind.name}", null)

/**
* 추모 미디어(영상·영정 사진)를 저장 페이로드에 실을 URL 로 해석하지 못한 실패.
*
* 매체별로 타입을 나누지 않고 [media] 로 구분한다 — 화면은 둘을 같은 문구로 처리하고
* (`AfternoteEditorViewModel.handleSaveFailure` 의 generic 폴백), 갈라 보던 코드가 없었다(#934 실측).
*
* `data class` 를 쓰지 않는다 — 생성된 equals/hashCode 가 선언 프로퍼티만 덮어 [cause] 를 무시한다.
* 원본 예외를 cause 로 나르는 것이 이 타입의 계약이다.
*/
class MediaSave(
val media: MediaKind,
cause: Throwable,
) : AfternoteFailure("memorial media save failed: ${media.name}", cause)
}

/** 저장 API 가 거절한 검증 사유. */
enum class AfternoteAuthoringValidationKind {
/** 서버 코드 475 — 수신자 최소 1명 필요. */
RECEIVERS_REQUIRED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow

/**
* 작성/수정 호출의 서버 검증 실패는
* [com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException]으로
* [com.afternote.feature.afternote.domain.error.AfternoteFailure.AuthoringValidation]으로
* Result.failure에 담겨 전달된다.
*/
interface AfternoteRepository {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,16 @@
package com.afternote.feature.afternote.domain.usecase.editor

import com.afternote.feature.afternote.domain.error.AfternoteFailure
import com.afternote.feature.afternote.domain.repository.author.MediaInput
import com.afternote.feature.afternote.domain.repository.author.MediaKind
import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository
import javax.inject.Inject

class MemorialVideoSaveException(
cause: Throwable,
) : Exception("영상 업로드에 실패했습니다.", cause)

class MemorialPhotoSaveException(
cause: Throwable,
) : Exception("영정 사진 업로드에 실패했습니다.", cause)

/**
* 추모 영상·영정 사진의 *서버 저장 직전 정리* 도메인 로직.
*
* 호출부가 로컬/원격을 확정한 [MediaInput] 을 Repository 가 저장 페이로드에 실을 URL 로 해석해 주고,
* 본 UseCase 는 그 실패를 매체별 도메인 예외로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라
* 본 UseCase 는 그 실패를 [AfternoteFailure.MediaSave] 로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라
* 형식 디테일 문자열을 직접 비교하지 않는다.
*/
class ResolveMemorialMediaForSaveUseCase
Expand All @@ -38,12 +31,12 @@ class ResolveMemorialMediaForSaveUseCase
val resolvedVideoUrl =
memorialMediaUploadRepository
.resolve(video, MediaKind.VIDEO)
.getOrElse { return Result.failure(MemorialVideoSaveException(it)) }
.getOrElse { return Result.failure(AfternoteFailure.MediaSave(MediaKind.VIDEO, it)) }

val resolvedPhotoUrl =
memorialMediaUploadRepository
.resolve(photo, MediaKind.PHOTO)
.getOrElse { return Result.failure(MemorialPhotoSaveException(it)) }
.getOrElse { return Result.failure(AfternoteFailure.MediaSave(MediaKind.PHOTO, it)) }

return Result.success(
ResolvedMemorialMediaForSave(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.feature.afternote.domain.usecase.editor

import com.afternote.feature.afternote.domain.error.AfternoteFailure
import com.afternote.feature.afternote.domain.repository.author.MediaInput
import com.afternote.feature.afternote.domain.repository.author.MediaKind
import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository
Expand All @@ -16,9 +17,9 @@ import org.junit.Test
* 검증 핵심:
* 1. 입력 [MediaInput] 과 [MediaKind] 를 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임)
* 2. 매체별 resolve 결과를 서로 뒤바꾸지 않고 각 필드에 담는지 (null 은 미첨부)
* 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진은 resolve 하지 않은 채**
* 3. 영상 resolve 실패면 [AfternoteFailure.MediaSave] (media=VIDEO) 로 wrap 하고 **사진은 resolve 하지 않은 채**
* short-circuit 하는지 (non-local return)
* 4. 사진 resolve 실패면 [MemorialPhotoSaveException] 으로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지
* 4. 사진 resolve 실패면 media=PHOTO 로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지
*
* 외부 라이브러리(mockk 등) 없이 호출 인자/횟수를 기록하는 직접 작성 fake를 사용한다.
*/
Expand Down Expand Up @@ -91,7 +92,7 @@ class ResolveMemorialMediaForSaveUseCaseTest {
}

@Test
fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진은 resolve 하지 않음`() {
fun `영상 resolve 실패면 MediaSave(VIDEO) 로 wrap 하고 사진은 resolve 하지 않음`() {
val videoError = IllegalStateException("video resolve failed")
val repo = FakeMemorialMediaUploadRepository().apply { videoResult = Result.failure(videoError) }

Expand All @@ -101,14 +102,16 @@ class ResolveMemorialMediaForSaveUseCaseTest {
}

assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is MemorialVideoSaveException)
val videoFailure = result.exceptionOrNull()
assertTrue(videoFailure is AfternoteFailure.MediaSave)
assertEquals(MediaKind.VIDEO, (videoFailure as AfternoteFailure.MediaSave).media)
assertSame(videoError, result.exceptionOrNull()?.cause) // 원본 예외를 cause 로 보존
// short-circuit — 사진 resolve 미호출
assertEquals(0, repo.calls.count { it.second == MediaKind.PHOTO })
}

@Test
fun `사진 resolve 실패면 MemorialPhotoSaveException 으로 wrap`() {
fun `사진 resolve 실패면 MediaSave(PHOTO) 로 wrap`() {
val photoError = IllegalStateException("photo resolve failed")
val repo = FakeMemorialMediaUploadRepository().apply { photoResult = Result.failure(photoError) }

Expand All @@ -118,7 +121,9 @@ class ResolveMemorialMediaForSaveUseCaseTest {
}

assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is MemorialPhotoSaveException)
val photoFailure = result.exceptionOrNull()
assertTrue(photoFailure is AfternoteFailure.MediaSave)
assertEquals(MediaKind.PHOTO, (photoFailure as AfternoteFailure.MediaSave).media)
assertSame(photoError, result.exceptionOrNull()?.cause)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import androidx.navigation.toRoute
import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.domain.repository.UserRepository
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException
import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind
import com.afternote.feature.afternote.domain.error.AfternoteFailure
import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput
import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand
import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository
Expand Down Expand Up @@ -521,12 +521,8 @@ class AfternoteEditorViewModel
private fun handleSaveFailure(e: Throwable) {
val validationError =
when (e) {
is AfternoteAuthoringValidationException -> {
when (e.kind) {
AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED -> {
AfternoteValidationError.RECEIVERS_REQUIRED
}
}
is AfternoteFailure -> {
e.toValidationError()
}

is AfternoteValidationException -> {
Expand Down Expand Up @@ -620,3 +616,25 @@ class AfternoteEditorViewModel

// endregion
}

/**
* 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 실패 유형이 늘면 여기가 컴파일 에러로 잡힌다.
* `else` 로 뭉개 두면 새 유형이 검증 실패로도 장애로도 분류되지 않은 채 조용히 흘러간다.
*
* null 은 "입력 검증 실패가 아님" 을 뜻한다 — 호출부가 그 경우에만 텔레메트리에 기록한다.
*/
private fun AfternoteFailure.toValidationError(): AfternoteValidationError? =
when (this) {
is AfternoteFailure.AuthoringValidation -> {
when (kind) {
AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED -> {
AfternoteValidationError.RECEIVERS_REQUIRED
}
}
}

// 미디어 해석 실패는 사용자가 입력을 고쳐 푸는 검증 실패가 아니라 업로드 장애다 — 기록 대상.
is AfternoteFailure.MediaSave -> {
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import com.afternote.feature.afternote.presentation.author.editor.receiver.model
*
* 현재 이 예외를 던지는 곳은 없다 — 저장 전 로컬 검증은 `AfternoteEditorValidator` 결과를
* `validationError` 상태에 바로 넣고 반환하는 경로를 쓴다. 서버가 거절한 검증(수신자 필수 등)은
* domain 의 `AfternoteAuthoringValidationException` 이 맡는다.
* domain 의 `AfternoteFailure.AuthoringValidation` 이 맡는다.
*/
class AfternoteValidationException(
val validationError: AfternoteValidationError,
Expand Down
Loading