From f2b7aa3540d6e63a2c0b29246b4ea3cdf327d3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 3 Sep 2026 18:39:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor(receiver):=20=EC=88=98=EC=8B=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20mapper=C2=B7telemetry=20=EC=84=A0=EC=96=B8?= =?UTF-8?q?=204=EA=B1=B4=EC=9D=84=20=ED=8C=8C=EC=9D=BC=20=EC=95=88?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=91=94=EB=8B=A4=20(#1832)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로덕션 소비처가 선언 파일뿐인데 테스트가 이름으로 직접 참조해 넓게 남아 있던 네 선언을 `private` 로 줄인다. - `ReceivedAfternoteDto.toDomainOrNull` - `List.toReceiverDomainList` - `ReceiverListDecodingFailure` · `ReceiverListMappingFailure` 테스트는 목록을 받는 공개 owner(`ReceivedAfternoteListDto.toReceiverDomainList` · `toDomainResult`)로만 넣고 같은 계약을 확인한다. telemetry 단언은 private 클래스 이름 대신 기록된 «분류 계약» 으로 옮겼다 — `receiver_stage` 별 제외 건수와, 두 단계가 서로 다른 수신 목록 전용 `error_type` 으로 갈린다는 사실을 본다. ErrorReporter 가 예외 원문을 redact 하므로 남는 타입 정보는 그 속성뿐이다. `ProductionVisibilityKonsistTest` 의 `ISSUE_1676_RECEIVER` baseline 은 `checkExact` 라 해소된 항목이 남으면 stale 로 실패한다. 그래서 위 3줄만 뺐다. screenshot Content 6건은 MVI 전환(#1803)이 시그니처를 다시 정하는 몫이라 #1819 로 분리돼 있고, baseline 에 그대로 둔다. `feature/afternote` 의 `LeaveMessageBlockContractTest` 는 같은 계약을 목록 DTO 공개 overload 로 검사하도록 `.afternotes` 한 단계만 걷어냈다. Refs #1676 --- .../mapper/LeaveMessageBlockContractTest.kt | 1 - .../ReceiverAfternoteListItemDtoToDomain.kt | 12 ++- ...eceiverAfternoteListItemDtoToDomainTest.kt | 78 ++++++++++--------- .../ReceiverAfternotePagingSourceTest.kt | 21 +++-- .../reporting/ReceiverListFailureContract.kt | 46 +++++++++++ .../ProductionVisibilityKonsistTest.kt | 3 - 6 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt index f583f49554..e8ad8b7791 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockContractTest.kt @@ -99,7 +99,6 @@ class LeaveMessageBlockContractTest { val items = json .decodeFromString(response) - .afternotes .toReceiverDomainList(NoopErrorReporter) assertEquals(listOf(1L, 2L), items.map { it.id }) diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt index 037aaa29f7..cc8a95d8a7 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt @@ -13,8 +13,12 @@ private const val RECEIVER_LIST_DECODING_STAGE = "receiver_list_decoding" private const val RECEIVER_LIST_MAPPING_STAGE = "receiver_list_mapping" private const val REJECTED_ITEM_COUNT_KEY = "rejected_item_count" +// 아래 두 신호는 이 파일 밖에서 이름으로 참조되지 않는다 (#1832). 텔레메트리에 실리는 건 클래스 자체가 아니라 +// ErrorReporter 가 원문을 redact 하며 붙이는 `error_type` 속성이고, 소비처와 테스트는 그 값과 +// `receiver_stage` 로 단계를 가른다. + /** category 키가 누락됐거나 문자열이 아니어서 목록 디코딩 중 제외된 원소가 있음을 알리는 신호. */ -internal class ReceiverListDecodingFailure : RuntimeException() +private class ReceiverListDecodingFailure : RuntimeException() /** * 목록 응답에 도메인으로 변환할 수 없는 category 가 포함됐음을 알리는 non-fatal 신호. @@ -22,7 +26,7 @@ internal class ReceiverListDecodingFailure : RuntimeException() * 서버 원문을 예외에 담지 않는다. 응답 내 식별자·제목·category 가 텔레메트리로 * 흘러갈 수 있기 때문이다. */ -internal class ReceiverListMappingFailure : RuntimeException() +private class ReceiverListMappingFailure : RuntimeException() /** * 서버 category 를 해석할 수 없으면 항목 하나만 목록에서 제외한다. @@ -30,7 +34,7 @@ internal class ReceiverListMappingFailure : RuntimeException() * 와이어 계약에 맞는 미래 값은 받아들이되 도메인에는 임의의 종류를 채우지 않는다. 그래야 필터·아이콘· * 상세 라우팅이 다른 종류로 조용히 왜곡되지 않는다. category 누락·null은 목록 serializer가 먼저 제외한다. */ -fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { +private fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { val resolvedType = afternoteTypeFromServerCategory(category) ?: return null return AfterNoteListItem( id = id, @@ -40,7 +44,7 @@ fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { ) } -fun List.toReceiverDomainList(errorReporter: ErrorReporter): List { +private fun List.toReceiverDomainList(errorReporter: ErrorReporter): List { var rejectedItemCount = 0 val items = mapNotNull { dto -> 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 83e20ea747..d691e1f4da 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 @@ -1,31 +1,32 @@ package com.afternote.feature.receiver.data.mapper -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.RECEIVER_LIST_DECODING_STAGE +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_MAPPING_STAGE import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter +import com.afternote.feature.receiver.data.reporting.assertReceiverListFailureContract import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** - * [ReceivedAfternoteDto.toDomainOrNull] / [toReceiverDomainList] 회귀 가드. + * [ReceivedAfternoteListDto.toReceiverDomainList] / [ReceivedAfternoteListDto.toDomainResult] 회귀 가드. * 수신자 목록 DTO→[com.afternote.feature.receiver.domain.model.AfterNoteListItem] 매핑. * 서버 카테고리를 [AfternoteType] 으로 바꾸는 규칙과 createdAt null 가드를 검증한다. + * + * 항목 하나를 옮기는 변환과 제외 집계는 이 파일 안에 갇힌 `private` 헬퍼다 (#1832). 그래서 + * 목록을 받는 공개 owner 로만 넣고, 나온 목록과 텔레메트리 속성으로 같은 계약을 확인한다. */ class ReceiverAfternoteListItemDtoToDomainTest { @Test - fun `toDomainOrNull - 필드 매핑 + category 정규화 + 날짜 포맷`() { + fun `목록 매핑 - 필드 매핑 + category 정규화 + 날짜 포맷`() { val result = - ReceivedAfternoteDto( - id = 9L, - title = "사진첩", - category = "GALLERY", - createdAt = "2025-11-26T14:30:00", - ).toDomainOrNull() - - requireNotNull(result) + mapItems( + resp(id = 9L, title = "사진첩", category = "GALLERY", createdAt = "2025-11-26T14:30:00"), + ).single() assertEquals(9L, result.id) assertEquals("사진첩", result.serviceName) @@ -34,37 +35,37 @@ class ReceiverAfternoteListItemDtoToDomainTest { } @Test - fun `toDomainOrNull - 변환은 대소문자 무시 + MUSIC PLAYLIST는 MEMORIAL`() { - assertEquals(AfternoteType.SOCIAL_NETWORK, requireNotNull(resp(category = "social").toDomainOrNull()).type) - assertEquals(AfternoteType.MEMORIAL, requireNotNull(resp(category = "PLAYLIST").toDomainOrNull()).type) - assertEquals(AfternoteType.MEMORIAL, requireNotNull(resp(category = "music").toDomainOrNull()).type) + fun `목록 매핑 - 변환은 대소문자 무시 + MUSIC PLAYLIST는 MEMORIAL`() { + assertEquals(AfternoteType.SOCIAL_NETWORK, mapItems(resp(category = "social")).single().type) + assertEquals(AfternoteType.MEMORIAL, mapItems(resp(category = "PLAYLIST")).single().type) + assertEquals(AfternoteType.MEMORIAL, mapItems(resp(category = "music")).single().type) } @Test - fun `toDomainOrNull - 사업자는 서버가 아는 종류다 - BUSINESS 로 올라온다`() { - assertEquals(AfternoteType.BUSINESS, requireNotNull(resp(category = "BUSINESS").toDomainOrNull()).type) + fun `목록 매핑 - 사업자는 서버가 아는 종류다 - BUSINESS 로 올라온다`() { + assertEquals(AfternoteType.BUSINESS, mapItems(resp(category = "BUSINESS")).single().type) } @Test - fun `toDomainOrNull - 서버가 모르는 category 를 보내면 항목을 거절한다`() { - assertNull(resp(category = "ESTATE").toDomainOrNull()) + fun `목록 매핑 - 서버가 모르는 category 를 보내면 항목을 거절한다`() { + assertTrue(mapItems(resp(category = "ESTATE")).isEmpty()) } @Test - fun `toDomainOrNull - 아예 모르는 값도 항목을 거절하고 특정 종류로 메우지 않는다`() { - assertNull(resp(category = "WHAT_IS_THIS").toDomainOrNull()) + fun `목록 매핑 - 아예 모르는 값도 항목을 거절하고 특정 종류로 메우지 않는다`() { + assertTrue(mapItems(resp(category = "WHAT_IS_THIS")).isEmpty()) } @Test - fun `toDomainOrNull - createdAt null이면 lastUpdatedAt null`() { - assertNull(requireNotNull(resp(createdAt = null).toDomainOrNull()).lastUpdatedAt) + fun `목록 매핑 - createdAt null이면 lastUpdatedAt null`() { + assertNull(mapItems(resp(createdAt = null)).single().lastUpdatedAt) } @Test fun `toReceiverDomainList - 각 항목을 순서대로 매핑`() { val reporter = RecordingErrorReporter() - val list = listOf(resp(id = 1L), resp(id = 2L)).toReceiverDomainList(reporter) + val list = listDto(resp(id = 1L), resp(id = 2L)).toReceiverDomainList(reporter) assertEquals(listOf(1L, 2L), list.map { it.id }) assertEquals(0, reporter.failures.size) @@ -74,16 +75,14 @@ class ReceiverAfternoteListItemDtoToDomainTest { fun `toReceiverDomainList - 지원하지 않는 category 항목만 제외하고 유효 항목은 보존한다`() { val reporter = RecordingErrorReporter() val list = - listOf( + listDto( resp(id = 1L, category = "SOCIAL"), resp(id = 2L, category = "ESTATE"), resp(id = 3L, category = "BUSINESS"), ).toReceiverDomainList(reporter) assertEquals(listOf(1L, 3L), list.map { it.id }) - assertEquals(1, reporter.failures.size) - assertEquals("receiver_list_mapping", reporter.failures.single().attributes["receiver_stage"]) - assertEquals("1", reporter.failures.single().attributes["rejected_item_count"]) + reporter.assertReceiverListFailureContract(mapOf(RECEIVER_LIST_MAPPING_STAGE to "1")) } @Test @@ -118,17 +117,22 @@ class ReceiverAfternoteListItemDtoToDomainTest { assertEquals(7, result.totalCount) assertEquals(listOf(1L, 3L), result.items.map { it.id }) - val failuresByStage = reporter.failures.associateBy { it.attributes["receiver_stage"] } - assertEquals(setOf("receiver_list_decoding", "receiver_list_mapping"), failuresByStage.keys) + reporter.assertReceiverListFailureContract( + mapOf( + RECEIVER_LIST_DECODING_STAGE to "2", + RECEIVER_LIST_MAPPING_STAGE to "1", + ), + ) + } - val decodingFailure = requireNotNull(failuresByStage["receiver_list_decoding"]) - assertEquals("2", decodingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListDecodingFailure::class.java.name, decodingFailure.attributes["error_type"]) + /** 제외가 없는 정상 경로만 쓰는 헬퍼 — 텔레메트리를 보는 케이스는 reporter 를 직접 들고 단언한다. */ + private fun mapItems(vararg dtos: ReceivedAfternoteDto) = listDto(*dtos).toReceiverDomainList(RecordingErrorReporter()) - val mappingFailure = requireNotNull(failuresByStage["receiver_list_mapping"]) - assertEquals("1", mappingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListMappingFailure::class.java.name, mappingFailure.attributes["error_type"]) - } + private fun listDto(vararg dtos: ReceivedAfternoteDto) = + ReceivedAfternoteListDto( + afternotes = dtos.toList(), + totalCount = dtos.size, + ) private fun resp( id: Long = 1L, 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 42e09ef715..245733c1d9 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 @@ -6,9 +6,10 @@ import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse 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.RECEIVER_LIST_DECODING_STAGE +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_MAPPING_STAGE import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter +import com.afternote.feature.receiver.data.reporting.assertReceiverListFailureContract import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason @@ -154,16 +155,12 @@ class ReceiverAfternotePagingSourceTest { val page = result as PagingSource.LoadResult.Page assertEquals(listOf(5L, 8L), page.data.map { it.id }) - val failuresByStage = reporter.failures.associateBy { it.attributes["receiver_stage"] } - assertEquals(setOf("receiver_list_decoding", "receiver_list_mapping"), failuresByStage.keys) - - val decodingFailure = requireNotNull(failuresByStage["receiver_list_decoding"]) - assertEquals("2", decodingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListDecodingFailure::class.java.name, decodingFailure.attributes["error_type"]) - - val mappingFailure = requireNotNull(failuresByStage["receiver_list_mapping"]) - assertEquals("1", mappingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListMappingFailure::class.java.name, mappingFailure.attributes["error_type"]) + reporter.assertReceiverListFailureContract( + mapOf( + RECEIVER_LIST_DECODING_STAGE to "2", + RECEIVER_LIST_MAPPING_STAGE to "1", + ), + ) val reportedPayload = reporter.failures.joinToString { failure -> diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt new file mode 100644 index 0000000000..a87d4ceae7 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.receiver.data.reporting + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue + +internal const val RECEIVER_LIST_DECODING_STAGE = "receiver_list_decoding" +internal const val RECEIVER_LIST_MAPPING_STAGE = "receiver_list_mapping" + +private const val RECEIVER_MAPPER_PACKAGE = "com.afternote.feature.receiver.data.mapper." +private const val RECEIVER_STAGE_KEY = "receiver_stage" +private const val REJECTED_ITEM_COUNT_KEY = "rejected_item_count" +private const val ERROR_TYPE_KEY = "error_type" + +/** + * 수신 목록 텔레메트리의 «분류 계약» 단언 (#1832). + * + * 디코딩·매핑 실패를 알리는 예외 타입은 mapper 파일 안에 갇힌 `private` 선언이라 테스트가 이름으로 + * 참조하지 않는다. 대신 [com.afternote.core.common.reporting.ErrorReporter] 가 기록에 붙이는 속성으로 + * 같은 계약을 검증한다 — 지켜야 할 것은 **두 단계가 각자 전용 타입으로 갈려 콘솔에서 구분된다**는 + * 사실이지 타입 이름이 아니다. `error_type` 은 원문을 버리는 redact 정책이 남기는 유일한 타입 정보다. + * + * @param expectedRejectedCounts 단계(`receiver_stage`) → 그 단계가 보고해야 할 제외 건수. + * 여기 없는 단계가 기록됐거나 빠졌으면 실패한다. + */ +internal fun RecordingErrorReporter.assertReceiverListFailureContract(expectedRejectedCounts: Map) { + val failuresByStage = failures.associateBy { it.attributes[RECEIVER_STAGE_KEY].orEmpty() } + assertEquals(expectedRejectedCounts.keys, failuresByStage.keys) + + val typesByStage = + expectedRejectedCounts.mapValues { (stage, expectedRejectedCount) -> + val failure = requireNotNull(failuresByStage[stage]) { "$stage 실패가 기록되지 않았다" } + assertEquals("$stage 제외 건수", expectedRejectedCount, failure.attributes[REJECTED_ITEM_COUNT_KEY]) + + val errorType = requireNotNull(failure.attributes[ERROR_TYPE_KEY]) { "$stage 에 error_type 이 없다" } + assertTrue( + "$stage 은 수신 목록 전용 실패 타입으로 기록돼야 한다: $errorType", + errorType.startsWith(RECEIVER_MAPPER_PACKAGE), + ) + errorType + } + + assertTrue( + "단계마다 다른 실패 타입으로 갈려야 콘솔에서 원인을 구분한다: $typesByStage", + typesByStage.values.toSet().size == typesByStage.size, + ) +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index b51b77e408..06b5145125 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -348,9 +348,6 @@ class ProductionVisibilityKonsistTest { val ISSUE_1676_RECEIVER = setOf( - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::class ReceiverListDecodingFailure [internal]", - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::class ReceiverListMappingFailure [internal]", - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::function toDomainOrNull [implicit-public]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt::function DocumentUploadScreenContent [internal]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt::function IdentityVerificationEmailScreenContent [internal]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt::function MasterKeyScreenContent [internal]",