From f56f969f479457ba4e3d084b78a0e06d0bcea0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 02:56:50 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor(afternote):=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20DTO=20=EB=A5=BC=20BE=20=EC=8B=A4?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EB=8F=99=EA=B8=B0=ED=99=94=20(#6?= =?UTF-8?q?76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판정 근거는 OpenAPI 문서가 아니라 배포본 `Afternote-BE` release `8bfe43d` 실코드다 — BE 응답 record 65개 중 `requiredMode` 를 명시한 건 2개뿐이라 명세만으로는 판정할 수 없고, Jackson `NON_NULL` 설정이 없어 record 에 있으면 키가 항상 나가고 없으면 절대 안 온다. ## 유령 필드 제거 — 값이 영영 오지 않던 것 - `AfternoteDetailDto.createdAt`: BE `AfternotedetailResponse` 에 없다. `= ""` 로 받아 `AfternoteDetailMapper` 가 `formatDateFromServer` 로 포맷하고, 화면은 `updatedAt.ifBlank { createdAt }` 로 폴백했다 — 항상 빈 문자열이었다. - `DetailReceiver.phone`: BE `AfternoteReceiverResponse` 는 receiverId·name·relation 3필드다. 화면 소비처도 0건이라 DTO·도메인·매퍼에서 함께 걷는다. ## 누락 필드 수신 — 서버가 보내는데 ignoreUnknownKeys 로 버려지던 것 - `AfternoteDetailDto.isDraft` · `AfternoteListItemDto.isDraft` (#808 이 필요로 하는 값) BE 가 `7715e0a`(2026-08-10)로 추가했고 release 에 포함돼 있다. `AfternoteService` 가 `Boolean.TRUE.equals(...)` 로 조립하므로 non-null 이다. ## nullable 좁힘 - `AfternoteDetailDto.receivers`: BE 가 카테고리 분기 전에 `toList()` 로 조립해 세 분기 모두 같은 값을 넣는다 — 수신자를 안 골라도 빈 배열이지 키가 빠지지 않는다. ## 재발 가드 `ResponseDtoContractKonsistTest` — 응답 DTO 의 non-null 프로퍼티에 보정형 기본값 (`= ""` · `= 0` · `= false` · `= emptyList()`)을 금지한다. 서버가 키를 빼도 파싱이 성공해 계약 누락이 은폐되는 조합이고, 위 `createdAt = ""` 이 그 실패 모양이었다. 실측 시점 잔여 38건은 담당 이슈별로 등재했고(#789 26 · #957 11 · #790 1), 해소된 항목이 목록에 남아 있어도 실패시켜 목록이 썩지 않게 했다. ## 낡은 주석 정리 `Detail.kt` 의 카테고리별 non-null 설명, `ReceiverRequest 는 이 필드 하나뿐`, `name·relation·phone 은 실리지 않아 빈 문자열 — BE #81 대기` 는 전부 현재 계약과 어긋나 삭제했다. 계약의 정본은 Swagger·BE 이고 FE 가 복제하면 두 곳이 갈린다. wire 이름과 도메인 이름이 다른 이유, 매퍼 경계에서 식별자 없는 수신자를 거르는 결정처럼 명세를 봐도 모르는 FE 판단만 남긴다. `LeaveMessageBlockContractTest` 픽스처는 2026-08-07 캡처라 `isDraft` 도입 전이다 — 그 키만 보강했다. --- .../AfternoteAuthorExtendedAndroidTest.kt | 2 +- .../afternote/data/dto/AfternoteDto.kt | 6 +- .../mapper/response/AfternoteDetailMapper.kt | 4 +- .../AfternoteListItemDtoToDomainTest.kt | 3 + .../mapper/LeaveMessageBlockContractTest.kt | 8 +- .../response/AfternoteDetailMapperTest.kt | 20 ++- .../afternote/domain/model/author/Detail.kt | 19 +-- .../detail/AfternoteDetailSuccessMapper.kt | 2 +- .../AfternoteEditorReceiverPrefillTest.kt | 4 +- .../konsist/ResponseDtoContractKonsistTest.kt | 143 ++++++++++++++++++ 10 files changed, 175 insertions(+), 36 deletions(-) create mode 100644 konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt 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 147f4dcbb9..8b78edc0a3 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -626,7 +626,7 @@ private fun authorDetail(): Detail = id = 73L, category = "SOCIAL", title = "Instagram", - timestamps = DetailTimestamps(createdAt = "2026.08.20", updatedAt = "2026.08.22"), + timestamps = DetailTimestamps(updatedAt = "2026.08.22"), type = AfternoteType.SOCIAL_NETWORK, credentials = DetailCredentials(id = "old@example.test", password = "old-password"), receivers = 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 60cf5f9c20..f77f706d92 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 @@ -51,10 +51,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/response/AfternoteDetailMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/response/AfternoteDetailMapper.kt index e41ca7d606..d4ee1e7a73 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/response/AfternoteDetailMapper.kt @@ -32,11 +32,10 @@ fun AfternoteDetailDto.toDetailDomain(): Detail = // 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), ) @@ -63,7 +62,6 @@ private fun AfternoteDetailReceiverDto.toDomain(): DetailReceiver? = receiverId = id, name = name ?: "", relation = relation ?: "", - phone = phone ?: "", ) } 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/AfternoteListItemDtoToDomainTest.kt index 2c2e0cad79..306d71f96b 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/AfternoteListItemDtoToDomainTest.kt @@ -19,6 +19,7 @@ class AfternoteListItemDtoToDomainTest { title = "은행 계정", category = "SOCIAL", createdAt = "2025-11-26T14:30:00", + isDraft = false, ).toDomain() assertEquals(7L, result.id) @@ -60,10 +61,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 8b20c437e3..071c0a182d 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 @@ -31,12 +31,14 @@ 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"}], @@ -60,7 +62,7 @@ class LeaveMessageBlockContractTest { val detail = json .decodeFromString( - """{"afternoteId":11,"category":"SOCIAL","title":"t","leaveMessage":[{"title":"","body":"재현용 남기실 말씀"}]}""", + """{"afternoteId":11,"category":"SOCIAL","title":"t","isDraft":false,"receivers":[],"leaveMessage":[{"title":"","body":"재현용 남기실 말씀"}]}""", ).toDetailDomain() assertEquals( @@ -74,7 +76,7 @@ class LeaveMessageBlockContractTest { val detail = json .decodeFromString( - """{"afternoteId":1,"category":"SOCIAL","title":"t","leaveMessage":null}""", + """{"afternoteId":1,"category":"SOCIAL","title":"t","isDraft":false,"receivers":[],"leaveMessage":null}""", ).toDetailDomain() assertTrue(detail.leaveMessageBlocks.isEmpty()) 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/response/AfternoteDetailMapperTest.kt index 06d09284c5..c2d9636d5d 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/response/AfternoteDetailMapperTest.kt @@ -23,6 +23,8 @@ class AfternoteDetailMapperTest { fun `toDetailDomain - 최소 응답은 nullable이 비거나 null`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", @@ -37,17 +39,17 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - timestamps 포맷`() { + fun `toDetailDomain - updatedAt 포맷`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", - createdAt = "2025-11-26T14:30:00", updatedAt = "2025-12-01T09:00:00", ).toDetailDomain() - assertEquals("2025.11.26", result.timestamps.createdAt) assertEquals("2025.12.01", result.timestamps.updatedAt) } @@ -55,6 +57,7 @@ class AfternoteDetailMapperTest { fun `toDetailDomain - receiver의 null 필드는 빈 문자열`() { val result = AfternoteDetailDto( + isDraft = false, afternoteId = 1L, category = "GALLERY", title = "t", @@ -65,17 +68,16 @@ class AfternoteDetailMapperTest { 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 없는 항목은 도메인으로 올리지 않는다`() { val result = AfternoteDetailDto( + isDraft = false, afternoteId = 1L, category = "GALLERY", title = "t", @@ -93,6 +95,8 @@ class AfternoteDetailMapperTest { fun `toDetailDomain - credentials 매핑`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "SOCIAL", title = "t", @@ -107,6 +111,8 @@ class AfternoteDetailMapperTest { fun `toDetailDomain - playlist 미디어·곡 매핑`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", @@ -136,6 +142,8 @@ class AfternoteDetailMapperTest { fun `toDetailDomain - memorialVideo null이면 video thumbnail null`() { val result = AfternoteDetailDto( + isDraft = false, + receivers = emptyList(), afternoteId = 1L, category = "PLAYLIST", title = "t", 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 6ebde02c56..cb2777a3e1 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 @@ -4,15 +4,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.playlist.MemorialDetail -/** - * 애프터노트 상세 도메인 모델. - * - * 서버 GET /api/afternotes/{id} 응답을 도메인 계층으로 매핑한 형태. - * 카테고리별로 관련 필드만 non-null: - * - SOCIAL: credentials, processingMethods - * - GALLERY: receivers, processingMethods - * - MEMORIAL: memorial - */ +/** 애프터노트 상세 도메인 모델. */ data class Detail( val id: Long, val category: String, @@ -23,13 +15,11 @@ data class Detail( val receivers: List, /** 사후 처리 방법 선택지 — 서버 `actions` 필드. 화면·폼 어휘("처리 방법")에 맞춘 이름이다. */ val processingMethods: List, - /** 서버 `leaveMessage` 배열 — 값이 없으면 빈 목록이다. */ val leaveMessageBlocks: List, val memorial: MemorialDetail?, ) data class DetailTimestamps( - val createdAt: String, val updatedAt: String, ) @@ -41,15 +31,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 5ee6097334..c72a78a82d 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 @@ -9,7 +9,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/AfternoteEditorReceiverPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorReceiverPrefillTest.kt index 614c748f52..514573ae3b 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,14 +44,14 @@ 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, category = "GALLERY_AND_FILES", title = "구글 포토", - timestamps = DetailTimestamps(createdAt = "2026-08-02", updatedAt = "2026-08-02"), + timestamps = DetailTimestamps(updatedAt = "2026-08-02"), type = AfternoteType.entries.first(), credentials = null, receivers = receivers.toList(), 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..596fb6054a --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt @@ -0,0 +1,143 @@ +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] + * #676 실측 시점에 남아 있던 38건. 모듈별 적용 이슈가 해소하며 목록에서 뺀다. + * **목록에 있는데 이미 고쳐진 항목도 실패**시킨다 — 그래야 목록이 썩지 않는다. + */ +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 로 선언한다.") + } + } + } + + @Test + fun `해소된 항목은 예외 목록에서 뺀다`() { + val stale = KNOWN_COERCING_DEFAULTS - coercingDefaults() + + check(stale.isEmpty()) { + 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 (PR #933 진행 중, 26건) */ + private val MINDRECORD = + setOf( + "WeeklyReportDto.dailyQuestionAmount", + "WeeklyReportDto.diaryAmount", + "WeeklyReportDto.summaryText", + "WeeklyReportDto.week", + "WeeklyReportDto.dailyQuestions", + "WeeklyReportDto.emotions", + "WeeklyReportDailyQuestionDto.title", + "WeeklyReportDailyQuestionDto.content", + "WeeklyReportDailyQuestionDto.date", + "WeeklyReportEmotionDto.keyword", + "WeeklyReportEmotionDto.percentage", + "DiaryListItemDto.title", + "DiaryListItemDto.content", + "DiaryListItemDto.createdAt", + "DiaryListItemDto.isDraft", + "DiaryListDto.diaries", + "DiaryListDto.monthDiaryCount", + "ReceiverDailyQuestionListDto.dailyQuestions", + "ReceiverDiaryListDto.diaries", + "ReceiverDiaryItemDto.isDraft", + "ReceiverDiaryItemDto.date", + "ReceiverDiaryItemDto.createdAt", + "ReceiverDiaryItemDto.updatedAt", + "DailyQuestionListItemDto.isDraft", + "TodayDailyQuestionDto.isAnswered", + "TodayDailyQuestionDto.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 + } +} From 2f83ee5ce0ac083c009d124ecde06b8547b7c47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:06:09 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(konsist):=20=ED=95=B4=EC=86=8C?= =?UTF-8?q?=EB=90=9C=20=EC=98=88=EC=99=B8=20=ED=95=AD=EB=AA=A9=EC=9D=80=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=EB=A1=9C=20=EC=95=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「항목을 해소하는 PR」 과 「목록에서 빼는 PR」 은 서로 다른 브랜치라 충돌이 나지 않는다. 그래서 어느 쪽이 먼저 머지되든 그 순간부터 develop 의 `:konsist:test` 가 red 가 되고, 필수 체크가 `guard` 하나뿐이라 머지를 막지도 못한 채 red 만 남는다. #933(#789 의 26건 해소)이 먼저 머지되며 실제로 그렇게 됐다. 목록이 썩는 것을 막는 값어치는 「신규 추가 금지」 쪽이 순서와 무관하게 담당하므로 그쪽만 실패시키고, stale 은 갱신 시점을 알리는 경고로 낮춘다. 경고가 CI 로그에 남도록 konsist 테스트의 표준 출력을 켠다. #933 으로 해소된 MINDRECORD 26건은 목록에서 뺀다. --- konsist/build.gradle.kts | 5 ++ .../konsist/ResponseDtoContractKonsistTest.kt | 59 +++++++------------ 2 files changed, 25 insertions(+), 39 deletions(-) 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 index 596fb6054a..5eb2f6fa20 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt @@ -29,8 +29,8 @@ import org.junit.Test * non-null 로 선언해 놓고 기본값으로 누락을 메우는, 서로 모순된 조합뿐이다. * * ### [KNOWN_COERCING_DEFAULTS] - * #676 실측 시점에 남아 있던 38건. 모듈별 적용 이슈가 해소하며 목록에서 뺀다. - * **목록에 있는데 이미 고쳐진 항목도 실패**시킨다 — 그래야 목록이 썩지 않는다. + * 실측 시점에 남아 있던 잔여. 모듈별 적용 이슈가 해소하며 목록에서 뺀다. + * 이미 고쳐진 항목이 목록에 남아 있으면 **경고만** 낸다 — 이유는 아래 「해소된 항목은 경고로 알린다」 주석 참고. */ class ResponseDtoContractKonsistTest { @Test @@ -49,18 +49,30 @@ class ResponseDtoContractKonsistTest { } } + /** + * 해소된 항목이 목록에 남아 있으면 **경고만** 낸다. + * + * 실패시키면 「항목을 해소하는 PR」 과 「목록에서 빼는 PR」 이 서로를 깨뜨린다. 둘은 다른 + * 브랜치라 충돌도 나지 않고, 어느 쪽이 먼저 머지되든 그 순간부터 develop 의 `:konsist:test` + * 가 red 가 된다. 필수 체크가 `guard` 하나뿐이라 머지를 막지도 못한 채 red 만 남는다. + * #933(#789 의 26건 해소)과 이 목록 사이에서 실제로 그렇게 됐다. + * + * 목록이 썩는 것을 막는 값어치는 「신규 추가 금지」 쪽이 이미 담당한다 — 순서와 무관하게 + * 작동하는 그쪽만 실패시키고, 이쪽은 갱신 시점을 알리는 데 그친다. + */ @Test - fun `해소된 항목은 예외 목록에서 뺀다`() { + fun `해소된 항목은 경고로 알린다`() { val stale = KNOWN_COERCING_DEFAULTS - coercingDefaults() + if (stale.isEmpty()) return - check(stale.isEmpty()) { + println( buildString { - appendLine("KNOWN_COERCING_DEFAULTS 에 이미 해소된 항목이 남아 있다 (${stale.size}건).") + appendLine("[경고] KNOWN_COERCING_DEFAULTS 에 이미 해소된 항목이 남아 있다 (${stale.size}건).") appendLine("목록에서 지워야 다음 위반이 이 자리에 숨지 않는다.") appendLine() stale.sorted().forEach { appendLine(" $it") } - } - } + }, + ) } private fun coercingDefaults(): Set = @@ -88,37 +100,6 @@ class ResponseDtoContractKonsistTest { /** 아웃바운드. `receivers = emptyList()`("미선택 = 빈 배열 전송") 같은 의도된 기본값이라 제외. */ const val REQUEST_DTO = "Request" - /** #789 — mindrecord (PR #933 진행 중, 26건) */ - private val MINDRECORD = - setOf( - "WeeklyReportDto.dailyQuestionAmount", - "WeeklyReportDto.diaryAmount", - "WeeklyReportDto.summaryText", - "WeeklyReportDto.week", - "WeeklyReportDto.dailyQuestions", - "WeeklyReportDto.emotions", - "WeeklyReportDailyQuestionDto.title", - "WeeklyReportDailyQuestionDto.content", - "WeeklyReportDailyQuestionDto.date", - "WeeklyReportEmotionDto.keyword", - "WeeklyReportEmotionDto.percentage", - "DiaryListItemDto.title", - "DiaryListItemDto.content", - "DiaryListItemDto.createdAt", - "DiaryListItemDto.isDraft", - "DiaryListDto.diaries", - "DiaryListDto.monthDiaryCount", - "ReceiverDailyQuestionListDto.dailyQuestions", - "ReceiverDiaryListDto.diaries", - "ReceiverDiaryItemDto.isDraft", - "ReceiverDiaryItemDto.date", - "ReceiverDiaryItemDto.createdAt", - "ReceiverDiaryItemDto.updatedAt", - "DailyQuestionListItemDto.isDraft", - "TodayDailyQuestionDto.isAnswered", - "TodayDailyQuestionDto.isDraft", - ) - /** #957 — afternote (11건). `AfternoteDetailDto.createdAt` 은 #676 에서 해소(BE 응답에 없는 필드라 삭제). */ private val AFTERNOTE = setOf( @@ -138,6 +119,6 @@ class ResponseDtoContractKonsistTest { /** #790 — timeletter (1건) */ private val TIMELETTER = setOf("ReceivedTimeLetterDto.blocks") - val KNOWN_COERCING_DEFAULTS = MINDRECORD + AFTERNOTE + TIMELETTER + val KNOWN_COERCING_DEFAULTS = AFTERNOTE + TIMELETTER } } From 95a37f44f21ba00d96c38889d0ec8baf2dd117e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:06:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor(mindrecord):=20=EB=8D=B0=EC=9D=BC?= =?UTF-8?q?=EB=A6=AC=EC=A7=88=EB=AC=B8=20=EB=8B=B5=EB=B3=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EC=9D=98=20=EB=B3=B4=EC=A0=95=ED=98=95=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B1=B7=EC=96=B4?= =?UTF-8?q?=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE `DailyQuestionAnswerResponse` record 에 content·isDraft 가 있고 Jackson 의 NON_NULL 설정이 없어 두 키는 항상 나간다. content 는 `UserDailyQuestion.content` 컬럼이 nullable = false 라 값도 항상 있다 (Afternote-BE main 0fb9e0b 실코드). `= ""` · `= false` 는 서버가 키를 빼도 파싱을 성공시켜 계약 누락을 은폐한다. #933 이 같은 파일의 나머지 DTO 를 같은 원칙으로 정리했지만, 이 DTO 는 #573 이 뒤에 머지돼 남아 있었다 — 이 PR 이 들이는 가드가 처음 잡아냈다. --- .../afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index b379db34d8..36b55a9c8f 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt @@ -90,6 +90,6 @@ data class TodayDailyQuestionDto( @Serializable data class DailyQuestionAnswerResponseDto( @SerialName("userDailyQuestionId") val userDailyQuestionId: Long, - @SerialName("content") val content: String = "", - @SerialName("isDraft") val isDraft: Boolean = false, + @SerialName("content") val content: String, + @SerialName("isDraft") val isDraft: Boolean, ) From a3c983bf8520266d9c4e6fccf72a714c7655831a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:20:13 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(test):=20DetailReceiver.phone=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EB=A5=BC=20androidTest=20=ED=94=BD=EC=8A=A4?= =?UTF-8?q?=EC=B2=98=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 PR 이 `DetailReceiver` 에서 `phone` 을 걷어냈는데 `AfternoteAuthorExtendedAndroidTest` 픽스처 한 곳이 남아 `:app:compileDebugAndroidTestKotlin` 이 깨졌다. develop(`af3f4610`) 도 같은 태스크가 21건 깨져 있어(#949 몫) 이 한 건이 그 잡음에 묻혔다. 오류 집합을 develop 과 대조해 증분이 이 한 건임을 확인했고, 제거 후 두 집합이 같아졌다. --- .../afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt | 1 - 1 file changed, 1 deletion(-) 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 550f2b8779..df7fa9d357 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -600,7 +600,6 @@ private fun authorDetail(): Detail = receiverId = 7L, name = "김수신", relation = "가족", - phone = "", ), ), processingMethods = listOf("계정 삭제"), From 1ac702d53d76d5d412d6a27fd4d845f1939158f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:46:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor(konsist):=20mindrecord=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=202=EA=B1=B4=EC=9D=80=20=EA=B3=A0=EC=B9=98=EC=A7=80?= =?UTF-8?q?=20=EB=A7=90=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=93=B1?= =?UTF-8?q?=EC=9E=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DailyQuestionAnswerResponseDto.content`·`isDraft` 의 보정형 기본값을 직접 걷어냈던 것을 되돌리고(`95a37f44`), 다른 모듈 잔여와 같은 방식으로 `KNOWN_COERCING_DEFAULTS` 에 올린다. mindrecord 는 이 PR 의 소관이 아니고, 해소는 담당 이슈(#789)가 한다. 이 PR 이 건드리는 파일에서 mindrecord 는 빠졌다 — `DailyQuestionDto.kt` 는 develop 과 동일하다. --- .../feature/mindrecord/data/dto/DailyQuestionDto.kt | 4 ++-- .../konsist/ResponseDtoContractKonsistTest.kt | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index 36b55a9c8f..b379db34d8 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt @@ -90,6 +90,6 @@ data class TodayDailyQuestionDto( @Serializable data class DailyQuestionAnswerResponseDto( @SerialName("userDailyQuestionId") val userDailyQuestionId: Long, - @SerialName("content") val content: String, - @SerialName("isDraft") val isDraft: Boolean, + @SerialName("content") val content: String = "", + @SerialName("isDraft") val isDraft: Boolean = false, ) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt index 5eb2f6fa20..fa451a4c54 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ResponseDtoContractKonsistTest.kt @@ -100,6 +100,16 @@ class ResponseDtoContractKonsistTest { /** 아웃바운드. `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( @@ -119,6 +129,6 @@ class ResponseDtoContractKonsistTest { /** #790 — timeletter (1건) */ private val TIMELETTER = setOf("ReceivedTimeLetterDto.blocks") - val KNOWN_COERCING_DEFAULTS = AFTERNOTE + TIMELETTER + val KNOWN_COERCING_DEFAULTS = MINDRECORD + AFTERNOTE + TIMELETTER } } From a3b85bb902e12707b220a1103354907f98222f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 20:22:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor(afternote):=20data/mapper=20?= =?UTF-8?q?=EB=A5=BC=20=EB=A0=88=ED=8F=AC=20=ED=91=9C=EC=A4=80=20=EB=AA=85?= =?UTF-8?q?=EB=AA=85=C2=B7=ED=8F=89=EB=A9=B4=20=EB=B0=B0=EC=B9=98=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 방향(도메인 → wire) 매핑이 InputToDto·PlaylistInputToDto·InputToRequest 세 파일로 갈려 매핑 하나를 찾으려면 셋을 다 열어야 했다. 파일명 패턴도 InputTo*·*DtoToDomain·*Utils 로 레포 표준(*Mapper)과 어긋나 있었다. - 요청 매퍼 3파일을 AfternoteRequestMapper.kt 하나로 통합 - AfternoteListItemDtoToDomain.kt -> AfternoteListItemMapper.kt - mapper/response/ 를 없애고 AfternoteDetailMapper.kt 를 평면으로 옮김. mindrecord·timeletter 가 쓰는 배치라, 대칭인 request/ 를 새로 만드는 쪽이 아니라 평면으로 수렴하는 쪽이 표준이다 - MappingUtils.kt -> ServerDateFormat.kt. categoryToAfternoteType 은 값 표 정본인 AfternoteCategoryCodec.kt 옆으로 옮겨 KDoc 의 정본 안내가 불필요해졌다 - toDetailDomain() -> toDomain() (receiver·mindrecord·timeletter 와 같은 이름) 함수명 toDto/toRequest 는 통일하지 않았다. 레포 표준에서 둘은 역할이 다르다 -- toRequest 는 요청 바디 루트(*RequestDto), toDto 는 그 안에 실리는 부분 DTO 이고 mindrecord DiaryMapper.kt 가 한 파일에서 둘을 같이 쓴다. afternote 의 InputToDto(부분)/InputToRequest(루트) 분업은 이미 그 규약과 일치해, 하나로 합치면 오히려 표준에서 이탈한다. List?.toDto() 와 List.toDto() 는 제네릭 소거 후 JVM 시그니처가 toDto(List):List 로 같아 한 파일에 둘 수 없다 -- 파일이 갈려 있던 것이 이 충돌을 우회하고 있었다. 각각 호출부가 1곳뿐이라 map { it.toDto() } 로 인라인했다. AfternoteCategoryCodec 의 "BUSINESS·ESTATE 는 서버에 없고 보내면 400" 주석은 BE 가 2026-08-13(Afternote-BE 78ee857)에 BUSINESS 를 추가해 거짓이 됐으므로 걷어냈다. 그 낡음이 만든 동작 결함(사업자 애프터노트가 소셜로 둔갑)은 #1048 로 분리했다. --- .../data/mapper/AfternoteCategoryCodec.kt | 4 +- .../{response => }/AfternoteDetailMapper.kt | 7 +-- ...ToDomain.kt => AfternoteListItemMapper.kt} | 0 ...ToRequest.kt => AfternoteRequestMapper.kt} | 47 ++++++++++++++++++- .../afternote/data/mapper/InputToDto.kt | 30 ------------ .../afternote/data/mapper/MappingUtils.kt | 19 -------- .../data/mapper/PlaylistInputToDto.kt | 26 ---------- .../afternote/data/mapper/ServerDateFormat.kt | 10 ++++ .../author/AfternoteRepositoryImpl.kt | 4 +- .../AfternoteDetailMapperTest.kt | 32 ++++++------- ...Test.kt => AfternoteListItemMapperTest.kt} | 2 +- .../mapper/LeaveMessageBlockContractTest.kt | 7 ++- feature/receiver/data/build.gradle.kts | 2 +- 13 files changed, 84 insertions(+), 106 deletions(-) rename feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/{response => }/AfternoteDetailMapper.kt (89%) rename feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/{AfternoteListItemDtoToDomain.kt => AfternoteListItemMapper.kt} (100%) rename feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/{InputToRequest.kt => AfternoteRequestMapper.kt} (61%) delete mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/InputToDto.kt delete mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt delete mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt create mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ServerDateFormat.kt rename feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/{response => }/AfternoteDetailMapperTest.kt (84%) rename feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/{AfternoteListItemDtoToDomainTest.kt => AfternoteListItemMapperTest.kt} (98%) 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 66b03a4631..a921648d53 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 @@ -4,7 +4,6 @@ import com.afternote.feature.afternote.domain.AfternoteType /** * 서버 `category`(백엔드 `AfternoteCategoryType`) 값의 정본 — 도메인·화면은 [AfternoteType] 만 다룬다. - * 서버가 정의하지 않은 종류(BUSINESS: Afternote-BE#110, ESTATE: #491)는 여기 없고, 보내면 400 이다. */ private val serverCategoryByType: Map = mapOf( @@ -23,3 +22,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 e41ca7d606..8d714ac6e7 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,13 +1,10 @@ -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 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.categoryToAfternoteType -import com.afternote.feature.afternote.data.mapper.formatDateFromServer -import com.afternote.feature.afternote.data.mapper.toLeaveMessageBlocks import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver @@ -16,7 +13,7 @@ import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialDetail import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia -fun AfternoteDetailDto.toDetailDomain(): Detail = +fun AfternoteDetailDto.toDomain(): Detail = Detail( id = afternoteId, category = category, 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 61% 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 c9282ad44f..562d121efb 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,12 +3,25 @@ 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.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( @@ -17,7 +30,7 @@ fun AfternoteUpdatePayload.toRequest() = processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDto(), credentials = credentials?.toDto(), - receivers = receivers?.toDto(), + receivers = receivers?.map { it.toDto() }, memorial = memorial?.toDto(), ) @@ -58,3 +71,35 @@ fun CreateMemorialPayload.toRequest() = memorial = memorial.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( + id = id, + 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/MappingUtils.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt deleted file mode 100644 index 7fcb36b5cf..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/MappingUtils.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.afternote.feature.afternote.data.mapper - -import com.afternote.feature.afternote.domain.AfternoteType - -fun formatDateFromServer(serverDateTime: String): String = - try { - // Server returns "2025-11-26T14:30:00" -> display "2025.11.26" - val datePart = serverDateTime.substringBefore('T') - datePart.replace('-', '.') - } catch (_: Exception) { - serverDateTime - } - -/** - * 서버 `category` → [AfternoteType]. 대응이 없으면 [AfternoteType.SOCIAL_NETWORK] 로 폴백한다. - * - * 값 표는 [afternoteTypeFromServerCategory] 가 정본이다 — 여기에 다시 적으면 갈라진다. - */ -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/PlaylistInputToDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt deleted file mode 100644 index 9e081f6a69..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/PlaylistInputToDto.kt +++ /dev/null @@ -1,26 +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( - id = id, - 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/ServerDateFormat.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ServerDateFormat.kt new file mode 100644 index 0000000000..2f66248f02 --- /dev/null +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ServerDateFormat.kt @@ -0,0 +1,10 @@ +package com.afternote.feature.afternote.data.mapper + +fun formatDateFromServer(serverDateTime: String): String = + try { + // Server returns "2025-11-26T14:30:00" -> display "2025.11.26" + val datePart = serverDateTime.substringBefore('T') + datePart.replace('-', '.') + } catch (_: Exception) { + serverDateTime + } 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 2043fc57df..e94435283a 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 @@ -55,7 +55,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 84% 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 06d09284c5..f40623c91a 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 @@ -13,20 +13,20 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * [AfternoteDetailDto.toDetailDomain] 회귀 가드 (작성자 상세). + * [AfternoteDetailDto.toDomain] 회귀 가드 (작성자 상세). * 핵심 경계: receivers null→emptyList, receiver 필드 null→"", processingMethods null→emptyList, * memorialVideo null→video/thumbnail null, * credentials·memorial nullable 매핑. */ class AfternoteDetailMapperTest { @Test - fun `toDetailDomain - 최소 응답은 nullable이 비거나 null`() { + fun `toDomain - 최소 응답은 nullable이 비거나 null`() { val result = AfternoteDetailDto( afternoteId = 1L, category = "SOCIAL", title = "t", - ).toDetailDomain() + ).toDomain() assertEquals(1L, result.id) assertEquals(AfternoteType.SOCIAL_NETWORK, result.type) @@ -37,7 +37,7 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - timestamps 포맷`() { + fun `toDomain - timestamps 포맷`() { val result = AfternoteDetailDto( afternoteId = 1L, @@ -45,21 +45,21 @@ class AfternoteDetailMapperTest { 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( afternoteId = 1L, category = "GALLERY", title = "t", receivers = listOf(AfternoteDetailReceiverDto(receiverId = 5L)), - ).toDetailDomain() + ).toDomain() val receiver = result.receivers.single() assertEquals(5L, receiver.receiverId) @@ -73,7 +73,7 @@ class AfternoteDetailMapperTest { * 이 필드 하나뿐). 도메인은 non-null 이므로 경계인 이 매퍼가 걸러야 한다. */ @Test - fun `toDetailDomain - receiverId 없는 항목은 도메인으로 올리지 않는다`() { + fun `toDomain - receiverId 없는 항목은 도메인으로 올리지 않는다`() { val result = AfternoteDetailDto( afternoteId = 1L, @@ -84,27 +84,27 @@ 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( afternoteId = 1L, category = "SOCIAL", title = "t", credentials = AfternoteCredentialsDto(id = "user", password = "pw"), - ).toDetailDomain() + ).toDomain() assertEquals("user", result.credentials!!.id) assertEquals("pw", result.credentials!!.password) } @Test - fun `toDetailDomain - playlist 미디어·곡 매핑`() { + fun `toDomain - playlist 미디어·곡 매핑`() { val result = AfternoteDetailDto( afternoteId = 1L, @@ -116,7 +116,7 @@ class AfternoteDetailMapperTest { songs = listOf(AfternoteSongDto(id = 3L, title = "s", artist = "a")), memorialVideo = AfternoteMemorialVideoDto(videoUrl = "v.mp4", thumbnailUrl = "t.jpg"), ), - ).toDetailDomain() + ).toDomain() val media = result.memorial!!.media assertEquals("memorial.jpg", media.photoUrl) @@ -133,14 +133,14 @@ class AfternoteDetailMapperTest { } @Test - fun `toDetailDomain - memorialVideo null이면 video thumbnail null`() { + fun `toDomain - memorialVideo null이면 video thumbnail null`() { val result = AfternoteDetailDto( afternoteId = 1L, category = "PLAYLIST", title = "t", memorial = AfternotePlaylistDto(memorialVideo = null), - ).toDetailDomain() + ).toDomain() val media = result.memorial!!.media assertNull(media.videoUrl) 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 98% 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 2c2e0cad79..8628eaadf5 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 @@ -10,7 +10,7 @@ import org.junit.Test * 작성자 목록 DTO→[com.afternote.feature.afternote.domain.model.author.ListItem] 매핑 + * 공유 헬퍼([formatDateFromServer]·[categoryToAfternoteType])의 경계 동작을 toDomain 경유로 검증. */ -class AfternoteListItemDtoToDomainTest { +class AfternoteListItemMapperTest { @Test fun `toDomain - 필드와 Long id를 그대로 매핑`() { val result = 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..16dcb51009 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 @@ -3,7 +3,6 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.AfternoteCreateAccountRequestDto 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.receiver.data.dto.ReceivedAfternoteDetailDto import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto @@ -44,7 +43,7 @@ class LeaveMessageBlockContractTest { "playlist":null,"updatedAt":"2026-08-07T06:21:14.553567"} """.trimIndent() - val detail = json.decodeFromString(response).toDetailDomain() + val detail = json.decodeFromString(response).toDomain() assertEquals( listOf( @@ -62,7 +61,7 @@ class LeaveMessageBlockContractTest { json .decodeFromString( """{"afternoteId":11,"category":"SOCIAL","title":"t","leaveMessage":[{"title":"","body":"재현용 남기실 말씀"}]}""", - ).toDetailDomain() + ).toDomain() assertEquals( listOf(LeaveMessageBlock(title = "", body = "재현용 남기실 말씀")), @@ -76,7 +75,7 @@ class LeaveMessageBlockContractTest { json .decodeFromString( """{"afternoteId":1,"category":"SOCIAL","title":"t","leaveMessage":null}""", - ).toDetailDomain() + ).toDomain() assertTrue(detail.leaveMessageBlocks.isEmpty()) } diff --git a/feature/receiver/data/build.gradle.kts b/feature/receiver/data/build.gradle.kts index 5777c4f4fb..83fb8d0b6e 100644 --- a/feature/receiver/data/build.gradle.kts +++ b/feature/receiver/data/build.gradle.kts @@ -13,7 +13,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) From 453ea05eca17b168d34b6feaa80e7b1ed2b1058d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 27 Aug 2026 08:54:35 +0900 Subject: [PATCH 7/7] =?UTF-8?q?test(afternote):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=8A=A4=ED=83=AC=ED=94=84=20=ED=94=BD?= =?UTF-8?q?=EC=8A=A4=EC=B2=98=EB=A5=BC=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../author/editor/AfternoteEditorFormPrefillTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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,