From 98b7cdccf8d9285de4f369ee1407af1db1cd010e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 26 Aug 2026 03:48:29 +0900 Subject: [PATCH] =?UTF-8?q?refactor(receiver):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=83=81=EC=84=B8=20=EC=95=A0=ED=94=84=ED=84=B0?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=9C=EB=AA=A9=EC=9D=98=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EC=97=86=EB=8A=94=20nullable=20=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B7=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목 없는 수신 상세는 서버에서 나올 수 없다. BE 가 세 겹으로 막는다 — 생성 요청이 @NotBlank(AfternoteCreateRequest), 컬럼이 nullable = false(Afternote), 수정 경로는 null 이면 기존 제목을 유지한다(AfternoteService). 응답 조립부 3종이 그 값을 그대로 싣고, 임시저장은 수신자 조회 쿼리가 isDraft = false 로 걸러 낸다. 그런데 상세 DTO·도메인만 String? 이라, 매퍼가 title.orEmpty() 로 도달 불가능한 «제목 없는 화면» 을 그리고 있었다. 발신자 쪽 제목 7곳도, 같은 파일의 수신 목록·곡 제목도 이미 non-null 이다. 검토된 결정이 아니라 손대지 않은 초기값이었다. 와이어까지 좁힌 것은 상세 한정이다. #1010 이 세운 «와이어 관용» 의 논거는 페이징 목록의 실패 폭(항목 하나가 페이지 전체를 날림)인데, 상세는 응답 하나가 화면 하나라 «항목만 떨어뜨린다» 가 성립하지 않고 제목 없는 상세는 어차피 못 그린다. 수신 목록 DTO 는 손대지 않았다. 계약이 깨졌을 때의 실패 방향을 테스트로 고정했다 — 키가 빠지거나 명시적 null 이면 빈 제목으로 성공하지 않고, 어느 키 때문인지 남기며 파싱이 실패한다. coerceInputValues 는 기본값이 있어야 동작하므로 이 필드를 구해 주지 않는다. --- .../mapper/LeaveMessageBlockContractTest.kt | 2 +- .../receiver/data/dto/ReceiverAfternoteDto.kt | 2 +- ...eceivedAfternoteDetailTitleContractTest.kt | 64 +++++++++++++++++++ .../ReceivedAfternoteDetailMapperTest.kt | 8 ++- .../domain/model/ReceiverAfternoteModels.kt | 2 +- .../ReceivedAfternoteDetailSuccessMapper.kt | 4 +- 6 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedAfternoteDetailTitleContractTest.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 8719d54aa7..b362605081 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 @@ -102,7 +102,7 @@ class LeaveMessageBlockContractTest { val detail = json .decodeFromString( - """{"id":1,"category":"GALLERY","leaveMessage":[{"title":null,"body":"사진은 남겨줘"}]}""", + """{"id":1,"category":"GALLERY","title":"사진첩","leaveMessage":[{"title":null,"body":"사진은 남겨줘"}]}""", ).toDomain() assertEquals( diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt index 24b8293fd9..18249e5b8e 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/ReceiverAfternoteDto.kt @@ -25,7 +25,7 @@ data class ReceivedAfternoteDto( data class ReceivedAfternoteDetailDto( @SerialName("id") val id: Long, @SerialName("category") val category: String? = null, - @SerialName("title") val title: String? = null, + @SerialName("title") val title: String, @SerialName("actions") val processingMethods: List = emptyList(), @SerialName("leaveMessage") val leaveMessage: List? = null, @SerialName("senderName") val senderName: String? = null, diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedAfternoteDetailTitleContractTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedAfternoteDetailTitleContractTest.kt new file mode 100644 index 0000000000..c687a3f949 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/dto/ReceivedAfternoteDetailTitleContractTest.kt @@ -0,0 +1,64 @@ +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.mapper.response.toDomain +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수신 상세 응답의 `title` 이 **반드시 온다**는 계약 회귀 가드 (#1177). + * + * BE 는 이 값을 비워 둘 수 없다 — 생성 요청이 `@NotBlank`(`AfternoteCreateRequest`), 컬럼이 + * `nullable = false`(`Afternote`), 수정 경로는 null 이면 기존 제목을 유지한다(`AfternoteService`). + * 응답 조립부 3종(social·gallery·playlist)이 그 값을 그대로 싣고, 임시저장은 수신자 조회 쿼리가 + * `isDraft = false` 로 걸러 낸다. 그래서 제목 없는 수신 상세는 **서버에서 나올 수 없다.** + * + * 이 가드가 지키는 것은 그 계약이 깨졌을 때의 **실패 방향**이다. 예전처럼 `String? = null` 이면 + * 키가 빠져도 파싱이 조용히 통과하고 화면이 제목 없이 열려, 서버 결함이 «제목이 비어 보이는» + * 정상 화면으로 위장된다. Json 설정은 `NetworkModule.provideJson` 과 동일 — + * `coerceInputValues` 는 **기본값이 있어야** 동작하므로 이 필드를 구해 주지 않는다. + */ +class ReceivedAfternoteDetailTitleContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Test + fun `제목이 있는 응답 - 디코드부터 도메인 title 까지 도달`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"id":1,"category":"SOCIAL","title":"인스타그램","actions":["계정 삭제"],"senderName":"김철수","createdAt":"2026-08-26T14:30:00"}}""" + + val detail = json.decodeFromString>(payload).requireData().toDomain() + + assertEquals("인스타그램", detail.title) + } + + @Test + fun `제목 키가 빠진 응답 - 빈 제목으로 성공하지 않고 어느 키인지 남기며 실패한다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"id":1,"category":"SOCIAL","senderName":"김철수"}}""" + + assertTrue(decodeFailureMessage(payload).contains("title")) + } + + @Test + fun `제목이 명시적 null 인 응답 - coerceInputValues 가 구해 주지 않는다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"id":1,"category":"SOCIAL","title":null}}""" + + assertTrue(decodeFailureMessage(payload).contains("title")) + } + + /** 실패 자체와 «어느 키 때문인지»를 함께 고정한다 — 진단 문구가 사라지면 서버 결함을 못 짚는다. */ + private fun decodeFailureMessage(payload: String): String = + assertThrows(SerializationException::class.java) { + json.decodeFromString>(payload) + }.message.orEmpty() +} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt index 830dc821ad..96b26ad303 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/response/ReceivedAfternoteDetailMapperTest.kt @@ -36,17 +36,17 @@ class ReceivedAfternoteDetailMapperTest { @Test fun `toDomain - category null이면 type null`() { - assertNull(ReceivedAfternoteDetailDto(id = 1L, category = null).toDomain().type) + assertNull(ReceivedAfternoteDetailDto(id = 1L, title = "제목", category = null).toDomain().type) } @Test fun `toDomain - createdAt null이면 createdAt null`() { - assertNull(ReceivedAfternoteDetailDto(id = 1L, createdAt = null).toDomain().createdAt) + assertNull(ReceivedAfternoteDetailDto(id = 1L, title = "제목", createdAt = null).toDomain().createdAt) } @Test fun `toDomain - playlist null이면 null`() { - assertNull(ReceivedAfternoteDetailDto(id = 1L, playlist = null).toDomain().playlist) + assertNull(ReceivedAfternoteDetailDto(id = 1L, title = "제목", playlist = null).toDomain().playlist) } @Test @@ -54,6 +54,7 @@ class ReceivedAfternoteDetailMapperTest { val result = ReceivedAfternoteDetailDto( id = 1L, + title = "제목", playlist = ReceivedPlaylistDto( atmosphere = "차분", @@ -74,6 +75,7 @@ class ReceivedAfternoteDetailMapperTest { val result = ReceivedAfternoteDetailDto( id = 1L, + title = "제목", credentials = ReceivedCredentialsDto(id = "u", password = "p"), ).toDomain() diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt index 870b780ebb..4524b7dbf1 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAfternoteModels.kt @@ -20,7 +20,7 @@ data class ReceivedExportBundle( ) data class ReceivedAfternoteDetail( - val title: String? = null, + val title: String, val senderName: String? = null, val createdAt: String? = null, val category: String? = null, diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt index 0846dc312e..2bc133f9e1 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt @@ -31,7 +31,7 @@ internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedD private fun ReceivedAfternoteDetail.toReceivedSocialNetworkDetailContent(): ReceivedSocialNetworkDetailContent = ReceivedSocialNetworkDetailContent( - serviceName = title.orEmpty(), + serviceName = title, accountId = credentials?.id.orEmpty(), password = credentials?.password.orEmpty(), processingMethods = processingMethods, @@ -41,7 +41,7 @@ private fun ReceivedAfternoteDetail.toReceivedSocialNetworkDetailContent(): Rece private fun ReceivedAfternoteDetail.toReceivedGalleryDetailContent(): ReceivedGalleryDetailContent = ReceivedGalleryDetailContent( - serviceName = title.orEmpty(), + serviceName = title, finalWriteDate = createdAt.orEmpty(), processingMethods = processingMethods, messageBlocks = leaveMessageBlocks.toMessageBlockUiModels(),