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 @@ -37,7 +37,7 @@ import com.afternote.afternote_fe.test.FailureArtifactRule
import com.afternote.afternote_fe.test.FakeAuthRepository
import com.afternote.afternote_fe.test.FakeErrorReporter
import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.core.domain.error.InvalidLoginCredentialsException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.domain.repository.UserProfileRepository
import com.afternote.core.domain.repository.auth.AuthRepository
import com.afternote.core.model.MindRecordCategory
Expand Down Expand Up @@ -142,7 +142,7 @@ class AppAndReceiverCompletionAndroidTest {
@Test
fun invalidCredentials_correctedPasswordThenRetry_entersHomeWithoutReportingUserError() {
fakeAuth.emailLoginResults.addLast(
Result.failure(InvalidLoginCredentialsException(IllegalStateException("rejected"))),
Result.failure(CoreAuthFailure.InvalidLoginCredentials(IllegalStateException("rejected"))),
)
fakeAuth.emailLoginResults.addLast(
Result.success(Session.DefaultSession("access", "refresh")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.afternote.afternote_fe.test.FailureArtifactRule
import com.afternote.afternote_fe.test.FakeAuthRepository
import com.afternote.afternote_fe.test.FakeUserRepository
import com.afternote.core.domain.error.NetworkUnavailableException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.domain.repository.UserRepository
import com.afternote.core.domain.repository.auth.AuthRepository
import com.afternote.core.model.Session
Expand Down Expand Up @@ -83,7 +83,7 @@ class AppOnboardingCanaryTest {
@Test
fun emailLogin_networkFailureThenRetry_entersHomeOnce() {
fakeAuth.emailLoginResults.addLast(
Result.failure(NetworkUnavailableException(IOException("offline"))),
Result.failure(CoreAuthFailure.NetworkUnavailable(IOException("offline"))),
)
fakeAuth.emailLoginResults.addLast(
Result.success(Session.DefaultSession("access", "refresh")),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
package com.afternote.core.data.repoimpl.account

import com.afternote.core.domain.error.EmailAlreadyRegisteredException
import com.afternote.core.domain.error.EmailVerificationException
import com.afternote.core.domain.error.NetworkUnavailableException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.network.model.ApiException
import java.io.IOException

/** 인증번호 무효(불일치/만료/미존재 통합) — 아이디 찾기도 회원가입 인증과 같은 값을 쓴다. */
private const val CODE_INVALID_VERIFICATION = 1207

/** 이미 가입된 이메일 (`POST auth/email/send` 409, 2026-07-30 실기기 logcat). */
private const val CODE_EMAIL_ALREADY_REGISTERED = 1200

/**
* 계정 API 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 타입만으로
* 분기하게 하는 것이 목적이다(#646).
*
* 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92 — 계약이 아니다).
* 표시 문구는 각 화면이 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부
* `runCatchingCancellable`(#661) 이라 `CancellationException` 이 [Result] 로 도달하지 않는다.
* 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92). 표시 문구는 각 화면이
* 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부 `runCatchingCancellable`(#661)
* 이라 `CancellationException` 이 [Result] 로 도달하지 않는다.
*/
internal fun <T> Result<T>.mapAccountFailure(): Result<T> =
when (val exception = exceptionOrNull()) {
Expand All @@ -27,11 +22,11 @@ internal fun <T> Result<T>.mapAccountFailure(): Result<T> =
is ApiException -> {
when (exception.code) {
CODE_INVALID_VERIFICATION -> {
Result.failure(EmailVerificationException(exception))
Result.failure(CoreAuthFailure.EmailVerification(exception))
}

CODE_EMAIL_ALREADY_REGISTERED -> {
Result.failure(EmailAlreadyRegisteredException(exception))
Result.failure(CoreAuthFailure.EmailAlreadyRegistered(exception))
}

else -> {
Expand All @@ -41,7 +36,7 @@ internal fun <T> Result<T>.mapAccountFailure(): Result<T> =
}

is IOException -> {
Result.failure(NetworkUnavailableException(exception))
Result.failure(CoreAuthFailure.NetworkUnavailable(exception))
}

else -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,13 @@ internal class AccountRepositoryImpl
constructor(
private val accountApiService: AccountApiService,
) : AccountRepository {
// requireStatus() 는 형제 메서드와 맞춘 것이다 — 이것만 빠져 있어 HTTP 200 봉투 안의
// 실패 status 가 성공으로 통과했고, 그러면 실패를 옮길 매퍼가 볼 실패 자체가 생기지 않는다.
override suspend fun sendEmailCode(email: String): Result<Unit> =
runCatchingCancellable {
accountApiService
.sendEmailCode(SendEmailCodeRequestDto(email))
.requireStatus()
}.mapAccountFailure()

/**
* 인증번호 무효(서버 code 1207)를 `EmailVerificationException` 으로 갈라내는 일은
* `mapAccountFailure` 가 형제 메서드와 똑같이 처리한다 — 여기서 따로 catch 하던 것을 걷었다.
* 같은 판정을 두 곳에 두면 갈라지기 때문이다(실제로 blank 처리가 서로 어긋나 있었다).
*
* 1207 을 구분하는 이유: 인증번호 무효는 사용자가 재입력으로 고칠 수 있는 실패라 호출처가
* 인라인 에러로 보여주고, 그 외(네트워크·서버 장애)는 입력과 무관해 스낵바로 보낸다.
* 둘 다 같은 HTTP 400 이라 상태코드로는 안 갈리므로 서버 봉투의 `code` 가 유일한 판별 신호다.
*/
override suspend fun verifyEmail(
email: String,
certificateCode: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import com.afternote.core.data.mapper.auth.AuthMapper
import com.afternote.core.datastore.LocalStoreRegistry
import com.afternote.core.datastore.StoreScope
import com.afternote.core.datastore.TokenDataSource
import com.afternote.core.domain.error.InvalidLoginCredentialsException
import com.afternote.core.domain.error.NetworkUnavailableException
import com.afternote.core.domain.error.SocialLoginRejectedException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.domain.repository.auth.AuthRepository
import com.afternote.core.model.Session
import com.afternote.core.model.TokenBundle
Expand Down Expand Up @@ -70,8 +68,6 @@ internal class AuthRepositoryImpl
)
}

// TODO:레거시 레포에 있던 authApiService 관련이고 리팩토링해야 하는지 검사 필요

override suspend fun defaultLogin(
email: String,
password: String,
Expand Down Expand Up @@ -155,9 +151,8 @@ internal class AuthRepositoryImpl
}
}

// BE `ErrorCode.java` 대조 — `AuthService.login()`·`socialLogin()` 이 실제로 던지는 거절만 담았다.
// 대역 판정(4xx 통과)으로 바꾸지 말 것: [ApiException.code] 는 HTTP 상태가 아니라 본문의
// 비즈니스 코드고, BE 에는 5xx 에 붙는 코드도 있다(1904).
// [ApiException.code] 는 HTTP 상태가 아니라 응답 본문의 비즈니스 코드다 — 대역 판정(4xx 통과)으로
// 바꾸지 말 것. 5xx 에 붙는 코드도 있어 대역으로는 사용자 거절과 장애가 갈리지 않는다.
private const val CODE_USER_NOT_FOUND = 1201
private const val CODE_PASSWORD_MISMATCH = 1202
private const val CODE_SOCIAL_LOGIN_FAILED = 1208
Expand All @@ -177,11 +172,11 @@ private fun <T> Result<T>.mapLoginFailure(): Result<T> =
is ApiException -> {
when (exception.code) {
CODE_USER_NOT_FOUND, CODE_PASSWORD_MISMATCH -> {
Result.failure(InvalidLoginCredentialsException(exception))
Result.failure(CoreAuthFailure.InvalidLoginCredentials(exception))
}

CODE_SOCIAL_LOGIN_FAILED, CODE_UNSUPPORTED_SOCIAL_LOGIN -> {
Result.failure(SocialLoginRejectedException(exception))
Result.failure(CoreAuthFailure.SocialLoginRejected(exception))
}

else -> {
Expand All @@ -191,10 +186,10 @@ private fun <T> Result<T>.mapLoginFailure(): Result<T> =
}

is IOException -> {
Result.failure(NetworkUnavailableException(exception))
Result.failure(CoreAuthFailure.NetworkUnavailable(exception))
}

else -> {
this // 성공·그 외 예외 모두 통과
this
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.afternote.core.data.repoimpl.account

import com.afternote.core.domain.error.EmailVerificationException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.network.dto.EmailFindDto
import com.afternote.core.network.dto.EmailFindRequestDto
import com.afternote.core.network.dto.FindSendCodeRequestDto
Expand All @@ -20,15 +20,15 @@ import org.junit.Test
/**
* [AccountRepositoryImpl.verifyEmail] 의 예외 번역 계약 회귀 가드 (#472).
*
* 계약 — 서버가 인증번호 무효(code 1207)로 거절하면 [EmailVerificationException] 으로
* 계약 — 서버가 인증번호 무효(code 1207)로 거절하면 [CoreAuthFailure.EmailVerification] 으로
* 번역해 Presentation 이 타입으로 분기(인라인 표시)할 수 있게 하고, 그 외 실패
* (네트워크·서버 오류 등)는 원본 예외를 유지해 기존 스낵바 경로로 흐르게 한다.
*/
class AccountRepositoryImplTest {
private fun repository(accountApiService: AccountApiService) = AccountRepositoryImpl(accountApiService)

@Test
fun `verifyEmail - 인증번호 무효(1207)는 EmailVerificationException 으로 번역`() {
fun `verifyEmail - 인증번호 무효(1207)는 EmailVerification 으로 번역`() {
val repository =
repository(
FakeAccountApiService(
Expand All @@ -46,7 +46,7 @@ class AccountRepositoryImplTest {
val result = runBlocking { repository.verifyEmail("user@example.com", "000000") }

val error = result.exceptionOrNull()
assertTrue(error is EmailVerificationException)
assertTrue(error is CoreAuthFailure.EmailVerification)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import androidx.datastore.preferences.core.emptyPreferences
import com.afternote.core.datastore.LocalStoreRegistry
import com.afternote.core.datastore.StoreScope
import com.afternote.core.datastore.TokenDataSource
import com.afternote.core.domain.error.InvalidLoginCredentialsException
import com.afternote.core.domain.error.NetworkUnavailableException
import com.afternote.core.domain.error.SocialLoginRejectedException
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.network.dto.LoginDto
import com.afternote.core.network.dto.LoginRequestDto
import com.afternote.core.network.dto.LogoutRequestDto
Expand All @@ -34,8 +32,8 @@ import kotlin.coroutines.cancellation.CancellationException
/**
* [AuthRepositoryImpl] 의 선제 reissue deadline 관리 계약 회귀 가드 (#408, PR #411 리뷰 반영).
* 로그인 경로의 실패 매핑 계약(#628)도 함께 가드한다 — 전송 계층 IO 실패 →
* [NetworkUnavailableException], 자격 거절(1201·1202) → [InvalidLoginCredentialsException],
* 소셜 거절(1208·1209) → [SocialLoginRejectedException], 그 밖의 서버 실패는 치환하지 않아
* [CoreAuthFailure.NetworkUnavailable], 자격 거절(1201·1202) → [CoreAuthFailure.InvalidLoginCredentials],
* 소셜 거절(1208·1209) → [CoreAuthFailure.SocialLoginRejected], 그 밖의 서버 실패는 치환하지 않아
* 소비처에서 일반 문구로 내려앉는다. 서버 `message` 는 판정에 쓰지 않는다(BE#92).
*
* 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale
Expand Down Expand Up @@ -183,7 +181,7 @@ class AuthRepositoryImplTest {
}

@Test
fun `defaultLogin - 전송 계층 IO 실패는 NetworkUnavailableException 으로 치환 (원인 보존)`() {
fun `defaultLogin - 전송 계층 IO 실패는 NetworkUnavailable 로 치환 (원인 보존)`() {
val repository =
repository(
FakeAuthApiService(
Expand All @@ -194,12 +192,12 @@ class AuthRepositoryImplTest {
val result = runBlocking { repository.defaultLogin("user@example.com", "pw") }

val exception = result.exceptionOrNull()
assertTrue(exception is NetworkUnavailableException)
assertTrue(exception is CoreAuthFailure.NetworkUnavailable)
assertTrue(exception?.cause is UnknownHostException)
}

@Test
fun `defaultLogin - 자격 거절(1202)은 InvalidLoginCredentialsException 으로 치환 (원인 보존)`() {
fun `defaultLogin - 자격 거절(1202)은 InvalidLoginCredentials 로 치환 (원인 보존)`() {
val repository =
repository(
FakeAuthApiService(
Expand All @@ -210,7 +208,7 @@ class AuthRepositoryImplTest {
val result = runBlocking { repository.defaultLogin("user@example.com", "pw") }

val exception = result.exceptionOrNull()
assertTrue(exception is InvalidLoginCredentialsException)
assertTrue(exception is CoreAuthFailure.InvalidLoginCredentials)
assertTrue(exception?.cause is ApiException)
}

Expand Down Expand Up @@ -241,11 +239,11 @@ class AuthRepositoryImplTest {

val result = runBlocking { repository.defaultLogin("user@example.com", "pw") }

assertTrue(result.exceptionOrNull() is InvalidLoginCredentialsException)
assertTrue(result.exceptionOrNull() is CoreAuthFailure.InvalidLoginCredentials)
}

@Test
fun `socialLogin - 소셜 거절(1208)은 SocialLoginRejectedException 으로 치환`() {
fun `socialLogin - 소셜 거절(1208)은 SocialLoginRejected 로 치환`() {
val repository =
repository(
FakeAuthApiService(
Expand All @@ -262,7 +260,7 @@ class AuthRepositoryImplTest {

val result = runBlocking { repository.kakaoLogin("oauth-token") }

assertTrue(result.exceptionOrNull() is SocialLoginRejectedException)
assertTrue(result.exceptionOrNull() is CoreAuthFailure.SocialLoginRejected)
}

@Test
Expand All @@ -276,7 +274,7 @@ class AuthRepositoryImplTest {

val result = runBlocking { repository.kakaoLogin("oauth-token") }

assertTrue(result.exceptionOrNull() is NetworkUnavailableException)
assertTrue(result.exceptionOrNull() is CoreAuthFailure.NetworkUnavailable)
}

@Test
Expand Down Expand Up @@ -310,7 +308,7 @@ class AuthRepositoryImplTest {

val result = runBlocking { repository.googleLogin("id-token") }

assertTrue(result.exceptionOrNull() is NetworkUnavailableException)
assertTrue(result.exceptionOrNull() is CoreAuthFailure.NetworkUnavailable)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.afternote.core.domain.error

/**
* 인증·계정 흐름(로그인·회원가입·이메일 인증·소셜 연동)의 실패 중 **사유가 확인된 것**의 공통 루트.
*
* data 계층이 `ApiException` 을 이 계열로 번역하고, presentation 은 이 루트로 좁힌 뒤 `when` 으로
* 가른다 — 하위 타입이 늘면 컴파일러가 소비처를 잡아준다(`sealed`). 사유를 확인하지 못한 실패는
* 번역하지 않고 원본 그대로 흘려보내므로, 소비처의 `else`(루트가 아닌 Throwable) 분기는 계속 필요하다.
*
* 표시 문구는 호출처 리소스가 갖는다(BE#92 — 서버 `message` 는 사용자 노출용이라는 규정이 없다).
* 서버 코드값은 cause 인 `ApiException` 이 갖는다. `message` 는 그 둘 어느 쪽도 아닌 **리포팅 콘솔용
* 정적 진단 문구**이며, 파라미터를 non-null 로 좁혀 하위 타입이 빠뜨릴 수 없게 했다 — 기본 생성에 맡기면
* `cause.toString()`(`java.net.UnknownHostException: Unable to resolve host ...` 같은 영문 기술 원문)이
* message 가 된다. 이 값은 화면에 싣지 않는다(`e.message ?: 폴백` 금지) — 표시 문구는 사유별 매핑이
* 갖고, 사유를 확인하지 못한 실패는 호출처가 넘긴 작업별 폴백 리소스로 내려앉힌다.
*/
sealed class CoreAuthFailure(
message: String,
cause: Throwable?,
) : Exception(message, cause) {
/**
* 이메일 로그인 자격 거절(서버 code 1201·1202). 계정 미존재와 비밀번호 불일치를 가르지 않는다 —
* 서버 문구도 시안(`3628:23437`)도 어느 쪽이 틀렸는지 노출하지 않는 단일 문구다.
*/
class InvalidLoginCredentials(
cause: Throwable,
) : CoreAuthFailure("login credentials rejected", cause)

/**
* 소셜 로그인 거절(서버 code 1208·1209). [InvalidLoginCredentials] 와 갈라 두는 이유 —
* 입력 필드와 무관한 실패라 화면이 필드 인라인이 아닌 별도 안내로 표시해야 한다.
*/
class SocialLoginRejected(
cause: Throwable,
) : CoreAuthFailure("social login rejected", cause)

/** 이미 가입된 이메일로 인증코드 발송을 요청했다는 사실(서버 code 1200). */
class EmailAlreadyRegistered(
cause: Throwable,
) : CoreAuthFailure("email already registered", cause)

/** 이메일 인증번호가 무효(불일치·만료·미존재 — 서버는 code 1207 하나로 내려준다)라는 사실. */
class EmailVerification(
cause: Throwable,
) : CoreAuthFailure("email verification code invalid", cause)

/**
* 서버 응답 없이 전송 계층에서 끝난 실패(DNS 해석 불가·타임아웃·연결 거부 등)의 도메인 표현.
*
* data 계층이 인증·계정 API 호출의 IO 예외를 이 타입으로 치환한다 — presentation 은
* core:network 에 의존하지 않으므로, 이 타입 하나로 "네트워크 연결 안내" 분기를 할 수 있다.
* 원인 예외는 [cause] 로 보존한다(로그 진단용).
*/
class NetworkUnavailable(
cause: Throwable,
) : CoreAuthFailure("network unavailable", cause)

/**
* 사용자가 소셜 로그인·계정 연동 인증을 직접 취소했다는 사실.
*
* 코루틴의 [kotlinx.coroutines.CancellationException] 과 구분해 구조화된 동시성(Structured
* Concurrency)이 깨지지 않도록 별도 타입으로 둔다 — 취소를 그 타입으로 나르면 상위 스코프까지
* 취소로 전파된다. 소비처는 이 타입을 리포팅·에러 표시에서 제외하고 조용히 흘려보낸다.
*
* 서버가 관여하지 않는 단말 측 이탈이라 [cause] 가 없다.
*/
class UserCancelledAuth : CoreAuthFailure("user cancelled social auth", null)
}

This file was deleted.

Loading
Loading