diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 5036aee58a..b515456033 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -585,14 +585,13 @@ private fun authorDetail(): Detail = Detail( id = 73L, serviceName = "Instagram", - timestamps = DetailTimestamps(createdAt = "2026.08.20", updatedAt = "2026.08.22"), + timestamps = DetailTimestamps(updatedAt = "2026.08.22"), receivers = listOf( DetailReceiver( receiverId = 7L, name = "김수신", relation = "가족", - phone = "", ), ), leaveMessageBlocks = diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt index c7a84a7ba5..4c56aaa2dc 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt @@ -52,10 +52,10 @@ data class AfternoteDetailDto( @SerialName("afternoteId") val afternoteId: Long, @SerialName("category") val category: String, @SerialName("title") val title: String, - @SerialName("createdAt") val createdAt: String = "", + @SerialName("isDraft") val isDraft: Boolean, @SerialName("updatedAt") val updatedAt: String = "", @SerialName("credentials") val credentials: AfternoteCredentialsDto? = null, - @SerialName("receivers") val receivers: List? = null, + @SerialName("receivers") val receivers: List, @SerialName("actions") val processingMethods: List? = null, @SerialName("leaveMessage") val leaveMessage: List? = null, @SerialName("playlist") val memorial: AfternotePlaylistDto? = null, @@ -87,7 +87,6 @@ data class AfternoteDetailReceiverDto( @SerialName("receiverId") val receiverId: Long? = null, @SerialName("name") val name: String? = null, @SerialName("relation") val relation: String? = null, - @SerialName("phone") val phone: String? = null, ) @Serializable @@ -96,6 +95,7 @@ data class AfternoteListItemDto( @SerialName("title") val title: String, @SerialName("category") val category: String, @SerialName("createdAt") val createdAt: String, + @SerialName("isDraft") val isDraft: Boolean, ) @Serializable diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt index f03bda126e..d1ebc2afd0 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteCategoryCodec.kt @@ -32,3 +32,6 @@ fun afternoteTypeFromServerCategory(value: String): AfternoteType? { if (normalized == LEGACY_MEMORIAL_CATEGORY) return AfternoteType.MEMORIAL return serverCategoryByType.entries.firstOrNull { it.value == normalized }?.key } + +/** 서버 `category` → [AfternoteType]. 대응이 없으면 [AfternoteType.SOCIAL_NETWORK] 로 폴백한다. */ +fun categoryToAfternoteType(category: String): AfternoteType = afternoteTypeFromServerCategory(category) ?: AfternoteType.SOCIAL_NETWORK diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt similarity index 89% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapper.kt rename to feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt index d75faf9fbe..d1bcc40c15 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt @@ -1,12 +1,9 @@ -package com.afternote.feature.afternote.data.mapper.response +package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.AfternoteDetailDto import com.afternote.feature.afternote.data.dto.AfternoteDetailReceiverDto import com.afternote.feature.afternote.data.dto.AfternotePlaylistDto import com.afternote.feature.afternote.data.dto.AfternoteSongDto -import com.afternote.feature.afternote.data.mapper.afternoteTypeFromServerCategory -import com.afternote.feature.afternote.data.mapper.formatDateFromServer -import com.afternote.feature.afternote.data.mapper.toLeaveMessageBlocks import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent @@ -25,7 +22,7 @@ import kotlin.collections.mapNotNull * 임의의 종류로 메우면 라벨·아이콘·처리 방법이 다른 종류의 것으로 표시된다(#1048). * 던진 예외는 `AfternoteRepositoryImpl.safeCall` 이 잡아 `Result.failure` 로 옮긴다. */ -fun AfternoteDetailDto.toDetailDomain(): Detail { +fun AfternoteDetailDto.toDomain(): Detail { val resolvedType = requireNotNull(afternoteTypeFromServerCategory(category)) { "해석할 수 없는 애프터노트 종류다: afternoteId=$afternoteId category=$category" @@ -82,11 +79,10 @@ private fun AfternoteDetailDto.toPublishedCredentials() = // DTO 는 방어적으로 receiverId 가 nullable 이지만 서버 스펙상 필수 필드다 — 없는 항목은 // 도메인으로 올리지 않는다(식별자 없는 수신자는 저장·수정 어디에도 쓸 수 없다). -private fun List?.toDomain() = this?.mapNotNull { it.toDomain() }.orEmpty() +private fun List.toDomain() = mapNotNull { it.toDomain() } private fun AfternoteDetailDto.toTimestamps(): DetailTimestamps = DetailTimestamps( - createdAt = formatDateFromServer(createdAt), updatedAt = formatDateFromServer(updatedAt), ) @@ -107,7 +103,6 @@ private fun AfternoteDetailReceiverDto.toDomain(): DetailReceiver? = receiverId = id, name = name ?: "", relation = relation ?: "", - phone = phone ?: "", ) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomain.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteListItemMapper.kt similarity index 100% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomain.kt rename to feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteListItemMapper.kt diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToRequest.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt similarity index 65% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToRequest.kt rename to feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index ea16e1deff..2534bc2e90 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToRequest.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -3,13 +3,26 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.AfternoteCreateAccountRequestDto import com.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequestDto import com.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto +import com.afternote.feature.afternote.data.dto.AfternoteCredentialsDto +import com.afternote.feature.afternote.data.dto.AfternoteMemorialVideoDto +import com.afternote.feature.afternote.data.dto.AfternotePlaylistDto import com.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto +import com.afternote.feature.afternote.data.dto.AfternoteSongDto import com.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.AfternoteAccountCredentials import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload +import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload +import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload +import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload + +/** + * 도메인 → 요청 wire. 요청 바디 루트는 `toRequest`, 그 안에 실리는 부분 DTO 는 `toDto` 다. + */ fun AfternoteUpdatePayload.toRequest() = AfternoteUpdateRequestDto( @@ -18,7 +31,7 @@ fun AfternoteUpdatePayload.toRequest() = processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDto(), credentials = credentials?.toDto(), - receivers = receivers?.toDto(), + receivers = receivers?.map { it.toDto() }, memorial = memorial?.toDto(), ) @@ -69,3 +82,34 @@ fun CreateMemorialPayload.toRequest() = leaveMessage = leaveMessageBlocks.toDto(), receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, ) + +fun MemorialWritePayload.toDto() = + AfternotePlaylistDto( + memorialPhotoUrl = memorialPhotoUrl, + songs = songs.map { it.toDto() }, + memorialVideo = memorialVideo?.toDto(), + ) + +fun MemorialSongPayload.toDto() = + AfternoteSongDto( + title = title, + artist = artist, + coverUrl = coverUrl, + ) + +fun MemorialVideoPayload.toDto() = + AfternoteMemorialVideoDto( + videoUrl = videoUrl, + thumbnailUrl = thumbnailUrl, + ) + +fun AfternoteAccountCredentials.toDto() = + AfternoteCredentialsDto( + id = id, + password = password, + ) + +fun ReceiverRefPayload.toDto() = + AfternoteReceiverRefDto( + receiverId = receiverId, + ) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToDto.kt deleted file mode 100644 index 33964bd7b6..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToDto.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.afternote.feature.afternote.data.mapper - -import com.afternote.feature.afternote.data.dto.AfternoteCredentialsDto -import com.afternote.feature.afternote.data.dto.AfternoteMemorialVideoDto -import com.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto -import com.afternote.feature.afternote.domain.model.author.AfternoteAccountCredentials -import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload -import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload - -fun MemorialVideoPayload.toDto() = - AfternoteMemorialVideoDto( - videoUrl = videoUrl, - thumbnailUrl = thumbnailUrl, - ) - -fun AfternoteAccountCredentials.toDto() = - AfternoteCredentialsDto( - id = id, - password = password, - ) - -fun ReceiverRefPayload.toDto() = - AfternoteReceiverRefDto( - receiverId = receiverId, - ) - -fun List?.toDto() = - this?.map { - it.toDto() - } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt deleted file mode 100644 index d04de688ce..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.afternote.feature.afternote.data.mapper - -import com.afternote.feature.afternote.data.dto.AfternotePlaylistDto -import com.afternote.feature.afternote.data.dto.AfternoteSongDto -import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload -import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload - -fun MemorialWritePayload.toDto() = - AfternotePlaylistDto( - memorialPhotoUrl = memorialPhotoUrl, - songs = songs.toDto(), - memorialVideo = memorialVideo?.toDto(), - ) - -fun MemorialSongPayload.toDto() = - AfternoteSongDto( - title = title, - artist = artist, - coverUrl = coverUrl, - ) - -fun List.toDto() = - map { - it.toDto() - } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ServerDateFormat.kt similarity index 100% rename from feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt rename to feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ServerDateFormat.kt diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteRepositoryImpl.kt index 5c9f595971..3abdef4fc9 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteRepositoryImpl.kt @@ -7,8 +7,8 @@ import androidx.paging.PagingData import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus -import com.afternote.feature.afternote.data.mapper.response.toDetailDomain import com.afternote.feature.afternote.data.mapper.toBusinessRequest +import com.afternote.feature.afternote.data.mapper.toDomain import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.data.mapper.toServerCategory import com.afternote.feature.afternote.data.mapper.toSocialRequest @@ -56,7 +56,7 @@ class AfternoteRepositoryImpl override suspend fun getDetail(id: Long): Result = safeCall { - api.getAfternoteDetail(afternoteId = id).requireData().toDetailDomain() + api.getAfternoteDetail(afternoteId = id).requireData().toDomain() } override suspend fun createSocial(payload: CreateAccountPayload): Result = diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapperTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt similarity index 73% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapperTest.kt rename to feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt index ddf89ef8aa..70d0f146e3 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapperTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.data.mapper.response +package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.AfternoteCredentialsDto import com.afternote.feature.afternote.data.dto.AfternoteDetailDto @@ -17,20 +17,22 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * [AfternoteDetailDto.toDetailDomain] 회귀 가드 (작성자 상세). + * [AfternoteDetailDto.toDomain] 회귀 가드 (작성자 상세). * 핵심 경계: 공통 필드와 타입별 [DetailContent] 분리, 부분·부재 credentials 의 빈 값 강등, * receivers null→emptyList, receiver 필드 null→"", processingMethods null→emptyList, * memorialVideo null→video/thumbnail null. */ class AfternoteDetailMapperTest { @Test - fun `toDetailDomain - gallery 최소 응답은 컬렉션이 비어 있다`() { + fun `toDomain - gallery 최소 응답은 컬렉션이 비어 있다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "GALLERY", title = "t", - ).toDetailDomain() + ).toDomain() assertEquals(1L, result.id) val content = result.content as DetailContent.Gallery @@ -40,26 +42,30 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - 사업자 상세는 BUSINESS 로 올라온다 - 소셜로 둔갑하지 않는다`() { + fun `toDomain - 사업자 상세는 BUSINESS 로 올라온다 - 소셜로 둔갑하지 않는다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "BUSINESS", title = "t", - ).toDetailDomain() + ).toDomain() assertTrue("사업자는 Business 내용으로 올라와야 한다", result.content is DetailContent.Business) } @Test - fun `toDetailDomain - 해석할 수 없는 category 는 실패다 - 임의의 종류로 메우지 않는다`() { + fun `toDomain - 해석할 수 없는 category 는 실패다 - 임의의 종류로 메우지 않는다`() { val thrown = assertThrows(IllegalArgumentException::class.java) { AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 42L, category = "???", title = "t", - ).toDetailDomain() + ).toDomain() } assertTrue( @@ -69,46 +75,46 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - timestamps 포맷`() { + fun `toDomain - timestamps 포맷`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "GALLERY", title = "t", - createdAt = "2025-11-26T14:30:00", updatedAt = "2025-12-01T09:00:00", - ).toDetailDomain() + ).toDomain() - assertEquals("2025.11.26", result.timestamps.createdAt) assertEquals("2025.12.01", result.timestamps.updatedAt) } @Test - fun `toDetailDomain - receiver의 null 필드는 빈 문자열`() { + fun `toDomain - receiver의 null 필드는 빈 문자열`() { val result = AfternoteDetailDto( + isDraft = false, afternoteId = 1L, category = "GALLERY", title = "t", receivers = listOf(AfternoteDetailReceiverDto(receiverId = 5L)), - ).toDetailDomain() + ).toDomain() val receiver = result.receivers.single() assertTrue(result.content is DetailContent.Gallery) assertEquals(5L, receiver.receiverId) assertEquals("", receiver.name) assertEquals("", receiver.relation) - assertEquals("", receiver.phone) } /** - * DTO 는 방어적으로 receiverId 가 nullable 이지만 서버 스펙상 필수다(상세 응답 `ReceiverRequest` 는 - * 이 필드 하나뿐). 도메인은 non-null 이므로 경계인 이 매퍼가 걸러야 한다. + * DTO 는 방어적으로 receiverId 가 nullable 이지만 도메인은 non-null 이므로, 경계인 이 매퍼가 걸러야 한다. */ @Test - fun `toDetailDomain - receiverId 없는 항목은 도메인으로 올리지 않는다`() { + fun `toDomain - receiverId 없는 항목은 도메인으로 올리지 않는다`() { val result = AfternoteDetailDto( + isDraft = false, afternoteId = 1L, category = "GALLERY", title = "t", @@ -117,20 +123,22 @@ class AfternoteDetailMapperTest { AfternoteDetailReceiverDto(receiverId = null, name = "식별자없음"), AfternoteDetailReceiverDto(receiverId = 9L, name = "김수신"), ), - ).toDetailDomain() + ).toDomain() assertEquals(listOf(9L), result.receivers.map { it.receiverId }) } @Test - fun `toDetailDomain - credentials 매핑`() { + fun `toDomain - credentials 매핑`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", credentials = AfternoteCredentialsDto(id = "user", password = "pw"), - ).toDetailDomain() + ).toDomain() val credentials = (result.content as DetailContent.SocialNetwork).credentials assertEquals("user", credentials.id) @@ -138,13 +146,15 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - credentials가 아예 없으면 빈 값으로 낮춘다`() { + fun `toDomain - credentials가 아예 없으면 빈 값으로 낮춘다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", - ).toDetailDomain() + ).toDomain() val credentials = (result.content as DetailContent.SocialNetwork).credentials assertEquals("", credentials.id) @@ -152,14 +162,16 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - credentials id만 없으면 id를 빈 값으로 낮춘다`() { + fun `toDomain - credentials id만 없으면 id를 빈 값으로 낮춘다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", credentials = AfternoteCredentialsDto(id = null, password = "pw"), - ).toDetailDomain() + ).toDomain() val credentials = (result.content as DetailContent.SocialNetwork).credentials assertEquals("", credentials.id) @@ -167,14 +179,16 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - credentials password만 없으면 password를 빈 값으로 낮춘다`() { + fun `toDomain - credentials password만 없으면 password를 빈 값으로 낮춘다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", credentials = AfternoteCredentialsDto(id = "user", password = null), - ).toDetailDomain() + ).toDomain() val credentials = (result.content as DetailContent.SocialNetwork).credentials assertEquals("user", credentials.id) @@ -182,9 +196,11 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - playlist 미디어·곡 매핑`() { + fun `toDomain - playlist 미디어·곡 매핑`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", @@ -194,7 +210,7 @@ class AfternoteDetailMapperTest { songs = listOf(AfternoteSongDto(title = "s", artist = "a")), memorialVideo = AfternoteMemorialVideoDto(videoUrl = "v.mp4", thumbnailUrl = "t.jpg"), ), - ).toDetailDomain() + ).toDomain() val memorial = (result.content as DetailContent.Memorial).memorial val media = memorial.media @@ -207,15 +223,17 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - playlist의 공통 leaveMessage도 보존한다`() { + fun `toDomain - playlist의 공통 leaveMessage도 보존한다`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", leaveMessage = listOf(LeaveMessageBlockDto(title = "가족에게", body = "잘 지내")), memorial = AfternotePlaylistDto(), - ).toDetailDomain() + ).toDomain() assertEquals( listOf(LeaveMessageBlock(title = "가족에게", body = "잘 지내")), @@ -224,14 +242,16 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - memorialVideo null이면 video thumbnail null`() { + fun `toDomain - memorialVideo null이면 video thumbnail null`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", memorial = AfternotePlaylistDto(memorialVideo = null), - ).toDetailDomain() + ).toDomain() val media = (result.content as DetailContent.Memorial).memorial.media assertNull(media.videoUrl) @@ -239,28 +259,32 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - playlist 타입에 playlist가 없으면 오류`() { + fun `toDomain - playlist 타입에 playlist가 없으면 오류`() { val exception = assertThrows(IllegalArgumentException::class.java) { AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", - ).toDetailDomain() + ).toDomain() } assertEquals("playlist is required for MEMORIAL detail", exception.message) } @Test - fun `toDetailDomain - 사업자 상세도 credentials 를 Business 내용에 싣는다`() { + fun `toDomain - 사업자 상세도 credentials 를 Business 내용에 싣는다`() { val business = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "BUSINESS", title = "회사 계정", credentials = AfternoteCredentialsDto(id = "user", password = "pw"), - ).toDetailDomain() + ).toDomain() val content = business.content as DetailContent.Business assertEquals(AfternoteType.BUSINESS, content.type) @@ -270,13 +294,15 @@ class AfternoteDetailMapperTest { /** `ESTATE`(#491)는 아직 서버 enum 에 없어 응답으로 올라올 수 없다 — 왔다면 해석 실패다. */ @Test - fun `toDetailDomain - ESTATE 는 서버가 보낼 수 없는 값이라 해석 실패다`() { + fun `toDomain - ESTATE 는 서버가 보낼 수 없는 값이라 해석 실패다`() { assertThrows(IllegalArgumentException::class.java) { AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 2L, category = "ESTATE", title = "부동산", - ).toDetailDomain() + ).toDomain() } } } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomainTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemMapperTest.kt similarity index 96% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomainTest.kt rename to feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemMapperTest.kt index f1873ad5dc..6e5d2f89f6 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemDtoToDomainTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteListItemMapperTest.kt @@ -13,7 +13,7 @@ import org.junit.Test * * 항목 매퍼를 따로 노출하지 않으므로 단건도 이 소비 경로로 확인한다 — 실제로 도는 경로 그대로다. */ -class AfternoteListItemDtoToDomainTest { +class AfternoteListItemMapperTest { @Test fun `필드와 Long id를 그대로 매핑`() { val result = @@ -23,6 +23,7 @@ class AfternoteListItemDtoToDomainTest { title = "은행 계정", category = "SOCIAL", createdAt = "2025-11-26T14:30:00", + isDraft = false, ), ).toDomainList().single() @@ -85,10 +86,12 @@ class AfternoteListItemDtoToDomainTest { title: String = "t", category: String = "SOCIAL", createdAt: String = "2025-01-01T00:00:00", + isDraft: Boolean = false, ) = AfternoteListItemDto( afternoteId = afternoteId, title = title, category = category, createdAt = createdAt, + isDraft = isDraft, ) } 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 38e45e36ed..fd7883d865 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 @@ -4,7 +4,6 @@ import com.afternote.feature.afternote.data.dto.AfternoteCreateAccountRequestDto import com.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto import com.afternote.feature.afternote.data.dto.AfternoteDetailDto import com.afternote.feature.afternote.data.dto.LeaveMessageBlockDto -import com.afternote.feature.afternote.data.mapper.response.toDetailDomain import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload @@ -35,19 +34,21 @@ class LeaveMessageBlockContractTest { /** * 본문은 실서버 응답 전문 그대로다 (`GET /afternotes/29`, 2026-08-07 실측). * 제목을 비워 보낸 블록이 `title: null` 로 돌아온다는 점이 이 케이스의 요지다. + * + * `isDraft` 는 캡처 이후 서버가 추가한 필드라 여기서만 보강했다. */ @Test fun `작성자 상세 - leaveMessage 배열을 파싱해 블록으로 옮긴다`() { val response = """ - {"afternoteId":29,"category":"SOCIAL","title":"509검증-배열","actions":["게시물 내리기"], + {"afternoteId":29,"category":"SOCIAL","title":"509검증-배열","isDraft":false,"actions":["게시물 내리기"], "leaveMessage":[{"title":"가족에게","body":"잘 부탁해"},{"title":null,"body":"제목 없는 블록"}], "credentials":{"id":"qa","password":"qa"}, "receivers":[{"receiverId":7,"name":"QA407Receiver","relation":"DAUGHTER"}], "playlist":null,"updatedAt":"2026-08-07T06:21:14.553567"} """.trimIndent() - val detail = json.decodeFromString(response).toDetailDomain() + val detail = json.decodeFromString(response).toDomain() assertEquals( listOf( @@ -64,8 +65,8 @@ class LeaveMessageBlockContractTest { val detail = json .decodeFromString( - """{"afternoteId":11,"category":"SOCIAL","title":"t","credentials":{"id":"qa","password":"qa"},"leaveMessage":[{"title":"","body":"재현용 남기실 말씀"}]}""", - ).toDetailDomain() + """{"afternoteId":11,"category":"SOCIAL","title":"t","isDraft":false,"receivers":[],"credentials":{"id":"qa","password":"qa"},"leaveMessage":[{"title":"","body":"재현용 남기실 말씀"}]}""", + ).toDomain() assertEquals( listOf(LeaveMessageBlock(title = "", body = "재현용 남기실 말씀")), @@ -78,8 +79,8 @@ class LeaveMessageBlockContractTest { val detail = json .decodeFromString( - """{"afternoteId":1,"category":"SOCIAL","title":"t","credentials":{"id":"qa","password":"qa"},"leaveMessage":null}""", - ).toDetailDomain() + """{"afternoteId":1,"category":"SOCIAL","title":"t","isDraft":false,"receivers":[],"credentials":{"id":"qa","password":"qa"},"leaveMessage":null}""", + ).toDomain() assertTrue(detail.leaveMessageBlocks.isEmpty()) } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/PlaylistInputToDtoTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/PlaylistInputToDtoTest.kt index 7e3aeddb65..35c6936b89 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/PlaylistInputToDtoTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/PlaylistInputToDtoTest.kt @@ -7,17 +7,15 @@ import org.junit.Test class PlaylistInputToDtoTest { @Test fun `곡 입력은 서버 계약의 제목 아티스트 커버만 wire로 변환한다`() { - val dtoSongs = - listOf( - MemorialSongPayload( - title = "노래", - artist = "가수", - coverUrl = "cover", - ), + val dtoSong = + MemorialSongPayload( + title = "노래", + artist = "가수", + coverUrl = "cover", ).toDto() - assertEquals("노래", dtoSongs.single().title) - assertEquals("가수", dtoSongs.single().artist) - assertEquals("cover", dtoSongs.single().coverUrl) + assertEquals("노래", dtoSong.title) + assertEquals("가수", dtoSong.artist) + assertEquals("cover", dtoSong.coverUrl) } } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/Detail.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/Detail.kt index 8311ee7de0..71cff53bbe 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/Detail.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/Detail.kt @@ -54,7 +54,6 @@ sealed interface DetailContent { } data class DetailTimestamps( - val createdAt: String, val updatedAt: String, ) @@ -66,15 +65,10 @@ data class DetailCredentials( /** * 애프터노트에 지정된 수신자. * - * [receiverId] 는 서버 스펙상 필수다(상세 응답의 `ReceiverRequest` 는 이 필드 하나뿐, nullable 표기 없음 — - * 2026-08-02 Swagger 실측). DTO 는 방어적으로 nullable 이지만 그 경계는 매퍼가 흡수하고, - * 도메인부터는 식별자가 있는 수신자만 다룬다. - * - * name·relation·phone 은 이 응답에 실리지 않아 현재 빈 문자열로 온다 — 서버 확장은 Afternote-BE #81. + * 식별자 없는 항목은 매퍼 경계에서 걸러, 도메인부터는 [receiverId] 가 있는 수신자만 다룬다. */ data class DetailReceiver( val receiverId: Long, val name: String, val relation: String, - val phone: String, ) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/AfternoteDetailSuccessMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/AfternoteDetailSuccessMapper.kt index 62c774876c..2e2b5d1ca7 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/AfternoteDetailSuccessMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/AfternoteDetailSuccessMapper.kt @@ -11,7 +11,7 @@ import com.afternote.feature.afternote.presentation.shared.model.toMessageBlockU /** 상세 화면에 쓰는 "최종 작성일": 갱신일이 있으면 그것, 공백이면 생성일. */ private val Detail.finalWriteDate: String - get() = timestamps.updatedAt.ifBlank { timestamps.createdAt } + get() = timestamps.updatedAt internal fun Detail.toReceiverUiModels(): List = receivers.map { r -> diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormPrefillTest.kt index fc2b7c1772..c48acd3975 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormPrefillTest.kt @@ -141,7 +141,7 @@ class AfternoteEditorFormPrefillTest { ) = Detail( id = 1L, serviceName = serviceName, - timestamps = DetailTimestamps(createdAt = "2026-08-21", updatedAt = "2026-08-21"), + timestamps = DetailTimestamps(updatedAt = "2026-08-21"), receivers = emptyList(), leaveMessageBlocks = leaveMessageBlocks, content = content, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorReceiverPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorReceiverPrefillTest.kt index 1364b03d96..f14b9402a5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorReceiverPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorReceiverPrefillTest.kt @@ -44,13 +44,13 @@ class AfternoteEditorReceiverPrefillTest { id: Long, name: String, relation: String, - ) = DetailReceiver(receiverId = id, name = name, relation = relation, phone = "010-0000-0000") + ) = DetailReceiver(receiverId = id, name = name, relation = relation) private fun detail(vararg receivers: DetailReceiver) = Detail( id = 1L, serviceName = "구글 포토", - timestamps = DetailTimestamps(createdAt = "2026-08-02", updatedAt = "2026-08-02"), + timestamps = DetailTimestamps(updatedAt = "2026-08-02"), receivers = receivers.toList(), leaveMessageBlocks = emptyList(), content = diff --git a/feature/receiver/data/build.gradle.kts b/feature/receiver/data/build.gradle.kts index de799eefda..45f158da13 100644 --- a/feature/receiver/data/build.gradle.kts +++ b/feature/receiver/data/build.gradle.kts @@ -14,7 +14,7 @@ dependencies { implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.feature.receiver.domain) - // 수신 상세가 애프터노트 도메인 모델·공유 매퍼(MappingUtils 등)·author 서비스(내려받기)를 + // 수신 상세가 애프터노트 도메인 모델·공유 매퍼(ServerDateFormat 등)·author 서비스(내려받기)를 // 참조한다. 공유 지점의 receiver 몫 분리는 후속 판단. implementation(projects.feature.afternote.domain) implementation(projects.feature.afternote.data) diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index 933a1467bd..c1a06d05d9 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -24,3 +24,8 @@ dependencies { } } } + +tasks.withType().configureEach { + // 「해소된 항목은 경고로 알린다」 가 찍는 경고가 CI 로그에 남아야, 목록을 언제 갱신할지 알아챈다. + testLogging { showStandardStreams = true } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt new file mode 100644 index 0000000000..fa451a4c54 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt @@ -0,0 +1,134 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.declaration.KoClassDeclaration +import com.lemonappdev.konsist.api.ext.list.withPackage +import org.junit.Test + +/** + * 응답 DTO 계약 회귀 가드. + * + * **보정형 기본값**(`= ""` · `= 0` · `= false` · `= emptyList()`)을 non-null 응답 프로퍼티에 두면, + * 서버가 키를 빼거나 계약을 바꿔도 파싱이 **성공**한다. 빈 문자열·0·false·빈 목록이 정상값으로 + * 화면까지 흘러가 계약 변경이 은폐된다. 실제 사고 모양: + * + * - `isDraft = false` → 임시저장 글이 목록에 그대로 노출 + * - `hasNext = false` → 페이지네이션이 조용히 멈춤 + * - `diaries = emptyList()` → 목록 소실이 "기록 없음" 으로 보임 + * + * `coerceInputValues` 는 **기본값이 있어야** 동작하므로, 기본값을 두는 순간 "키 누락" 과 + * "서버가 보낸 잘못된 값" 이 같은 결과로 수렴한다. 둘은 성격이 다르다 — + * 키 누락은 계약이 바뀐 것이라 실패해야 드러나고, 값 확장은 관대해도 된다(전용 직렬화기). + * + * ### 검사 대상 + * `..dto..` 패키지의 `@Serializable data class` 중 **이름에 `Request` 가 없는 것**(= 응답). + * 요청 DTO 의 기본값은 "미선택 = 빈 배열 전송" 처럼 의도된 것이라 제외한다. + * + * ### nullable 은 대상이 아니다 + * `val imageUrl: String? = null` 처럼 **값이 조건부인 필드**는 정당하다. 이 가드가 막는 것은 + * non-null 로 선언해 놓고 기본값으로 누락을 메우는, 서로 모순된 조합뿐이다. + * + * ### [KNOWN_COERCING_DEFAULTS] + * 실측 시점에 남아 있던 잔여. 모듈별 적용 이슈가 해소하며 목록에서 뺀다. + * 이미 고쳐진 항목이 목록에 남아 있으면 **경고만** 낸다 — 이유는 아래 「해소된 항목은 경고로 알린다」 주석 참고. + */ +class ResponseDtoContractKonsistTest { + @Test + fun `응답 DTO 의 non-null 프로퍼티는 보정형 기본값을 두지 않는다`() { + val violations = coercingDefaults() - KNOWN_COERCING_DEFAULTS + + check(violations.isEmpty()) { + buildString { + appendLine("응답 DTO 의 non-null 프로퍼티에 보정형 기본값이 새로 추가됐다 (${violations.size}건).") + appendLine("서버가 키를 빼도 파싱이 성공해 계약 누락이 은폐된다.") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("값이 조건부라 정말 없을 수 있으면 기본값이 아니라 nullable 로 선언한다.") + } + } + } + + /** + * 해소된 항목이 목록에 남아 있으면 **경고만** 낸다. + * + * 실패시키면 「항목을 해소하는 PR」 과 「목록에서 빼는 PR」 이 서로를 깨뜨린다. 둘은 다른 + * 브랜치라 충돌도 나지 않고, 어느 쪽이 먼저 머지되든 그 순간부터 develop 의 `:konsist:test` + * 가 red 가 된다. 필수 체크가 `guard` 하나뿐이라 머지를 막지도 못한 채 red 만 남는다. + * #933(#789 의 26건 해소)과 이 목록 사이에서 실제로 그렇게 됐다. + * + * 목록이 썩는 것을 막는 값어치는 「신규 추가 금지」 쪽이 이미 담당한다 — 순서와 무관하게 + * 작동하는 그쪽만 실패시키고, 이쪽은 갱신 시점을 알리는 데 그친다. + */ + @Test + fun `해소된 항목은 경고로 알린다`() { + val stale = KNOWN_COERCING_DEFAULTS - coercingDefaults() + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] KNOWN_COERCING_DEFAULTS 에 이미 해소된 항목이 남아 있다 (${stale.size}건).") + appendLine("목록에서 지워야 다음 위반이 이 자리에 숨지 않는다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + + private fun coercingDefaults(): Set = + responseDtoClasses() + .flatMap { dto -> + dto.primaryConstructor + ?.parameters + .orEmpty() + .filter { !it.type.isNullable && it.defaultValue != null } + .map { "${dto.name}.${it.name}" } + }.toSet() + + private fun responseDtoClasses(): List = + Konsist + .scopeFromProject() + .classes() + .withPackage("com.afternote..dto..") + .filter { it.hasDataModifier } + .filter { it.annotations.any { annotation -> annotation.name == SERIALIZABLE } } + .filterNot { REQUEST_DTO in it.name } + + private companion object { + const val SERIALIZABLE = "Serializable" + + /** 아웃바운드. `receivers = emptyList()`("미선택 = 빈 배열 전송") 같은 의도된 기본값이라 제외. */ + const val REQUEST_DTO = "Request" + + /** + * #789 — mindrecord (2건). #933 이 26건을 해소한 뒤 #573 이 머지되며 남은 잔여다. + * BE `DailyQuestionAnswerResponse` record 에 두 키가 다 있어 서버는 항상 보낸다. + */ + private val MINDRECORD = + setOf( + "DailyQuestionAnswerResponseDto.content", + "DailyQuestionAnswerResponseDto.isDraft", + ) + + /** #957 — afternote (11건). `AfternoteDetailDto.createdAt` 은 #676 에서 해소(BE 응답에 없는 필드라 삭제). */ + private val AFTERNOTE = + setOf( + "AfternoteDetailDto.updatedAt", + "AfternotePlaylistDto.songs", + "AfternotePageDto.content", + "AfternotePageDto.page", + "AfternotePageDto.size", + "AfternotePageDto.hasNext", + "ReceivedAfternoteListDto.afternotes", + "ReceivedAfternoteListDto.totalCount", + "ReceivedAfternoteDetailDto.processingMethods", + "ReceivedPlaylistDto.songs", + "MusicSearchResponseDto.tracks", + ) + + /** #790 — timeletter (1건) */ + private val TIMELETTER = setOf("ReceivedTimeLetterDto.blocks") + + val KNOWN_COERCING_DEFAULTS = MINDRECORD + AFTERNOTE + TIMELETTER + } +}