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,5 +1,6 @@
package com.afternote.feature.receiver.data.dto

import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.feature.afternote.data.mapper.formatDateFromServer
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
Expand Down Expand Up @@ -96,9 +97,11 @@ data class DeliveryVerificationDto(
* 키를 빼거나 이름을 바꿔도 «기록함 0건» 으로 조용히 성공해 계약 누락이 은폐된다.
*/
@Serializable
data class ReceivedRecordBoxListDto(
@SerialName("recordBoxes") val recordBoxes: List<ReceivedRecordBoxDto>,
)
class ReceivedRecordBoxListDto private constructor(
@SerialName("recordBoxes") private val recordBoxes: List<ReceivedRecordBoxDto>,
) {
internal fun toDomain(errorReporter: ErrorReporter): List<ReceivedRecordBox> = recordBoxes.map { it.toDomain(errorReporter) }
}

/**
* 받은 기록함 한 칸.
Expand All @@ -124,7 +127,7 @@ data class ReceivedRecordBoxListDto(
* - `approvedAt` — 승인 상태에서만 채워진다.
*/
@Serializable
data class ReceivedRecordBoxDto(
private data class ReceivedRecordBoxDto(
@SerialName("receiverId") val receiverId: Long,
@SerialName("accessCode") val accessCode: String,
@SerialName("senderName") val senderName: String,
Expand All @@ -144,6 +147,47 @@ data class ReceiverMessageDto(
@SerialName("createdAt") val createdAt: String? = null,
)

private const val RECEIVER_STAGE_KEY = "receiver_stage"
private const val VERIFICATION_STATUS_STAGE = "verification_status_mapping"
private const val UNKNOWN_STATUS_KEY = "unknown_status"

/** 서버가 도메인에 없는 상태 문자열을 보냈음을 알리는 non-fatal 신호. */
private class VerificationStatusMappingFailure : RuntimeException()

/**
* 서버 상태 문자열을 도메인 값으로 옮긴다. **모르는 값이면 화면은 그대로 두고 텔레메트리에 남긴다** (#1554).
*
* 화면을 실패로 떨어뜨리지 않는 이유는, 서버가 상태를 하나 추가했을 뿐인데 사용자가 화면을 통째로
* 못 보게 되기 때문이다. 그렇다고 조용히 [DeliveryVerificationStatus.UNKNOWN] 으로 흡수하면
* 「아직 열람 신청 안 함」으로 그려져 이미 신청한 사용자에게 신청 버튼이 다시 보인다 — 그 사실을
* 아무도 모르는 것이 종전 동작이었다.
*
* `null` 은 신호가 아니다. 열람 신청 전에는 서버가 이 필드를 채우지 않으므로 정상 경로다.
*
* 상태 문자열은 서버가 정의한 enum 이름이라 텔레메트리에 담되, **enum 형태일 때만** 담는다. 응답 본문이
* 통째로 흘러들어 개인정보가 섞이는 경로를 만들지 않기 위함이다.
*/
private fun resolveVerificationStatus(
raw: String?,
errorReporter: ErrorReporter,
): DeliveryVerificationStatus {
if (raw == null) return DeliveryVerificationStatus.UNKNOWN

DeliveryVerificationStatus.fromWireOrNull(raw)?.let { return it }

errorReporter.recordFailure(
throwable = VerificationStatusMappingFailure(),
attributes =
buildMap {
put(RECEIVER_STAGE_KEY, VERIFICATION_STATUS_STAGE)
if (raw.matches(ENUM_LIKE)) put(UNKNOWN_STATUS_KEY, raw)
},
)
return DeliveryVerificationStatus.UNKNOWN
}

private val ENUM_LIKE = Regex("^[A-Za-z_]{1,32}$")

fun ReceiverAuthVerifyDto.toDomain(): ReceiverIdentity =
ReceiverIdentity(
receiverId = receiverId,
Expand All @@ -168,22 +212,22 @@ fun ReceiverAuthPresignedUrlDto.toDomain(): ReceiverAuthPresignedUrl =
contentLength = contentLength,
)

fun DeliveryVerificationDto.toDomain(): DeliveryVerification =
fun DeliveryVerificationDto.toDomain(errorReporter: ErrorReporter): DeliveryVerification =
DeliveryVerification(
id = id,
status = DeliveryVerificationStatus.fromRaw(status),
status = resolveVerificationStatus(status, errorReporter),
deathCertificateUrl = deathCertificateUrl,
familyRelationCertificateUrl = familyRelationCertificateUrl,
adminNote = adminNote,
createdAt = createdAt,
)

fun ReceivedRecordBoxDto.toDomain(): ReceivedRecordBox =
private fun ReceivedRecordBoxDto.toDomain(errorReporter: ErrorReporter): ReceivedRecordBox =
ReceivedRecordBox(
receiverId = receiverId,
masterKey = accessCode,
senderName = senderName,
verificationStatus = verificationStatus?.let(DeliveryVerificationStatus::fromRaw) ?: DeliveryVerificationStatus.UNKNOWN,
verificationStatus = resolveVerificationStatus(verificationStatus, errorReporter),
requestedAt = requestedAt,
approvedAt = approvedAt,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.feature.receiver.data.repositoryimpl

import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.network.model.ApiException
import com.afternote.core.network.model.requireData
Expand Down Expand Up @@ -36,6 +37,7 @@ class ReceiverAuthRepositoryImpl
@Inject
constructor(
private val api: ReceiverAuthApiService,
private val errorReporter: ErrorReporter,
) : ReceiverAuthRepository {
override suspend fun verifyMasterKey(masterKey: String): Result<ReceiverIdentity> =
runCatchingCancellable {
Expand Down Expand Up @@ -99,15 +101,15 @@ class ReceiverAuthRepositoryImpl
familyRelationCertificateUrl = familyRelationCertificateUrl,
),
).requireData()
.toDomain()
.toDomain(errorReporter)
} catch (e: ApiException) {
throw e.toReceiverServerFailure()
}
}

override suspend fun getDeliveryVerificationStatus(): Result<DeliveryVerification> =
runCatchingCancellable {
api.getDeliveryVerificationStatus().requireData().toDomain()
api.getDeliveryVerificationStatus().requireData().toDomain(errorReporter)
}

override suspend fun getSenderMessage(): Result<SenderMessageInfo> =
Expand All @@ -120,7 +122,6 @@ class ReceiverAuthRepositoryImpl
api
.getReceivedRecordBoxes()
.requireData()
.recordBoxes
.map { it.toDomain() }
.toDomain(errorReporter)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.afternote.feature.receiver.data.dto

import com.afternote.core.network.model.BaseResponse
import com.afternote.core.network.model.requireData
import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
Expand All @@ -21,6 +22,8 @@ import org.junit.Test
* Json 디코드 → `requireData()` → `toDomain()` 을 통과시킨다. Json 설정은 `NetworkModule.provideJson` 과 동일.
*/
class ReceivedRecordBoxContractTest {
private val reporter = RecordingErrorReporter()

private val json =
Json {
ignoreUnknownKeys = true
Expand All @@ -36,9 +39,9 @@ class ReceivedRecordBoxContractTest {
|"verificationStatus":"APPROVED","requestedAt":"2026-06-21T03:07:26","approvedAt":"2026-07-29T16:00:10"}]}}
""".trimMargin().replace("\n", "")

val boxes = json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().recordBoxes
val boxes = json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().toDomain(reporter)

val box = boxes.single().toDomain()
val box = boxes.single()
assertEquals(4L, box.receiverId)
assertEquals("59c04a15-1f4a-4b2e-9a0c-2f4e8d7b6c31", box.masterKey)
assertEquals("김혜성", box.senderName)
Expand All @@ -61,9 +64,8 @@ class ReceivedRecordBoxContractTest {
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.recordBoxes
.toDomain(reporter)
.single()
.toDomain()

assertEquals(DeliveryVerificationStatus.PENDING, box.verificationStatus)
assertNull(box.approvedAt)
Expand All @@ -81,13 +83,13 @@ class ReceivedRecordBoxContractTest {
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.recordBoxes
.toDomain(reporter)
.single()
.toDomain()

assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus)
assertNull(box.requestedAt)
assertNull(box.approvedAt)
assertTrue(reporter.failures.isEmpty())
}

@Test
Expand All @@ -106,8 +108,7 @@ class ReceivedRecordBoxContractTest {
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.recordBoxes
.map { it.toDomain() }
.toDomain(reporter)

assertEquals(2, boxes.size)
val mine = boxes.first { it.masterKey == "bbbbbbbb-0000-4000-8000-000000000002" }
Expand All @@ -119,7 +120,7 @@ class ReceivedRecordBoxContractTest {
fun `기록함이 하나도 없으면 빈 목록이다`() {
val payload = """{"status":200,"code":200,"data":{"recordBoxes":[]}}"""

val boxes = json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().recordBoxes
val boxes = json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().toDomain(reporter)

assertTrue(boxes.isEmpty())
}
Expand Down Expand Up @@ -147,9 +148,8 @@ class ReceivedRecordBoxContractTest {
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.recordBoxes
.toDomain(reporter)
.single()
.toDomain()

assertEquals(DeliveryVerificationStatus.APPROVED, box.verificationStatus)
assertEquals("2026-08-25T18:43:47.696636", box.requestedAt)
Expand All @@ -170,7 +170,10 @@ class ReceivedRecordBoxContractTest {

val failure =
runCatching {
json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().recordBoxes
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.toDomain(reporter)
}.exceptionOrNull()

assertNotNull("$missing 누락이 조용히 통과했다", failure)
Expand All @@ -188,7 +191,10 @@ class ReceivedRecordBoxContractTest {

val failure =
runCatching {
json.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload).requireData().recordBoxes
json
.decodeFromString<BaseResponse<ReceivedRecordBoxListDto>>(payload)
.requireData()
.toDomain(reporter)
}.exceptionOrNull()

assertNotNull("$nulled 의 null 이 조용히 통과했다", failure)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.feature.receiver.data.dto

import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
Expand All @@ -9,9 +10,12 @@ import org.junit.Test
* `ReceiverAuthDto` 의 toDomain 확장 회귀 가드.
* 단순 전달 매핑(SenderMessageInfo·ReceiverIdentity·ReceiverAuthPresignedUrl)과
* SenderMessageInfo 의 createdAt 표시 형식 변환,
* 핵심 로직인 [DeliveryVerificationStatus.fromRaw] 의 대소문자 무시 + UNKNOWN fallback 을 검증.
* 핵심 로직인 [DeliveryVerificationStatus.fromWireOrNull] 의 대소문자 무시와,
* 모르는 값을 UNKNOWN 으로 두되 텔레메트리에 남기는 정책(#1554)을 검증.
*/
class ReceiverAuthDtoMapperTest {
private val reporter = RecordingErrorReporter()

@Test
fun `ReceiverMessageDto toDomain - SenderMessageInfo 매핑`() {
val result =
Expand Down Expand Up @@ -81,19 +85,32 @@ class ReceiverAuthDtoMapperTest {
}

@Test
fun `DeliveryVerificationDto toDomain - status fromRaw 대소문자 무시`() {
assertEquals(DeliveryVerificationStatus.APPROVED, response(status = "approved").toDomain().status)
assertEquals(DeliveryVerificationStatus.PENDING, response(status = "PENDING").toDomain().status)
fun `DeliveryVerificationDto toDomain - status 대소문자 무시`() {
assertEquals(DeliveryVerificationStatus.APPROVED, response(status = "approved").toDomain(reporter).status)
assertEquals(DeliveryVerificationStatus.PENDING, response(status = "PENDING").toDomain(reporter).status)
}

@Test
fun `DeliveryVerificationDto toDomain - 알 수 없는 status는 UNKNOWN`() {
assertEquals(DeliveryVerificationStatus.UNKNOWN, response(status = "WeIrD").toDomain().status)
assertEquals(DeliveryVerificationStatus.UNKNOWN, response(status = "WeIrD").toDomain(reporter).status)
}

/**
* 서버가 상태를 하나 추가하면 화면은 「아직 신청 안 함」으로 그려진다 — 조용히 두면 아무도 모른다.
* 화면은 그대로 두되 사실은 남긴다는 것이 #1554 의 결정이다.
*/
@Test
fun `DeliveryVerificationDto toDomain - 알 수 없는 status 는 텔레메트리에 남는다`() {
response(status = "EXPIRED").toDomain(reporter)

val failure = reporter.failures.single()
assertEquals("verification_status_mapping", failure.attributes["receiver_stage"])
assertEquals("EXPIRED", failure.attributes["unknown_status"])
}

@Test
fun `DeliveryVerificationDto toDomain - 나머지 필드 전달`() {
val result = response(status = "REJECTED").toDomain()
val result = response(status = "REJECTED").toDomain(reporter)
assertEquals(11L, result.id)
assertEquals(DeliveryVerificationStatus.REJECTED, result.status)
assertEquals("death", result.deathCertificateUrl)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto
import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto
import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
Expand Down Expand Up @@ -141,19 +142,3 @@ class ReceiverAfternoteListItemDtoToDomainTest {
createdAt = createdAt,
)
}

private class RecordingErrorReporter : ErrorReporter {
data class Failure(
val throwable: Throwable,
val attributes: Map<String, String>,
)

val failures = mutableListOf<Failure>()

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 @@ -8,6 +8,7 @@ import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto
import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto
import com.afternote.feature.receiver.data.mapper.ReceiverListDecodingFailure
import com.afternote.feature.receiver.data.mapper.ReceiverListMappingFailure
import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter
import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService
import com.afternote.feature.receiver.domain.error.ReceiverFailure
import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason
Expand Down Expand Up @@ -225,22 +226,6 @@ class ReceiverAfternotePagingSourceTest {
}
}

private class RecordingErrorReporter : ErrorReporter {
data class Failure(
val throwable: Throwable,
val attributes: Map<String, String>,
)

val failures = mutableListOf<Failure>()

override fun writeFailure(
throwable: Throwable,
attributes: Map<String, String>,
) {
failures += Failure(throwable, attributes)
}
}

private class FakeReceiverAfternoteApiService(
private val response: suspend () -> BaseResponse<ReceivedAfternoteListDto>,
) : ReceiverAfternoteApiService {
Expand Down
Loading
Loading