Skip to content
Open
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 @@ -4,7 +4,14 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.IOException

/** 서버 공통 응답 봉투. 제네릭 [T] 는 `data` 필드의 페이로드 타입 — `data` 없는 엔드포인트는 `BaseResponse<Unit>`. */
/**
* 서버 공통 응답 봉투. 제네릭 [T] 는 `data` 필드의 페이로드 타입 — `data` 없는 엔드포인트는 `BaseResponse<Unit>`.
*
* 서버 스키마(`ApiResponse*`)에 더 있는 `expiresIn`(액세스 토큰 잔여 수명 초 — BE 2026-06-01 도입,
* 유효 토큰으로 호출한 일부 목록 endpoint 에서 실제로 내려옴) 등 클라 미소비 필드는 선언하지 않는다 —
* null 필드는 서버 직렬화에서 생략되고, `NetworkModule.provideJson` 의 ignoreUnknownKeys 가
* 선언 안 된 키를 무시한다. FE 가 토큰 선제 갱신을 구현하는 시점에 nullable 로 추가.
*/
@Serializable
data class BaseResponse<T>(
@SerialName("status")
Expand Down Expand Up @@ -44,6 +51,10 @@ fun <T : Any> BaseResponse<T>.requireData(): T {
* 을 판단 — `message` 는 클라 fallback 이 섞여 사용자에게 노출하면 안 됨.
* @property message IOException 호환용 디버깅 메시지 (serverMessage 있으면 그것, 없으면 클라 fallback).
* 사용자 직접 노출 X — Logcat·Crashlytics 용.
*
* 헤더의 `IOException(message)` 는 부모 생성자 호출(Java 의 `super(message)`) — 인자는 주 생성자의
* `message` 파라미터. 같은 값을 자기 프로퍼티(`String?` 인 Throwable.message 를 non-null 로 좁힘)와
* 부모 Throwable 내부 필드 양쪽에 설정해, 디버거·Java 경로의 메시지 표시를 보존한다.
*/
class ApiException(
val code: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.afternote.feature.afternote.data.mapper.formatDateFromServer
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl
import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult
import com.afternote.feature.receiver.domain.model.ReceiverIdentity
import com.afternote.feature.receiver.domain.model.SenderMessageInfo
import kotlinx.serialization.SerialName
Expand All @@ -22,6 +23,25 @@ data class ReceiverAuthVerifyResponse(
@SerialName("relation") val relation: String,
)

@Serializable
data class ReceiverAuthCodeEmailSendRequest(
@SerialName("email") val email: String,
)

@Serializable
data class ReceiverEmailAuthVerifyRequest(
@SerialName("email") val email: String,
@SerialName("authCode") val authCode: String,
)

@Serializable
data class ReceiverEmailAuthVerifyResponse(
@SerialName("receiverId") val receiverId: Long,
@SerialName("receiverName") val receiverName: String,
@SerialName("senderName") val senderName: String,
@SerialName("accessCode") val accessCode: String,
)

@Serializable
data class ReceiverAuthPresignedUrlRequest(
@SerialName("extension") val extension: String,
Expand Down Expand Up @@ -66,6 +86,14 @@ fun ReceiverAuthVerifyResponse.toDomain(): ReceiverIdentity =
relation = relation,
)

fun ReceiverEmailAuthVerifyResponse.toDomain(): ReceiverEmailAuthResult =
ReceiverEmailAuthResult(
receiverId = receiverId,
receiverName = receiverName,
senderName = senderName,
accessCode = accessCode,
)

fun ReceiverAuthPresignedUrlResponse.toDomain(): ReceiverAuthPresignedUrl =
ReceiverAuthPresignedUrl(
presignedUrl = presignedUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,33 @@ package com.afternote.feature.afternote.data.repositoryimpl.receiver

import com.afternote.core.network.model.ApiException
import com.afternote.core.network.model.requireData
import com.afternote.core.network.model.requireStatus
import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequest
import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequest
import com.afternote.feature.afternote.data.dto.toDomain
import com.afternote.feature.afternote.data.service.ReceiverAuthApiService
import com.afternote.feature.afternote.domain.error.ReceiverDeliverySubmitException
import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl
import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult
import com.afternote.feature.receiver.domain.model.ReceiverIdentity
import com.afternote.feature.receiver.domain.model.SenderMessageInfo
import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository
import javax.inject.Inject
import javax.inject.Singleton

/**
* `receiver-auth` 계열 endpoint 의 [ReceiverAuthRepository] 구현.
*
* 에러 처리 구조 — 일부 메서드는 try/catch 가 runCatching *안에* 포개져 있다:
* 안쪽 catch 가 [ApiException](인프라 타입)을 도메인 예외로 바꿔 던지면(exception translation),
* 그 새 예외는 자기를 만든 try 로 되돌아가지 않고 바깥 runCatching 이 잡아
* `Result.failure(도메인 예외)` 로 반환된다 — 호출자에게 예외가 throw 되어 나가는 일은 없다.
*/
@Singleton
class ReceiverAuthRepositoryImpl
@Inject
Expand All @@ -27,6 +40,31 @@ class ReceiverAuthRepositoryImpl
api.verify(ReceiverAuthVerifyRequest(authCode)).requireData().toDomain()
}

override suspend fun sendEmailAuthCode(email: String): Result<Unit> =
runCatching {
try {
api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequest(email)).requireStatus()
} catch (e: ApiException) {
throw ReceiverEmailAuthException(serverMessage = e.serverMessage, serverCode = e.code)
}
}

override suspend fun verifyEmailAuthCode(
email: String,
authCode: String,
): Result<ReceiverEmailAuthResult> =
runCatching {
try {
api
.verifyEmailAuthCode(
ReceiverEmailAuthVerifyRequest(email = email, authCode = authCode),
).requireData()
.toDomain()
} catch (e: ApiException) {
throw ReceiverEmailAuthException(serverMessage = e.serverMessage, serverCode = e.code)
}
}

override suspend fun getPresignedUrl(extension: String): Result<ReceiverAuthPresignedUrl> =
runCatching {
api.getPresignedUrl(ReceiverAuthPresignedUrlRequest(extension)).requireData().toDomain()
Expand All @@ -37,28 +75,17 @@ class ReceiverAuthRepositoryImpl
familyRelationCertificateUrl: String,
): Result<DeliveryVerification> =
runCatching {
api
.submitDeliveryVerification(
DeliveryVerificationRequest(
deathCertificateUrl = deathCertificateUrl,
familyRelationCertificateUrl = familyRelationCertificateUrl,
),
).requireData()
.toDomain()
}.recoverCatching { throwable ->
// ApiErrorInterceptor 가 4xx/5xx 응답을 ApiException 으로 변환 (백엔드 message 포함).
// presentation 이 core:network 의 ApiException 을 직접 알면 layer 위반이므로
// 도메인 예외 ReceiverDeliverySubmitException 으로 변환해 노출한다.
//
// ApiException.serverMessage = 서버가 실제 보낸 message (null 가능),
// ApiException.message = 클라 fallback 섞여 있어 사용자 노출 부적합 — serverMessage 만 전달.
throw if (throwable is ApiException) {
ReceiverDeliverySubmitException(
serverMessage = throwable.serverMessage,
httpCode = throwable.code,
)
} else {
throwable
try {
api
.submitDeliveryVerification(
DeliveryVerificationRequest(
deathCertificateUrl = deathCertificateUrl,
familyRelationCertificateUrl = familyRelationCertificateUrl,
),
).requireData()
.toDomain()
} catch (e: ApiException) {
throw ReceiverDeliverySubmitException(serverMessage = e.serverMessage, httpCode = e.code)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package com.afternote.feature.afternote.data.service
import com.afternote.core.network.model.BaseResponse
import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequest
import com.afternote.feature.afternote.data.dto.DeliveryVerificationResponse
import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlResponse
import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequest
import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyResponse
import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequest
import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyResponse
import com.afternote.feature.afternote.data.dto.ReceiverMessageResponse
import retrofit2.http.Body
import retrofit2.http.GET
Expand All @@ -15,16 +18,29 @@ import retrofit2.http.POST
/**
* 수신자 인증 흐름 전용 API.
*
* `verify` 외 모든 endpoint 는 `X-Auth-Code` 헤더가 필요하며
* 인증 코드 취득 전 단계인 `verify`·`email/auth-code`·`email/verify` 를 제외한 모든 endpoint 는
* `X-Auth-Code` 헤더가 필요하며
* [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor] 가
* 저장된 인증 코드를 자동 부착한다.
* 저장된 인증 코드를 자동 부착한다 (저장된 코드가 없으면 헤더 없이 통과).
*/
interface ReceiverAuthApiService {
@POST("receiver-auth/verify")
suspend fun verify(
@Body body: ReceiverAuthVerifyRequest,
): BaseResponse<ReceiverAuthVerifyResponse>

/** 수신자 레코드에 등록된 email 로 6자리 인증번호 발송. 미등록 email 은 404 (code 1901). */
@POST("receiver-auth/email/auth-code")
suspend fun sendEmailAuthCode(
@Body body: ReceiverAuthCodeEmailSendRequest,
): BaseResponse<Unit>

/** 발송된 6자리 인증번호 검증. 만료/불일치는 400 (code 1902). */
@POST("receiver-auth/email/verify")
suspend fun verifyEmailAuthCode(
@Body body: ReceiverEmailAuthVerifyRequest,
): BaseResponse<ReceiverEmailAuthVerifyResponse>

@POST("receiver-auth/presigned-url")
suspend fun getPresignedUrl(
@Body body: ReceiverAuthPresignedUrlRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ class ReceiverAuthDtoMapperTest {
assertEquals("자녀", result.relation)
}

@Test
fun `ReceiverEmailAuthVerifyResponse toDomain - ReceiverEmailAuthResult 매핑`() {
val result =
ReceiverEmailAuthVerifyResponse(
receiverId = 7L,
receiverName = "김수신",
senderName = "홍발신",
accessCode = "b7a3c9d1-1234-5678-9abc-def012345678",
).toDomain()

assertEquals(7L, result.receiverId)
assertEquals("김수신", result.receiverName)
assertEquals("홍발신", result.senderName)
assertEquals("b7a3c9d1-1234-5678-9abc-def012345678", result.accessCode)
}

@Test
fun `ReceiverAuthPresignedUrlResponse toDomain - 매핑`() {
val result =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.afternote.feature.afternote.data.dto

import com.afternote.core.network.model.BaseResponse
import com.afternote.core.network.model.requireData
import com.afternote.core.network.model.requireStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test

/**
* `POST /receiver-auth/email/auth-code`·`POST /receiver-auth/email/verify` 응답 계약 회귀 가드 (#407).
*
* 페이로드는 2026-06-11 라이브 Swagger(`afternote.kro.kr/v3/api-docs`) 스키마 기반 합성 —
* verify 성공 응답은 6자리 인증번호를 메일로 받아야 해서 자동 캡처 불가
* (`ReceiverEmailAuthVerifyResponse` 4필드 구성·타입은 Swagger 와 BE 소스로 확정, 이슈 #407 본문).
* 프로덕션 경로(`ReceiverAuthRepositoryImpl`)와 동일하게 Json 디코드 → `requireData()`/`requireStatus()`
* → `toDomain()` 을 통과시킨다 — Json 설정은 `NetworkModule.provideJson` 과 동일
* (ignoreUnknownKeys + coerceInputValues).
*
* 에러 응답(404 code 1901 / 400 code 1902)은 HTTP 4xx 라 `ApiErrorInterceptor` 가 가로채는 경로 —
* 그 이후의 도메인 예외 변환은 `ReceiverAuthRepositoryImplEmailAuthTest` 가 가드한다.
*/
class ReceiverEmailAuthContractTest {
private val json =
Json {
ignoreUnknownKeys = true
coerceInputValues = true
}

@Test
fun `email-verify 성공 응답 - 디코드부터 accessCode 포함 도메인 모델까지 도달`() {
val payload =
"""{"status":200,"code":200,"message":"성공","data":{"receiverId":3,"receiverName":"큐에이수신자","senderName":"큐에이발신자","accessCode":"123e4567-e89b-12d3-a456-426614174000"}}"""

val result = json.decodeFromString<BaseResponse<ReceiverEmailAuthVerifyResponse>>(payload).requireData().toDomain()

assertEquals(3L, result.receiverId)
assertEquals("큐에이수신자", result.receiverName)
assertEquals("큐에이발신자", result.senderName)
// 백엔드가 receiver.getAuthCode() 를 그대로 반환 — 마스터 키와 동일한 UUID.
assertEquals("123e4567-e89b-12d3-a456-426614174000", result.accessCode)
}

@Test
fun `auth-code 성공 응답(data 없음) - BaseResponse_Unit 디코드 + requireStatus 무사 통과`() {
val payload = """{"status":200,"code":200,"message":"성공","data":null}"""

json.decodeFromString<BaseResponse<Unit>>(payload).requireStatus()
}
}
Loading
Loading