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(),