From 0135efa227e6905d33a08453ae4f2f1a44627c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 12:51:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(receiver):=20=EB=AA=A8=EB=A5=B4?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=ED=83=9C=EA=B0=92=EC=9D=84=20=EC=82=BC?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=A7=90=EA=B3=A0=20=ED=85=94=EB=A0=88?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AC=EC=97=90=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4=20(#1554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fromRaw 는 이름이 «원시 문자열에서» 라고만 말했지, 실제로 하는 일은 와이어 관용이었다 — 대소문자를 무시하고 모르는 값을 UNKNOWN 으로 흡수한다. 그 관용이 domain 모듈에 있어서, 서버가 상태를 하나 추가하면 화면이 NotRequested(아직 신청 안 함)로 그려지는 것을 아무도 몰랐다. 이미 열람을 신청한 사용자에게 신청 버튼이 다시 보이는데도 로그 한 줄 남지 않았다. - 도메인 enum 은 fromWireOrNull 로 바꿔 «아는 값이 아니면 null» 만 말한다. 폴백 여부는 도메인이 아니라 와이어를 받는 쪽 정책이다. - data 매퍼가 그 정책을 진다 — 모르는 값이면 UNKNOWN 을 쓰되 ErrorReporter 에 남긴다. 화면은 그대로다. - null 은 신호가 아니다. 열람 신청 전에는 서버가 이 필드를 비우므로 정상 경로이고 기록하지 않는다. 상태 문자열은 서버 enum 이름이라 텔레메트리에 담되 enum 형태일 때만 담는다 — 응답 본문이 통째로 흘러들어 개인정보가 섞이는 경로를 만들지 않는다. 같은 모듈 매퍼(ReceiverAfternoteListItemDtoToDomain)의 방식을 따랐고, 거기 private 로 중복돼 있던 RecordingErrorReporter 2벌을 공용으로 모았다. --- .../receiver/data/dto/ReceiverAuthDto.kt | 50 ++++++++++- .../ReceiverAuthRepositoryImpl.kt | 8 +- .../data/dto/ReceivedRecordBoxContractTest.kt | 13 +-- .../data/dto/ReceiverAuthDtoMapperTest.kt | 47 ++++++++-- ...eceiverAfternoteListItemDtoToDomainTest.kt | 17 +--- .../ReceiverAfternotePagingSourceTest.kt | 17 +--- .../data/reporting/RecordingErrorReporter.kt | 28 ++++++ ...ReceiverAuthRepositoryImplEmailAuthTest.kt | 87 ++++++++++--------- .../domain/model/ReceiverAuthModels.kt | 12 ++- 9 files changed, 189 insertions(+), 90 deletions(-) create mode 100644 feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/RecordingErrorReporter.kt diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt index 6be6120a09..455b43f276 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt @@ -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 @@ -144,6 +145,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 신호. */ +internal 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, @@ -168,22 +210,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 = +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, ) diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index 857f426411..f48d108353 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -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 @@ -36,6 +37,7 @@ class ReceiverAuthRepositoryImpl @Inject constructor( private val api: ReceiverAuthApiService, + private val errorReporter: ErrorReporter, ) : ReceiverAuthRepository { override suspend fun verifyMasterKey(masterKey: String): Result = runCatchingCancellable { @@ -99,7 +101,7 @@ class ReceiverAuthRepositoryImpl familyRelationCertificateUrl = familyRelationCertificateUrl, ), ).requireData() - .toDomain() + .toDomain(errorReporter) } catch (e: ApiException) { throw e.toReceiverServerFailure() } @@ -107,7 +109,7 @@ class ReceiverAuthRepositoryImpl override suspend fun getDeliveryVerificationStatus(): Result = runCatchingCancellable { - api.getDeliveryVerificationStatus().requireData().toDomain() + api.getDeliveryVerificationStatus().requireData().toDomain(errorReporter) } override suspend fun getSenderMessage(): Result = @@ -121,6 +123,6 @@ class ReceiverAuthRepositoryImpl .getReceivedRecordBoxes() .requireData() .recordBoxes - .map { it.toDomain() } + .map { it.toDomain(errorReporter) } } } diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt index bf18730fff..f84c1185e2 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt @@ -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 @@ -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 @@ -38,7 +41,7 @@ class ReceivedRecordBoxContractTest { val boxes = json.decodeFromString>(payload).requireData().recordBoxes - val box = boxes.single().toDomain() + val box = boxes.single().toDomain(reporter) assertEquals(4L, box.receiverId) assertEquals("59c04a15-1f4a-4b2e-9a0c-2f4e8d7b6c31", box.masterKey) assertEquals("김혜성", box.senderName) @@ -63,7 +66,7 @@ class ReceivedRecordBoxContractTest { .requireData() .recordBoxes .single() - .toDomain() + .toDomain(reporter) assertEquals(DeliveryVerificationStatus.PENDING, box.verificationStatus) assertNull(box.approvedAt) @@ -83,7 +86,7 @@ class ReceivedRecordBoxContractTest { .requireData() .recordBoxes .single() - .toDomain() + .toDomain(reporter) assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus) assertNull(box.requestedAt) @@ -107,7 +110,7 @@ class ReceivedRecordBoxContractTest { .decodeFromString>(payload) .requireData() .recordBoxes - .map { it.toDomain() } + .map { it.toDomain(reporter) } assertEquals(2, boxes.size) val mine = boxes.first { it.masterKey == "bbbbbbbb-0000-4000-8000-000000000002" } @@ -149,7 +152,7 @@ class ReceivedRecordBoxContractTest { .requireData() .recordBoxes .single() - .toDomain() + .toDomain(reporter) assertEquals(DeliveryVerificationStatus.APPROVED, box.verificationStatus) assertEquals("2026-08-25T18:43:47.696636", box.requestedAt) diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt index e05b04b342..288b10c7b1 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt @@ -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 @@ -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 = @@ -81,19 +85,50 @@ 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 `ReceivedRecordBoxDto toDomain - status 가 null 이면 기록하지 않는다`() { + val box = + ReceivedRecordBoxDto( + receiverId = 9L, + accessCode = "code", + senderName = "이영희", + receiverName = "김지은", + recordStatus = "STORED", + viewStatus = "REQUESTABLE", + verificationStatus = null, + ).toDomain(reporter) + + assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus) + assertEquals(emptyList(), reporter.failures) } @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) diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt index a5449e435d..83e20ea747 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt @@ -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 @@ -141,19 +142,3 @@ class ReceiverAfternoteListItemDtoToDomainTest { createdAt = createdAt, ) } - -private class RecordingErrorReporter : ErrorReporter { - data class Failure( - val throwable: Throwable, - val attributes: Map, - ) - - val failures = mutableListOf() - - override fun writeFailure( - throwable: Throwable, - attributes: Map, - ) { - failures += Failure(throwable, attributes) - } -} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt index 1002b81478..42e09ef715 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt @@ -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 @@ -225,22 +226,6 @@ class ReceiverAfternotePagingSourceTest { } } -private class RecordingErrorReporter : ErrorReporter { - data class Failure( - val throwable: Throwable, - val attributes: Map, - ) - - val failures = mutableListOf() - - override fun writeFailure( - throwable: Throwable, - attributes: Map, - ) { - failures += Failure(throwable, attributes) - } -} - private class FakeReceiverAfternoteApiService( private val response: suspend () -> BaseResponse, ) : ReceiverAfternoteApiService { diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/RecordingErrorReporter.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/RecordingErrorReporter.kt new file mode 100644 index 0000000000..ffd4ba37be --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/RecordingErrorReporter.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.receiver.data.reporting + +import com.afternote.core.common.reporting.ErrorReporter + +/** + * 기록된 실패를 들여다보는 테스트용 [ErrorReporter]. + * + * `writeFailure` 를 구현한다 — `recordFailure` 는 인터페이스가 취소 제외·원문 redact 를 + * 처리한 뒤 이 함수를 부르므로, 그 정책까지 함께 태워야 실제 동작과 같아진다. + */ +internal class RecordingErrorReporter : ErrorReporter { + data class Failure( + val throwable: Throwable, + val attributes: Map, + ) + + val failures = mutableListOf() + + val stages: List + get() = failures.mapNotNull { it.attributes["receiver_stage"] } + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + failures += Failure(throwable, attributes) + } +} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt index f6a8396f9c..dac6bf851d 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -13,6 +13,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverMessageDto +import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter import com.afternote.feature.receiver.data.service.ReceiverAuthApiService import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason @@ -42,16 +43,18 @@ class ReceiverAuthRepositoryImplEmailAuthTest { fun `sendEmailAuthCode - 미등록 이메일 1901 ApiException 을 도메인 예외로 변환`() { val repository = ReceiverAuthRepositoryImpl( - FakeReceiverAuthApiService( - onSendEmailAuthCode = { - throw ApiException( - status = 404, - code = 1901, - serverMessage = "등록된 수신자 이메일이 아닙니다.", - fallbackMessage = "등록된 수신자 이메일이 아닙니다.", - ) - }, - ), + errorReporter = RecordingErrorReporter(), + api = + FakeReceiverAuthApiService( + onSendEmailAuthCode = { + throw ApiException( + status = 404, + code = 1901, + serverMessage = "등록된 수신자 이메일이 아닙니다.", + fallbackMessage = "등록된 수신자 이메일이 아닙니다.", + ) + }, + ), ) val result = runBlocking { repository.sendEmailAuthCode("none@example.com") } @@ -67,16 +70,18 @@ class ReceiverAuthRepositoryImplEmailAuthTest { fun `verifyEmailAuthCode - 만료·미존재 1902 ApiException 을 도메인 예외로 변환`() { val repository = ReceiverAuthRepositoryImpl( - FakeReceiverAuthApiService( - onVerifyEmailAuthCode = { - throw ApiException( - status = 400, - code = 1902, - serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", - fallbackMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", - ) - }, - ), + errorReporter = RecordingErrorReporter(), + api = + FakeReceiverAuthApiService( + onVerifyEmailAuthCode = { + throw ApiException( + status = 400, + code = 1902, + serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", + fallbackMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", + ) + }, + ), ) val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") } @@ -93,7 +98,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val original = IOException("timeout") val repository = ReceiverAuthRepositoryImpl( - FakeReceiverAuthApiService(onSendEmailAuthCode = { throw original }), + errorReporter = RecordingErrorReporter(), + api = FakeReceiverAuthApiService(onSendEmailAuthCode = { throw original }), ) val result = runBlocking { repository.sendEmailAuthCode("a@b.com") } @@ -106,7 +112,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { runBlocking { val repository = ReceiverAuthRepositoryImpl( - FakeReceiverAuthApiService(onSendEmailAuthCode = { awaitCancellation() }), + errorReporter = RecordingErrorReporter(), + api = FakeReceiverAuthApiService(onSendEmailAuthCode = { awaitCancellation() }), ) var result: Result? = null @@ -122,23 +129,25 @@ class ReceiverAuthRepositoryImplEmailAuthTest { fun `verifyEmailAuthCode - 성공 응답을 도메인 모델로 매핑`() { val repository = ReceiverAuthRepositoryImpl( - FakeReceiverAuthApiService( - onVerifyEmailAuthCode = { body -> - assertEquals("a@b.com", body.email) - assertEquals("123456", body.authCode) - BaseResponse( - status = 200, - code = 200, - message = "성공", - data = - ReceiverEmailAuthVerifyDto( - receiverId = 3L, - receiverName = "큐에이수신자", - senderName = "큐에이발신자", - ), - ) - }, - ), + errorReporter = RecordingErrorReporter(), + api = + FakeReceiverAuthApiService( + onVerifyEmailAuthCode = { body -> + assertEquals("a@b.com", body.email) + assertEquals("123456", body.authCode) + BaseResponse( + status = 200, + code = 200, + message = "성공", + data = + ReceiverEmailAuthVerifyDto( + receiverId = 3L, + receiverName = "큐에이수신자", + senderName = "큐에이발신자", + ), + ) + }, + ), ) val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") }.getOrThrow() diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt index 5962bdea6b..c3d0ef53f4 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt @@ -47,7 +47,17 @@ enum class DeliveryVerificationStatus { ; companion object { - fun fromRaw(value: String): DeliveryVerificationStatus = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: UNKNOWN + /** + * 서버 상태 문자열을 도메인 값으로 옮긴다. **아는 값이 아니면 `null`** 이다. + * + * 폴백을 여기서 하지 않는 이유는, 모르는 값을 [UNKNOWN] 으로 흡수할지 말지가 도메인이 아니라 + * **와이어를 받는 쪽의 정책**이기 때문이다. 예전 `fromRaw` 는 이 자리에서 조용히 [UNKNOWN] 을 + * 돌려줬고, 그래서 서버가 상태를 하나 추가하면 화면이 「아직 신청 안 함」으로 그려지는 것을 + * 아무도 몰랐다. 지금은 `toDomain(errorReporter)` 가 그 사실을 텔레메트리에 남긴다 (#1554). + * + * 대소문자는 무시한다 — 서버가 `APPROVED`·`approved` 를 섞어 보내도 같은 상태다. + */ + fun fromWireOrNull(value: String): DeliveryVerificationStatus? = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } } } From cf9eaee104c43918fdd57996418c249e1bcee693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 1 Sep 2026 04:17:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(receiver):=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=A8=20wire=20DTO=EB=A5=BC=20owner=20=EC=95=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=A8=EA=B8=B4=EB=8B=A4=20(#1554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../receiver/data/dto/ReceiverAuthDto.kt | 14 +++++---- .../ReceiverAuthRepositoryImpl.kt | 3 +- .../data/dto/ReceivedRecordBoxContractTest.kt | 29 ++++++++++--------- .../data/dto/ReceiverAuthDtoMapperTest.kt | 18 ------------ 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt index 455b43f276..b8d7bc4f06 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDto.kt @@ -97,9 +97,11 @@ data class DeliveryVerificationDto( * 키를 빼거나 이름을 바꿔도 «기록함 0건» 으로 조용히 성공해 계약 누락이 은폐된다. */ @Serializable -data class ReceivedRecordBoxListDto( - @SerialName("recordBoxes") val recordBoxes: List, -) +class ReceivedRecordBoxListDto private constructor( + @SerialName("recordBoxes") private val recordBoxes: List, +) { + internal fun toDomain(errorReporter: ErrorReporter): List = recordBoxes.map { it.toDomain(errorReporter) } +} /** * 받은 기록함 한 칸. @@ -125,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, @@ -150,7 +152,7 @@ private const val VERIFICATION_STATUS_STAGE = "verification_status_mapping" private const val UNKNOWN_STATUS_KEY = "unknown_status" /** 서버가 도메인에 없는 상태 문자열을 보냈음을 알리는 non-fatal 신호. */ -internal class VerificationStatusMappingFailure : RuntimeException() +private class VerificationStatusMappingFailure : RuntimeException() /** * 서버 상태 문자열을 도메인 값으로 옮긴다. **모르는 값이면 화면은 그대로 두고 텔레메트리에 남긴다** (#1554). @@ -220,7 +222,7 @@ fun DeliveryVerificationDto.toDomain(errorReporter: ErrorReporter): DeliveryVeri createdAt = createdAt, ) -fun ReceivedRecordBoxDto.toDomain(errorReporter: ErrorReporter): ReceivedRecordBox = +private fun ReceivedRecordBoxDto.toDomain(errorReporter: ErrorReporter): ReceivedRecordBox = ReceivedRecordBox( receiverId = receiverId, masterKey = accessCode, diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index f48d108353..e526ac6fde 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -122,7 +122,6 @@ class ReceiverAuthRepositoryImpl api .getReceivedRecordBoxes() .requireData() - .recordBoxes - .map { it.toDomain(errorReporter) } + .toDomain(errorReporter) } } diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt index f84c1185e2..448b100b06 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedRecordBoxContractTest.kt @@ -39,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>(payload).requireData().recordBoxes + val boxes = json.decodeFromString>(payload).requireData().toDomain(reporter) - val box = boxes.single().toDomain(reporter) + val box = boxes.single() assertEquals(4L, box.receiverId) assertEquals("59c04a15-1f4a-4b2e-9a0c-2f4e8d7b6c31", box.masterKey) assertEquals("김혜성", box.senderName) @@ -64,9 +64,8 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes - .single() .toDomain(reporter) + .single() assertEquals(DeliveryVerificationStatus.PENDING, box.verificationStatus) assertNull(box.approvedAt) @@ -84,13 +83,13 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes - .single() .toDomain(reporter) + .single() assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus) assertNull(box.requestedAt) assertNull(box.approvedAt) + assertTrue(reporter.failures.isEmpty()) } @Test @@ -109,8 +108,7 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes - .map { it.toDomain(reporter) } + .toDomain(reporter) assertEquals(2, boxes.size) val mine = boxes.first { it.masterKey == "bbbbbbbb-0000-4000-8000-000000000002" } @@ -122,7 +120,7 @@ class ReceivedRecordBoxContractTest { fun `기록함이 하나도 없으면 빈 목록이다`() { val payload = """{"status":200,"code":200,"data":{"recordBoxes":[]}}""" - val boxes = json.decodeFromString>(payload).requireData().recordBoxes + val boxes = json.decodeFromString>(payload).requireData().toDomain(reporter) assertTrue(boxes.isEmpty()) } @@ -150,9 +148,8 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes - .single() .toDomain(reporter) + .single() assertEquals(DeliveryVerificationStatus.APPROVED, box.verificationStatus) assertEquals("2026-08-25T18:43:47.696636", box.requestedAt) @@ -173,7 +170,10 @@ class ReceivedRecordBoxContractTest { val failure = runCatching { - json.decodeFromString>(payload).requireData().recordBoxes + json + .decodeFromString>(payload) + .requireData() + .toDomain(reporter) }.exceptionOrNull() assertNotNull("$missing 누락이 조용히 통과했다", failure) @@ -191,7 +191,10 @@ class ReceivedRecordBoxContractTest { val failure = runCatching { - json.decodeFromString>(payload).requireData().recordBoxes + json + .decodeFromString>(payload) + .requireData() + .toDomain(reporter) }.exceptionOrNull() assertNotNull("$nulled 의 null 이 조용히 통과했다", failure) diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt index 288b10c7b1..0dffa04d34 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAuthDtoMapperTest.kt @@ -108,24 +108,6 @@ class ReceiverAuthDtoMapperTest { assertEquals("EXPIRED", failure.attributes["unknown_status"]) } - /** 열람 신청 전에는 서버가 이 필드를 채우지 않는다 — 정상 경로라 기록하지 않는다. */ - @Test - fun `ReceivedRecordBoxDto toDomain - status 가 null 이면 기록하지 않는다`() { - val box = - ReceivedRecordBoxDto( - receiverId = 9L, - accessCode = "code", - senderName = "이영희", - receiverName = "김지은", - recordStatus = "STORED", - viewStatus = "REQUESTABLE", - verificationStatus = null, - ).toDomain(reporter) - - assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus) - assertEquals(emptyList(), reporter.failures) - } - @Test fun `DeliveryVerificationDto toDomain - 나머지 필드 전달`() { val result = response(status = "REJECTED").toDomain(reporter)