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..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 @@ -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 @@ -96,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) } +} /** * 받은 기록함 한 칸. @@ -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, @@ -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, @@ -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, ) 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..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 @@ -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 = @@ -120,7 +122,6 @@ class ReceiverAuthRepositoryImpl api .getReceivedRecordBoxes() .requireData() - .recordBoxes - .map { it.toDomain() } + .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..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 @@ -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 @@ -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>(payload).requireData().recordBoxes + val boxes = json.decodeFromString>(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) @@ -61,9 +64,8 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes + .toDomain(reporter) .single() - .toDomain() assertEquals(DeliveryVerificationStatus.PENDING, box.verificationStatus) assertNull(box.approvedAt) @@ -81,13 +83,13 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes + .toDomain(reporter) .single() - .toDomain() assertEquals(DeliveryVerificationStatus.UNKNOWN, box.verificationStatus) assertNull(box.requestedAt) assertNull(box.approvedAt) + assertTrue(reporter.failures.isEmpty()) } @Test @@ -106,8 +108,7 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes - .map { it.toDomain() } + .toDomain(reporter) assertEquals(2, boxes.size) val mine = boxes.first { it.masterKey == "bbbbbbbb-0000-4000-8000-000000000002" } @@ -119,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()) } @@ -147,9 +148,8 @@ class ReceivedRecordBoxContractTest { json .decodeFromString>(payload) .requireData() - .recordBoxes + .toDomain(reporter) .single() - .toDomain() assertEquals(DeliveryVerificationStatus.APPROVED, box.verificationStatus) assertEquals("2026-08-25T18:43:47.696636", box.requestedAt) @@ -170,7 +170,10 @@ class ReceivedRecordBoxContractTest { val failure = runCatching { - json.decodeFromString>(payload).requireData().recordBoxes + json + .decodeFromString>(payload) + .requireData() + .toDomain(reporter) }.exceptionOrNull() assertNotNull("$missing 누락이 조용히 통과했다", failure) @@ -188,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 e05b04b342..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 @@ -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,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) 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) } } }